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;
+    }
+}