Bug 446247 - Autocomplete should match any part of the string. r=dolske

This commit is contained in:
MattN 2009-08-01 17:30:26 -07:00
parent 240a6d45f0
commit 5b85b7cf95
5 changed files with 392 additions and 62 deletions

View File

@ -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);

View File

@ -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);
}
};

View File

@ -45,6 +45,12 @@ Form History test: form field autocomplete
<button type="submit">Submit</button>
</form>
<!-- normal form for testing word boundary filtering -->
<form id="form6" onsubmit="return false;">
<input type="text" name="field4">
<button type="submit">Submit</button>
</form>
</div>
<pre id="test">
@ -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;

View File

@ -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 <mnoorenberghe@mozilla.com> (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;
}
}