Bug 566746 - Changes to form autocomplete to support new asynchronous FormHistory.jsm module, p=enndeakin,felix, r=dteller

This commit is contained in:
Felix Fung 2012-03-09 04:57:05 -05:00
parent 6c58e024a0
commit 7b491fa3a9
4 changed files with 238 additions and 192 deletions

View File

@ -10,6 +10,11 @@ const Cr = Components.results;
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
"resource://gre/modules/Deprecated.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
"resource://gre/modules/FormHistory.jsm");
function FormAutoComplete() {
this.init();
}
@ -18,14 +23,6 @@ FormAutoComplete.prototype = {
classID : Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"),
QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormAutoComplete, Ci.nsISupportsWeakReference]),
__formHistory : null,
get _formHistory() {
if (!this.__formHistory)
this.__formHistory = Cc["@mozilla.org/satchel/form-history;1"].
getService(Ci.nsIFormHistory2);
return this.__formHistory;
},
_prefBranch : null,
_debug : true, // mirrors browser.formfill.debug
_enabled : true, // mirrors browser.formfill.enable preference
@ -37,6 +34,13 @@ FormAutoComplete.prototype = {
_boundaryWeight : 25,
_prefixWeight : 5,
// Only one query is performed at a time, which will be stored in _pendingQuery
// 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. The pending query will be an
// mozIStoragePendingStatement object.
_pendingQuery : null,
init : function() {
// Preferences. Add observer so we get notified of changes.
this._prefBranch = Services.prefs.getBranch("browser.formfill.");
@ -50,10 +54,6 @@ FormAutoComplete.prototype = {
this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
this._timeGroupingSize = this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
this._expireDays = this._prefBranch.getIntPref("expire_days");
this._dbStmts = {};
Services.obs.addObserver(this.observer, "profile-before-change", true);
},
observer : {
@ -96,12 +96,6 @@ FormAutoComplete.prototype = {
default:
self.log("Oops! Pref not handled, change ignored.");
}
} else if (topic == "profile-before-change") {
for each (let stmt in self._dbStmts) {
stmt.finalize();
}
self._dbStmts = {};
self.__formHistory = null;
}
}
},
@ -121,33 +115,64 @@ FormAutoComplete.prototype = {
},
autoCompleteSearch : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult) {
Deprecated.warning("nsIFormAutoComplete::autoCompleteSearch is deprecated", "https://bugzilla.mozilla.org/show_bug.cgi?id=697377");
let result = null;
let listener = {
onSearchCompletion: function (r) result = r
};
this._autoCompleteSearchShared(aInputName, aUntrimmedSearchString, aField, aPreviousResult, listener);
// Just wait for the result to to be available.
let thread = Components.classes["@mozilla.org/thread-manager;1"].getService().currentThread;
while (!result && this._pendingQuery) {
thread.processNextEvent(true);
}
return result;
},
autoCompleteSearchAsync : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener) {
this._autoCompleteSearchShared(aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener);
},
/*
* autoCompleteSearch
* autoCompleteSearchShared
*
* aInputName -- |name| attribute from the form input being autocompleted.
* aUntrimmedSearchString -- current value of the input
* aField -- nsIDOMHTMLInputElement being autocompleted (may be null if from chrome)
* aPreviousResult -- previous search result, if any.
*
* Returns: an nsIAutoCompleteResult
* aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult
* that may be returned asynchronously.
*/
autoCompleteSearch : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult) {
_autoCompleteSearchShared : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener) {
function sortBytotalScore (a, b) {
return b.totalScore - a.totalScore;
}
if (!this._enabled)
return null;
let result = null;
if (!this._enabled) {
result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);
if (aListener) {
aListener.onSearchCompletion(result);
}
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');
return null;
result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);
if (aListener) {
aListener.onSearchCompletion(result);
}
return;
}
this.log("AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString);
let searchString = aUntrimmedSearchString.trim().toLowerCase();
let result = null;
// reuse previous results if:
// a) length greater than one character (others searches are special cases) AND
@ -176,145 +201,76 @@ FormAutoComplete.prototype = {
}
filteredEntries.sort(sortBytotalScore);
result.wrappedJSObject.entries = filteredEntries;
if (aListener) {
aListener.onSearchCompletion(result);
}
} else {
this.log("Creating new autocomplete search result.");
let entries = this.getAutoCompleteValues(aInputName, searchString);
result = new FormAutoCompleteResult(this._formHistory, entries, aInputName, aUntrimmedSearchString);
if (aField && aField.maxLength > -1) {
let original = result.wrappedJSObject.entries;
let filtered = original.filter(function (el) el.text.length <= this.maxLength, aField);
result.wrappedJSObject.entries = filtered;
}
}
return result;
// Start with an empty list.
result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);
let processEntry = function(aEntries) {
if (aField && aField.maxLength > -1) {
result.entries =
aEntries.filter(function (el) { return el.text.length <= aField.maxLength; });
} else {
result.entries = aEntries;
}
if (aListener) {
aListener.onSearchCompletion(result);
}
}
this.getAutoCompleteValues(aInputName, searchString, processEntry);
}
},
getAutoCompleteValues : function (fieldName, searchString) {
let values = [];
let searchTokens;
stopAutoCompleteSearch : function () {
if (this._pendingQuery) {
this._pendingQuery.cancel();
this._pendingQuery = null;
}
},
/*
* Get the values for an autocomplete list given a search string.
*
* fieldName - fieldname field within form history (the form input name)
* searchString - string to search for
* 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 : function (fieldName, searchString, callback) {
let params = {
agedWeight: this._agedWeight,
bucketSize: this._bucketSize,
expiryDate: 1000 * (Date.now() - this._expireDays * 24 * 60 * 60 * 1000),
fieldname: fieldName,
maxTimeGroupings: this._maxTimeGroupings,
now: Date.now() * 1000, // convert from ms to microseconds
timeGroupingSize: this._timeGroupingSize
timeGroupingSize: this._timeGroupingSize,
prefixWeight: this._prefixWeight,
boundaryWeight: this._boundaryWeight
}
// only do substring matching when more than one character is typed
let where = ""
let boundaryCalc = "";
if (searchString.length > 1) {
searchTokens = searchString.split(/\s+/);
this.stopAutoCompleteSearch();
// build up the word boundary and prefix match bonus calculation
boundaryCalc = "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
// for each word, calculate word boundary weights for the SELECT clause and
// add word to the WHERE clause of the query
let tokenCalc = [];
for (let i = 0; i < searchTokens.length; i++) {
tokenCalc.push("(value LIKE :tokenBegin" + i + " ESCAPE '/') + " +
"(value LIKE :tokenBoundary" + i + " ESCAPE '/')");
where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') ";
}
// add more weight if we have a traditional prefix match and
// multiply boundary bonuses by boundary weight
boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
params.prefixWeight = this._prefixWeight;
params.boundaryWeight = this._boundaryWeight;
} else if (searchString.length == 1) {
where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
boundaryCalc = "1";
} else {
where = "";
boundaryCalc = "1";
}
/* Three factors in the frecency calculation for an entry (in order of use in calculation):
* 1) average number of times used - items used more are ranked higher
* 2) how recently it was last used - items used recently are ranked higher
* 3) additional weight for aged entries surviving expiry - these entries are relevant
* since they have been used multiple times over a large time span so rank them higher
* The score is then divided by the bucket size and we round the result so that entries
* with a very similar frecency are bucketed together with an alphabetical sort. This is
* to reduce the amount of moving around by entries while typing.
*/
let self = this;
let processResults = {
onSuccess: function(aResults) {
self._pendingQuery = null;
callback(aResults);
},
onFailure: function(aError) {
self.log("getAutocompleteValues failed: " + aError.message);
self._pendingQuery = null;
}
};
let query = "/* do not warn (bug 496471): can't use an index */ " +
"SELECT value, " +
"ROUND( " +
"timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
"MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+
"MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
":bucketSize "+
", 3) AS frecency, " +
boundaryCalc + " AS boundaryBonuses " +
"FROM moz_formhistory " +
"WHERE fieldname=:fieldname " + where +
"ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
let stmt;
try {
stmt = this._dbCreateStatement(query, params);
// Chicken and egg problem: Need the statement to escape the params we
// pass to the function that gives us the statement. So, fix it up now.
if (searchString.length >= 1)
stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%";
if (searchString.length > 1) {
for (let i = 0; i < searchTokens.length; i++) {
let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/");
stmt.params["tokenBegin" + i] = escapedToken + "%";
stmt.params["tokenBoundary" + i] = "% " + escapedToken + "%";
stmt.params["tokenContains" + i] = "%" + escapedToken + "%";
}
} else {
// no addional params need to be substituted into the query when the
// length is zero or one
}
while (stmt.executeStep()) {
let entry = {
text: stmt.row.value,
textLowerCase: stmt.row.value.toLowerCase(),
frecency: stmt.row.frecency,
totalScore: Math.round(stmt.row.frecency * stmt.row.boundaryBonuses)
}
values.push(entry);
}
} catch (e) {
this.log("getValues failed: " + e.name + " : " + e.message);
throw "DB failed getting form autocomplete values";
} finally {
if (stmt) {
stmt.reset();
}
}
return values;
},
_dbStmts : null,
_dbCreateStatement : function (query, params) {
let stmt = this._dbStmts[query];
// Memoize the statements
if (!stmt) {
this.log("Creating new statement for query: " + query);
stmt = this._formHistory.DBConnection.createStatement(query);
this._dbStmts[query] = stmt;
}
// Replace parameters, must be done 1 at a time
if (params) {
let stmtparams = stmt.params;
for (let i in params)
stmtparams[i] = params[i];
}
return stmt;
self._pendingQuery = FormHistory.getAutoCompleteResults(searchString, params, processResults);
},
/*
@ -422,8 +378,11 @@ FormAutoCompleteResult.prototype = {
let [removedEntry] = this.entries.splice(index, 1);
if (removeFromDB)
this.formHistory.removeEntry(this.fieldName, removedEntry.text);
if (removeFromDB) {
this.formHistory.update({ op: "remove",
fieldname: this.fieldName,
value: removedEntry.text });
}
}
};

View File

@ -36,11 +36,12 @@
#include "mozilla/dom/Element.h"
#include "nsContentUtils.h"
NS_IMPL_ISUPPORTS5(nsFormFillController,
NS_IMPL_ISUPPORTS6(nsFormFillController,
nsIFormFillController,
nsIAutoCompleteInput,
nsIAutoCompleteSearch,
nsIDOMEventListener,
nsIFormAutoCompleteObserver,
nsIMutationObserver)
nsFormFillController::nsFormFillController() :
@ -600,11 +601,15 @@ nsFormFillController::StartSearch(const nsAString &aSearchString, const nsAStrin
// XXX aPreviousResult shouldn't ever be a historyResult type, since we're not letting
// satchel manage the field?
rv = mLoginManager->AutoCompleteSearch(aSearchString,
aPreviousResult,
mFocusedInput,
getter_AddRefs(result));
aPreviousResult,
mFocusedInput,
getter_AddRefs(result));
NS_ENSURE_SUCCESS(rv, rv);
if (aListener) {
aListener->OnSearchResult(this, result);
}
} else {
nsCOMPtr<nsIAutoCompleteResult> formHistoryResult;
mLastListener = aListener;
// It appears that mFocusedInput is always null when we are focusing a XUL
// element. Scary :)
@ -613,48 +618,65 @@ nsFormFillController::StartSearch(const nsAString &aSearchString, const nsAStrin
do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv);
NS_ENSURE_SUCCESS(rv, rv);
rv = formAutoComplete->AutoCompleteSearch(aSearchParam,
formAutoComplete->AutoCompleteSearchAsync(aSearchParam,
aSearchString,
mFocusedInput,
aPreviousResult,
getter_AddRefs(formHistoryResult));
this);
mLastFormAutoComplete = formAutoComplete;
} else {
mLastSearchString = aSearchString;
NS_ENSURE_SUCCESS(rv, rv);
// Even if autocomplete is disabled, handle the inputlist anyway as that was
// specifically requested by the page. This is so a field can have the default
// autocomplete disabled and replaced with a custom inputlist autocomplete.
return PerformInputListAutoComplete(aPreviousResult);
}
}
mLastSearchResult = formHistoryResult;
mLastListener = aListener;
mLastSearchString = aSearchString;
return NS_OK;
}
nsCOMPtr <nsIInputListAutoComplete> inputListAutoComplete =
do_GetService("@mozilla.org/satchel/inputlist-autocomplete;1", &rv);
NS_ENSURE_SUCCESS(rv, rv);
nsresult
nsFormFillController::PerformInputListAutoComplete(nsIAutoCompleteResult* aPreviousResult)
{
// If an <input> is focused, check if it has a list="<datalist>" which can
// provide the list of suggestions.
rv = inputListAutoComplete->AutoCompleteSearch(formHistoryResult,
aSearchString,
mFocusedInput,
getter_AddRefs(result));
nsresult rv;
nsCOMPtr<nsIAutoCompleteResult> result;
if (mFocusedInput) {
nsCOMPtr<nsIDOMHTMLElement> list;
mFocusedInput->GetList(getter_AddRefs(list));
nsCOMPtr <nsIInputListAutoComplete> inputListAutoComplete =
do_GetService("@mozilla.org/satchel/inputlist-autocomplete;1", &rv);
NS_ENSURE_SUCCESS(rv, rv);
rv = inputListAutoComplete->AutoCompleteSearch(aPreviousResult,
mLastSearchString,
mFocusedInput,
getter_AddRefs(result));
NS_ENSURE_SUCCESS(rv, rv);
nsCOMPtr<nsINode> node = do_QueryInterface(list);
if (mListNode != node) {
if (mListNode) {
mListNode->RemoveMutationObserver(this);
mListNode = nullptr;
}
if (node) {
node->AddMutationObserverUnlessExists(this);
mListNode = node;
}
if (mFocusedInput) {
nsCOMPtr<nsIDOMHTMLElement> list;
mFocusedInput->GetList(getter_AddRefs(list));
// Add a mutation observer to check for changes to the items in the <datalist>
// and update the suggestions accordingly.
nsCOMPtr<nsINode> node = do_QueryInterface(list);
if (mListNode != node) {
if (mListNode) {
mListNode->RemoveMutationObserver(this);
mListNode = nullptr;
}
if (node) {
node->AddMutationObserverUnlessExists(this);
mListNode = node;
}
}
}
NS_ENSURE_SUCCESS(rv, rv);
aListener->OnSearchResult(this, result);
if (mLastListener) {
mLastListener->OnSearchResult(this, result);
}
return NS_OK;
}
@ -707,9 +729,31 @@ void nsFormFillController::RevalidateDataList()
NS_IMETHODIMP
nsFormFillController::StopSearch()
{
// Make sure to stop and clear this, otherwise the controller will prevent
// mLastFormAutoComplete from being deleted.
if (mLastFormAutoComplete) {
mLastFormAutoComplete->StopAutoCompleteSearch();
mLastFormAutoComplete = nullptr;
}
return NS_OK;
}
////////////////////////////////////////////////////////////////////////
//// nsIFormAutoCompleteObserver
NS_IMETHODIMP
nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult *aResult)
{
nsCOMPtr<nsIAutoCompleteResult> resultParam = do_QueryInterface(aResult);
nsAutoString searchString;
resultParam->GetSearchString(searchString);
mLastSearchResult = aResult;
mLastSearchString = searchString;
return PerformInputListAutoComplete(resultParam);
}
////////////////////////////////////////////////////////////////////////
//// nsIDOMEventListener
@ -1178,4 +1222,3 @@ static const mozilla::Module kSatchelModule = {
};
NSMODULE_DEFN(satchel) = &kSatchelModule;

View File

@ -11,6 +11,7 @@
#include "nsIAutoCompleteSearch.h"
#include "nsIAutoCompleteController.h"
#include "nsIAutoCompletePopup.h"
#include "nsIFormAutoComplete.h"
#include "nsIDOMEventListener.h"
#include "nsCOMPtr.h"
#include "nsDataHashtable.h"
@ -33,6 +34,7 @@ class nsFormFillController : public nsIFormFillController,
public nsIAutoCompleteInput,
public nsIAutoCompleteSearch,
public nsIDOMEventListener,
public nsIFormAutoCompleteObserver,
public nsIMutationObserver
{
public:
@ -40,6 +42,7 @@ public:
NS_DECL_NSIFORMFILLCONTROLLER
NS_DECL_NSIAUTOCOMPLETESEARCH
NS_DECL_NSIAUTOCOMPLETEINPUT
NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER
NS_DECL_NSIDOMEVENTLISTENER
NS_DECL_NSIMUTATIONOBSERVER
@ -60,6 +63,8 @@ protected:
void StartControllingInput(nsIDOMHTMLInputElement *aInput);
void StopControllingInput();
nsresult PerformInputListAutoComplete(nsIAutoCompleteResult* aPreviousResult);
void RevalidateDataList();
bool RowMatch(nsFormHistory *aHistory, uint32_t aIndex, const nsAString &aInputName, const nsAString &aInputValue);
@ -79,6 +84,9 @@ protected:
nsCOMPtr<nsILoginManager> mLoginManager;
nsIDOMHTMLInputElement* mFocusedInput;
nsINode* mFocusedInputNode;
// mListNode is a <datalist> element which, is set, has the form fill controller
// as a mutation observer for it.
nsINode* mListNode;
nsCOMPtr<nsIAutoCompletePopup> mFocusedPopup;
@ -87,7 +95,13 @@ protected:
//these are used to dynamically update the autocomplete
nsCOMPtr<nsIAutoCompleteResult> mLastSearchResult;
// The observer passed to StartSearch. It will be notified when the search is
// complete or the data from a datalist changes.
nsCOMPtr<nsIAutoCompleteObserver> mLastListener;
// This is cleared by StopSearch().
nsCOMPtr<nsIFormAutoComplete> mLastFormAutoComplete;
nsString mLastSearchString;
nsDataHashtable<nsPtrHashKey<const nsINode>, bool> mPwmgrInputs;

View File

@ -6,17 +6,47 @@
#include "nsISupports.idl"
interface nsIAutoCompleteResult;
interface nsIFormAutoCompleteObserver;
interface nsIDOMHTMLInputElement;
[scriptable, uuid(997c0c05-5d1d-47e5-9cbc-765c0b8ec699)]
[scriptable, uuid(c079f18f-40ab-409d-800e-878889b83b58)]
interface nsIFormAutoComplete: nsISupports {
/**
* Generate results for a form input autocomplete menu.
* Generate results for a form input autocomplete menu synchronously.
* This method is deprecated in favour of autoCompleteSearchAsync.
*/
nsIAutoCompleteResult autoCompleteSearch(
in AString aInputName,
in AString aSearchString,
in nsIDOMHTMLInputElement aField,
in nsIAutoCompleteResult aPreviousResult);
nsIAutoCompleteResult autoCompleteSearch(in AString aInputName,
in AString aSearchString,
in nsIDOMHTMLInputElement aField,
in nsIAutoCompleteResult aPreviousResult);
/**
* Generate results for a form input autocomplete menu asynchronously.
*/
void autoCompleteSearchAsync(in AString aInputName,
in AString aSearchString,
in nsIDOMHTMLInputElement aField,
in nsIAutoCompleteResult aPreviousResult,
in nsIFormAutoCompleteObserver aListener);
/**
* If a search is in progress, stop it. Otherwise, do nothing. This is used
* to cancel an existing search, for example, in preparation for a new search.
*/
void stopAutoCompleteSearch();
};
[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)]
interface nsIFormAutoCompleteObserver : nsISupports
{
/*
* Called when a search is complete and the results are ready even if the
* result set is empty. If the search is cancelled or a new search is
* started, this is not called.
*
* @param result - The search result object
*/
void onSearchCompletion(in nsIAutoCompleteResult result);
};