diff --git a/modules/libpref/src/init/all.js b/modules/libpref/src/init/all.js index bab5b9b4d40..1c041b107b9 100644 --- a/modules/libpref/src/init/all.js +++ b/modules/libpref/src/init/all.js @@ -2730,9 +2730,11 @@ pref("signon.debug", false); // logs to Error Console pref("browser.formfill.debug", false); pref("browser.formfill.enable", true); pref("browser.formfill.agedWeight", 2); -pref("browser.formfill.bucketSize", 5); +pref("browser.formfill.bucketSize", 1); pref("browser.formfill.maxTimeGroupings", 25); pref("browser.formfill.timeGroupingSize", 604800); +pref("browser.formfill.boundaryWeight", 25); +pref("browser.formfill.prefixWeight", 5); // Zoom prefs pref("browser.zoom.full", false); diff --git a/toolkit/components/satchel/src/nsFormAutoComplete.js b/toolkit/components/satchel/src/nsFormAutoComplete.js index 8d69652dfbf..fc5e6d3aa3f 100644 --- a/toolkit/components/satchel/src/nsFormAutoComplete.js +++ b/toolkit/components/satchel/src/nsFormAutoComplete.js @@ -75,14 +75,16 @@ FormAutoComplete.prototype = { return this.__observerService; }, - _prefBranch : null, - _debug : false, // mirrors browser.formfill.debug - _enabled : true, // mirrors browser.formfill.enable preference - _agedWeight : 2, - _bucketSize : 5, - _maxTimeGroupings : 25, - _timeGroupingSize : 7 * 24 * 60 * 60 * 1000 * 1000, - _expireDays : null, + _prefBranch : null, + _debug : false, // 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, init : function() { // Preferences. Add observer so we get notified of changes. @@ -136,6 +138,12 @@ FormAutoComplete.prototype = { 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."); } @@ -164,46 +172,56 @@ FormAutoComplete.prototype = { * autoCompleteSearch * * aInputName -- |name| attribute from the form input being autocompleted. - * aSearchString -- current value of the input + * aUntrimmedSearchString -- current value of the input * aPreviousResult -- previous search result, if any. * * Returns: an nsIAutoCompleteResult */ - autoCompleteSearch : function (aInputName, aSearchString, aPreviousResult) { + autoCompleteSearch : function (aInputName, aUntrimmedSearchString, aPreviousResult) { + function sortBytotalScore (a, b) { + let x = a.totalScore; + let y = b.totalScore; + return ((x > y) ? -1 : ((x < y) ? 1 : 0)); + } + if (!this._enabled) return null; - this.log("AutoCompleteSearch invoked. Search is: " + aSearchString); - + this.log("AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString); + let searchString = aUntrimmedSearchString.trim().toLowerCase(); let result = null; - if (aPreviousResult && - aSearchString.substr(0, aPreviousResult.searchString.length) == aPreviousResult.searchString) { + // 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.indexOf(aPreviousResult.searchString.trim().toLowerCase()) >= 0) { this.log("Using previous autocomplete result"); result = aPreviousResult; - result.wrappedJSObject.searchString = aSearchString; + result.wrappedJSObject.searchString = aUntrimmedSearchString; + 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. - // Count backwards, because result.matchCount is decremented - // when we remove an entry. - for (let i = result.matchCount - 1; i >= 0; i--) { - let match = result.getValueAt(i); - - // Remove results that are too short, or have different prefix. + // filter them further based on the new search string and add to a new array. + let entries = result.wrappedJSObject.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 (aSearchString.length > match.length || - aSearchString.toLowerCase() != - match.substr(0, aSearchString.length).toLowerCase()) - { - this.log("Removing autocomplete entry '" + match + "'"); - result.removeValueAt(i, false); - } + if(searchTokens.some(function (tok) entry.textLowerCase.indexOf(tok) < 0)) + continue; + this._calculateScore(entry, searchString, searchTokens); + this.log("Reusing autocomplete entry '" + entry.text + + "' (" + entry.frecency +" / " + entry.totalScore + ")"); + filteredEntries.push(entry); } + filteredEntries.sort(sortBytotalScore); + result.wrappedJSObject.entries = filteredEntries; } else { this.log("Creating new autocomplete search result."); - let entries = this.getAutoCompleteValues(aInputName, aSearchString); - result = new FormAutoCompleteResult(this._formHistory, entries, aInputName, aSearchString); + let entries = this.getAutoCompleteValues(aInputName, searchString); + result = new FormAutoCompleteResult(this._formHistory, entries, aInputName, aUntrimmedSearchString); } return result; @@ -211,6 +229,46 @@ FormAutoComplete.prototype = { getAutoCompleteValues : function (fieldName, searchString) { let values = []; + let searchTokens; + + 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 + } + + // only do substring matching when more than one character is typed + let where = "" + let boundaryCalc = ""; + if (searchString.length > 1) { + searchTokens = searchString.split(/\s+/); + + // 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 @@ -220,27 +278,18 @@ FormAutoComplete.prototype = { * 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 query = "SELECT value, " + "ROUND( " + "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " + "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+ "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " + ":bucketSize "+ - ") AS frecency " + + ", 3) AS frecency, " + + boundaryCalc + " AS boundaryBonuses " + "FROM moz_formhistory " + - "WHERE fieldname=:fieldname AND value LIKE :valuePrefix ESCAPE '/' " + - "ORDER BY frecency DESC, UPPER(value) ASC"; - - 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, - valuePrefix: null // set below... - } + "WHERE fieldname=:fieldname " + where + + "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC"; let stmt; try { @@ -248,10 +297,29 @@ FormAutoComplete.prototype = { // 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. - stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%"; + 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.step()) - values.push(stmt.row.value); + while (stmt.step()) { + 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); @@ -290,6 +358,31 @@ FormAutoComplete.prototype = { return prefsBranch.getIntPref("browser.formfill.expire_days"); else return prefsBranch.getIntPref("browser.history_expire_days"); + }, + + /* + * _calculateScore + * + * entry -- an nsIAutoCompleteResult entry + * aSearchString -- current value of the input (lowercase) + * searchTokens -- array of tokens of the search string + * + * Returns: an int + */ + _calculateScore : function (entry, aSearchString, searchTokens) { + let boundaryCalc = 0; + // for each word, calculate word boundary weights + for each (let token in searchTokens) { + boundaryCalc += (entry.textLowerCase.indexOf(token) == 0); + boundaryCalc += (entry.textLowerCase.indexOf(" " + token) >= 0); + } + boundaryCalc = boundaryCalc * this._boundaryWeight; + // now add more weight if we have a traditional prefix match and + // multiply boundary bonuses by boundary weight + boundaryCalc += this._prefixWeight * + (entry.textLowerCase. + indexOf(aSearchString) == 0); + entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc)); } }; // end of FormAutoComplete implementation @@ -303,11 +396,6 @@ function FormAutoCompleteResult (formHistory, entries, fieldName, searchString) this.entries = entries; this.fieldName = fieldName; this.searchString = searchString; - - if (entries.length > 0) { - this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; - this.defaultIndex = 0; - } } FormAutoCompleteResult.prototype = { @@ -332,16 +420,25 @@ FormAutoCompleteResult.prototype = { // Interfaces from idl... searchString : null, - searchResult : Ci.nsIAutoCompleteResult.RESULT_NOMATCH, - defaultIndex : -1, errorDescription : "", + get defaultIndex() { + if (entries.length == 0) + return -1; + else + return 0; + }, + get searchResult() { + if (this.entries.length == 0) + return Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + return Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + }, get matchCount() { return this.entries.length; }, getValueAt : function (index) { this._checkIndexBounds(index); - return this.entries[index]; + return this.entries[index].text; }, getCommentAt : function (index) { @@ -364,11 +461,8 @@ FormAutoCompleteResult.prototype = { let [removedEntry] = this.entries.splice(index, 1); - if (this.defaultIndex > this.entries.length) - this.defaultIndex--; - if (removeFromDB) - this.formHistory.removeEntry(this.fieldName, removedEntry); + this.formHistory.removeEntry(this.fieldName, removedEntry.text); } }; diff --git a/toolkit/components/satchel/test/test_form_autocomplete.html b/toolkit/components/satchel/test/test_form_autocomplete.html index 76b40dec950..b147be11314 100644 --- a/toolkit/components/satchel/test/test_form_autocomplete.html +++ b/toolkit/components/satchel/test/test_form_autocomplete.html @@ -45,6 +45,12 @@ Form History test: form field autocomplete + +
+@@ -74,6 +80,10 @@ fh.addEntry("field3", "aaz"); fh.addEntry("field3", "aa\xe6"); // 0xae == latin ae pair (0xc6 == AE) fh.addEntry("field3", "az"); fh.addEntry("field3", "z"); +fh.addEntry("field4", "a\xe6"); +fh.addEntry("field4", "aa a\xe6"); +fh.addEntry("field4", "aba\xe6"); +fh.addEntry("field4", "bc d\xe6"); // Restore the form to the default state. function restoreForm() { @@ -436,7 +446,7 @@ function runTest(testNum) { break; case 205: - checkMenuEntries(["az"]); + ok(getMenuEntries().length > 0, "checking typing in middle of text"); doKey("left"); sendChar("a", input); break; @@ -460,6 +470,57 @@ function runTest(testNum) { checkMenuEntries([]); doKey("escape"); + // Look at form 6, try to trigger autocomplete popup + input = $_(6, "field4"); + restoreForm(); + testNum = 249; + sendChar("a", input); + break; + + /* Test substring matches and word boundary bonuses */ + + case 250: + // alphabetical results for first character + checkMenuEntries(["aa a\xe6", "aba\xe6", "a\xe6"]); + sendChar("\xc6", input); + break; + + case 251: + // prefix match comes first, then word boundary match + // followed by substring match + checkMenuEntries(["a\xe6", "aa a\xe6", "aba\xe6"]); + + restoreForm(); + sendChar("b", input); + break; + + case 252: + checkMenuEntries(["bc d\xe6"]); + sendChar(" ", input); + break; + + case 253: + // check that trailing space has no effect after single char. + checkMenuEntries(["bc d\xe6"]); + sendChar("\xc6", input); + break; + + case 254: + // check multi-word substring matches + checkMenuEntries(["bc d\xe6", "aba\xe6"]); + doKey("left"); + sendChar("d", input); + break; + + case 255: + // check inserting in multi-word searches + checkMenuEntries(["bc d\xe6"]); + sendChar("z", input); + break; + + case 256: + checkMenuEntries([]); + SimpleTest.finish(); return; diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite new file mode 100644 index 00000000000..5eeab074fd8 Binary files /dev/null and b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite differ diff --git a/toolkit/components/satchel/test/unit/perf_autocomplete.js b/toolkit/components/satchel/test/unit/perf_autocomplete.js new file mode 100644 index 00000000000..278ab88cd35 --- /dev/null +++ b/toolkit/components/satchel/test/unit/perf_autocomplete.js @@ -0,0 +1,173 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Satchel Test Code. + * + * The Initial Developer of the Original Code is + * Mozilla Corporation. + * Portions created by the Initial Developer are Copyright (C) 2009 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Matthew Noorenberghe(Original Author) + * + * Alternatively, the contents of this file may be used under the terms of + * either the GNU General Public License Version 2 or later (the "GPL"), or + * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), + * in which case the provisions of the GPL or the LGPL are applicable instead + * of those above. If you wish to allow use of your version of this file only + * under the terms of either the GPL or the LGPL, and not to allow others to + * use your version of this file under the terms of the MPL, indicate your + * decision by deleting the provisions above and replace them with the notice + * and other provisions required by the GPL or the LGPL. If you do not delete + * the provisions above, a recipient may use your version of this file under + * the terms of any one of the MPL, the GPL or the LGPL. + * + * ***** END LICENSE BLOCK ***** */ + +var testnum = 0; +var fh; +var fac; +var prefs; + +function countAllEntries() { + let stmt = fh.DBConnection.createStatement("SELECT COUNT(*) as numEntries FROM moz_formhistory"); + do_check_true(stmt.step()); + let numEntries = stmt.row.numEntries; + stmt.finalize(); + return numEntries; +} + +function do_AC_search(searchTerm, previousResult) { + var duration = 0; + var searchCount = 5; + var tempPrevious = null; + var startTime; + for (var i = 0; i < searchCount; i++) { + if (previousResult !== null) + tempPrevious = fac.autoCompleteSearch("searchbar-history", previousResult, null, null); + startTime = Date.now(); + results = fac.autoCompleteSearch("searchbar-history", searchTerm, null, tempPrevious); + duration += Date.now() - startTime; + } + dump("[autoCompleteSearch][test " + testnum + "] for '" + searchTerm + "' "); + if (previousResult !== null) + dump("with '" + previousResult + "' previous result "); + else + dump("w/o previous result "); + dump("took " + duration + " ms with " + results.matchCount + " matches. "); + dump("Average of " + Math.round(duration / searchCount) + " ms per search\n"); + return results; +} + +function run_test() { + try { + + // ===== test init ===== + var testfile = do_get_file("formhistory_1000.sqlite"); + var profileDir = dirSvc.get("ProfD", Ci.nsIFile); + var results; + + // Cleanup from any previous tests or failures. + var destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) + destFile.remove(false); + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + fh = Cc["@mozilla.org/satchel/form-history;1"]. + getService(Ci.nsIFormHistory2); + fac = Cc["@mozilla.org/satchel/form-autocomplete;1"]. + getService(Ci.nsIFormAutoComplete); + prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + + timeGroupingSize = prefs.getIntPref("browser.formfill.timeGroupingSize") * 1000 * 1000; + maxTimeGroupings = prefs.getIntPref("browser.formfill.maxTimeGroupings"); + bucketSize = prefs.getIntPref("browser.formfill.bucketSize"); + + // ===== 1 ===== + // Check initial state is as expected + testnum++; + do_check_true(fh.hasEntries); + do_check_eq(1000, countAllEntries()); + fac.autoCompleteSearch("searchbar-history", "zzzzzzzzzz", null, null); // warm-up search + do_check_true(fh.nameExists("searchbar-history")); + + // ===== 2 ===== + // Search for '' with no previous result + testnum++; + results = do_AC_search("", null); + do_check_true(results.matchCount > 0); + + // ===== 3 ===== + // Search for 'r' with no previous result + testnum++; + results = do_AC_search("r", null); + do_check_true(results.matchCount > 0); + + // ===== 4 ===== + // Search for 'r' with '' previous result + testnum++; + results = do_AC_search("r", ""); + do_check_true(results.matchCount > 0); + + // ===== 5 ===== + // Search for 're' with no previous result + testnum++; + results = do_AC_search("re", null); + do_check_true(results.matchCount > 0); + + // ===== 6 ===== + // Search for 're' with 'r' previous result + testnum++; + results = do_AC_search("re", "r"); + do_check_true(results.matchCount > 0); + + // ===== 7 ===== + // Search for 'rea' without previous result + testnum++; + results = do_AC_search("rea", null); + let countREA = results.matchCount; + + // ===== 8 ===== + // Search for 'rea' with 're' previous result + testnum++; + results = do_AC_search("rea", "re"); + do_check_eq(countREA, results.matchCount); + + // ===== 9 ===== + // Search for 'real' with 'rea' previous result + testnum++; + results = do_AC_search("real", "rea"); + let countREAL = results.matchCount; + do_check_true(results.matchCount <= countREA); + + // ===== 10 ===== + // Search for 'real' with 're' previous result + testnum++; + results = do_AC_search("real", "re"); + do_check_eq(countREAL, results.matchCount); + + // ===== 11 ===== + // Search for 'real' with no previous result + testnum++; + results = do_AC_search("real", null); + do_check_eq(countREAL, results.matchCount); + + + } catch (e) { + throw "FAILED in test #" + testnum + " -- " + e; + } +}