From 7b491fa3a9843cb2059c92a7c99f1876545b1b81 Mon Sep 17 00:00:00 2001 From: Felix Fung Date: Fri, 9 Mar 2012 04:57:05 -0500 Subject: [PATCH] Bug 566746 - Changes to form autocomplete to support new asynchronous FormHistory.jsm module, p=enndeakin,felix, r=dteller --- .../components/satchel/nsFormAutoComplete.js | 261 ++++++++---------- .../satchel/nsFormFillController.cpp | 111 +++++--- .../components/satchel/nsFormFillController.h | 14 + .../satchel/nsIFormAutoComplete.idl | 44 ++- 4 files changed, 238 insertions(+), 192 deletions(-) diff --git a/toolkit/components/satchel/nsFormAutoComplete.js b/toolkit/components/satchel/nsFormAutoComplete.js index af367b90cf2..bdcb3cebf88 100644 --- a/toolkit/components/satchel/nsFormAutoComplete.js +++ b/toolkit/components/satchel/nsFormAutoComplete.js @@ -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 }); + } } }; diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp index cd54a7985f7..8575a9d3211 100644 --- a/toolkit/components/satchel/nsFormFillController.cpp +++ b/toolkit/components/satchel/nsFormFillController.cpp @@ -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 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 inputListAutoComplete = - do_GetService("@mozilla.org/satchel/inputlist-autocomplete;1", &rv); - NS_ENSURE_SUCCESS(rv, rv); +nsresult +nsFormFillController::PerformInputListAutoComplete(nsIAutoCompleteResult* aPreviousResult) +{ + // If an is focused, check if it has a list="" which can + // provide the list of suggestions. - rv = inputListAutoComplete->AutoCompleteSearch(formHistoryResult, - aSearchString, - mFocusedInput, - getter_AddRefs(result)); + nsresult rv; + nsCOMPtr result; - if (mFocusedInput) { - nsCOMPtr list; - mFocusedInput->GetList(getter_AddRefs(list)); + nsCOMPtr 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 node = do_QueryInterface(list); - if (mListNode != node) { - if (mListNode) { - mListNode->RemoveMutationObserver(this); - mListNode = nullptr; - } - if (node) { - node->AddMutationObserverUnlessExists(this); - mListNode = node; - } + if (mFocusedInput) { + nsCOMPtr list; + mFocusedInput->GetList(getter_AddRefs(list)); + + // Add a mutation observer to check for changes to the items in the + // and update the suggestions accordingly. + nsCOMPtr 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 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; - diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h index 66245d8b005..71a1f79279a 100644 --- a/toolkit/components/satchel/nsFormFillController.h +++ b/toolkit/components/satchel/nsFormFillController.h @@ -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 mLoginManager; nsIDOMHTMLInputElement* mFocusedInput; nsINode* mFocusedInputNode; + + // mListNode is a element which, is set, has the form fill controller + // as a mutation observer for it. nsINode* mListNode; nsCOMPtr mFocusedPopup; @@ -87,7 +95,13 @@ protected: //these are used to dynamically update the autocomplete nsCOMPtr mLastSearchResult; + + // The observer passed to StartSearch. It will be notified when the search is + // complete or the data from a datalist changes. nsCOMPtr mLastListener; + + // This is cleared by StopSearch(). + nsCOMPtr mLastFormAutoComplete; nsString mLastSearchString; nsDataHashtable, bool> mPwmgrInputs; diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormAutoComplete.idl index 3a939dac15d..f4ad92c6426 100644 --- a/toolkit/components/satchel/nsIFormAutoComplete.idl +++ b/toolkit/components/satchel/nsIFormAutoComplete.idl @@ -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); };