/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * FormHistory * * Used to store values that have been entered into forms which may later * be used to automatically fill in the values when the form is visited again. * * search(terms, queryData, callback) * Look up values that have been previously stored. * terms - array of terms to return data for * queryData - object that contains the query terms * The query object contains properties for each search criteria to match, where the value * of the property specifies the value that term must have. For example, * { term1: value1, term2: value2 } * callback - callback that is called when results are available or an error occurs. * The callback is passed a result array containing each found entry. Each element in * the array is an object containing a property for each search term specified by 'terms'. * count(queryData, callback) * Find the number of stored entries that match the given criteria. * queryData - array of objects that indicate the query. See the search method for details. * callback - callback that is called when results are available or an error occurs. * The callback is passed the number of found entries. * update(changes, callback) * Write data to form history storage. * changes - an array of changes to be made. If only one change is to be made, it * may be passed as an object rather than a one-element array. * Each change object is of the form: * { op: operation, term1: value1, term2: value2, ... } * Valid operations are: * add - add a new entry * update - update an existing entry * remove - remove an entry * bump - update the last accessed time on an entry * The terms specified allow matching of one or more specific entries. If no terms * are specified then all entries are matched. This means that { op: "remove" } is * used to remove all entries and clear the form history. * callback - callback that is called when results have been stored. * getAutoCompeteResults(searchString, params, callback) * Retrieve an array of form history values suitable for display in an autocomplete list. * Returns an mozIStoragePendingStatement that can be used to cancel the operation if * needed. * searchString - the string to search for, typically the entered value of a textbox * params - zero or more filter arguments: * fieldname - form field name * agedWeight * bucketSize * expiryDate * maxTimeGroundings * timeGroupingSize * prefixWeight * boundaryWeight * callback - callback that is called with the array of results. Each result in the array * is an object with four arguments: * text, textLowerCase, frecency, totalScore * schemaVersion * This property holds the version of the database schema * * Terms: * guid - entry identifier. For 'add', a guid will be generated. * fieldname - form field name * value - form value * timesUsed - the number of times the entry has been accessed * firstUsed - the time the the entry was first created * lastUsed - the time the entry was last accessed * firstUsedStart - search for entries created after or at this time * firstUsedEnd - search for entries created before or at this time * lastUsedStart - search for entries last accessed after or at this time * lastUsedEnd - search for entries last accessed before or at this time * newGuid - a special case valid only for 'update' and allows the guid for * an existing record to be updated. The 'guid' term is the only * other term which can be used (ie, you can not also specify a * fieldname, value etc) and indicates the guid of the existing * record that should be updated. * * In all of the above methods, the callback argument should be an object with * handleResult(result), handleFailure(error) and handleCompletion(reason) functions. * For search and getAutoCompeteResults, result is an object containing the desired * properties. For count, result is the integer count. For, update, handleResult is * not called. For handleCompletion, reason is either 0 if successful or 1 if * an error occurred. */ this.EXPORTED_SYMBOLS = ["FormHistory"]; const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); Components.utils.import("resource://gre/modules/Services.jsm"); Components.utils.import("resource://gre/modules/AppConstants.jsm"); XPCOMUtils.defineLazyServiceGetter(this, "uuidService", "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"); const DB_SCHEMA_VERSION = 4; const DAY_IN_MS = 86400000; // 1 day in milliseconds const MAX_SEARCH_TOKENS = 10; const NOOP = function noop() {}; var supportsDeletedTable = AppConstants.platform == "android"; var Prefs = { initialized: false, get debug() { this.ensureInitialized(); return this._debug; }, get enabled() { this.ensureInitialized(); return this._enabled; }, get expireDays() { this.ensureInitialized(); return this._expireDays; }, ensureInitialized: function() { if (this.initialized) return; this.initialized = true; this._debug = Services.prefs.getBoolPref("browser.formfill.debug"); this._enabled = Services.prefs.getBoolPref("browser.formfill.enable"); this._expireDays = Services.prefs.getIntPref("browser.formfill.expire_days"); } }; function log(aMessage) { if (Prefs.debug) { Services.console.logStringMessage("FormHistory: " + aMessage); } } function sendNotification(aType, aData) { if (typeof aData == "string") { let strWrapper = Cc["@mozilla.org/supports-string;1"]. createInstance(Ci.nsISupportsString); strWrapper.data = aData; aData = strWrapper; } else if (typeof aData == "number") { let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"]. createInstance(Ci.nsISupportsPRInt64); intWrapper.data = aData; aData = intWrapper; } else if (aData) { throw Components.Exception("Invalid type " + (typeof aType) + " passed to sendNotification", Cr.NS_ERROR_ILLEGAL_VALUE); } Services.obs.notifyObservers(aData, "satchel-storage-changed", aType); } /** * Current database schema */ const dbSchema = { tables : { moz_formhistory : { "id" : "INTEGER PRIMARY KEY", "fieldname" : "TEXT NOT NULL", "value" : "TEXT NOT NULL", "timesUsed" : "INTEGER", "firstUsed" : "INTEGER", "lastUsed" : "INTEGER", "guid" : "TEXT", }, moz_deleted_formhistory: { "id" : "INTEGER PRIMARY KEY", "timeDeleted" : "INTEGER", "guid" : "TEXT" } }, indices : { moz_formhistory_index : { table : "moz_formhistory", columns : [ "fieldname" ] }, moz_formhistory_lastused_index : { table : "moz_formhistory", columns : [ "lastUsed" ] }, moz_formhistory_guid_index : { table : "moz_formhistory", columns : [ "guid" ] }, } }; /** * Validating and processing API querying data */ const validFields = [ "fieldname", "value", "timesUsed", "firstUsed", "lastUsed", "guid", ]; const searchFilters = [ "firstUsedStart", "firstUsedEnd", "lastUsedStart", "lastUsedEnd", ]; function validateOpData(aData, aDataType) { let thisValidFields = validFields; // A special case to update the GUID - in this case there can be a 'newGuid' // field and of the normally valid fields, only 'guid' is accepted. if (aDataType == "Update" && "newGuid" in aData) { thisValidFields = ["guid", "newGuid"]; } for (let field in aData) { if (field != "op" && thisValidFields.indexOf(field) == -1) { throw Components.Exception( aDataType + " query contains an unrecognized field: " + field, Cr.NS_ERROR_ILLEGAL_VALUE); } } return aData; } function validateSearchData(aData, aDataType) { for (let field in aData) { if (field != "op" && validFields.indexOf(field) == -1 && searchFilters.indexOf(field) == -1) { throw Components.Exception( aDataType + " query contains an unrecognized field: " + field, Cr.NS_ERROR_ILLEGAL_VALUE); } } } function makeQueryPredicates(aQueryData, delimiter = ' AND ') { return Object.keys(aQueryData).map(function(field) { if (field == "firstUsedStart") { return "firstUsed >= :" + field; } else if (field == "firstUsedEnd") { return "firstUsed <= :" + field; } else if (field == "lastUsedStart") { return "lastUsed >= :" + field; } else if (field == "lastUsedEnd") { return "lastUsed <= :" + field; } return field + " = :" + field; }).join(delimiter); } /** * Storage statement creation and parameter binding */ function makeCountStatement(aSearchData) { let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory"; let queryTerms = makeQueryPredicates(aSearchData); if (queryTerms) { query += " WHERE " + queryTerms; } return dbCreateAsyncStatement(query, aSearchData); } function makeSearchStatement(aSearchData, aSelectTerms) { let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory"; let queryTerms = makeQueryPredicates(aSearchData); if (queryTerms) { query += " WHERE " + queryTerms; } return dbCreateAsyncStatement(query, aSearchData); } function makeAddStatement(aNewData, aNow, aBindingArrays) { let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " + "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)"; aNewData.timesUsed = aNewData.timesUsed || 1; aNewData.firstUsed = aNewData.firstUsed || aNow; aNewData.lastUsed = aNewData.lastUsed || aNow; return dbCreateAsyncStatement(query, aNewData, aBindingArrays); } function makeBumpStatement(aGuid, aNow, aBindingArrays) { let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid"; let queryParams = { lastUsed : aNow, guid : aGuid, }; return dbCreateAsyncStatement(query, queryParams, aBindingArrays); } function makeRemoveStatement(aSearchData, aBindingArrays) { let query = "DELETE FROM moz_formhistory"; let queryTerms = makeQueryPredicates(aSearchData); if (queryTerms) { log("removeEntries"); query += " WHERE " + queryTerms; } else { log("removeAllEntries"); // Not specifying any fields means we should remove all entries. We // won't need to modify the query in this case. } return dbCreateAsyncStatement(query, aSearchData, aBindingArrays); } function makeUpdateStatement(aGuid, aNewData, aBindingArrays) { let query = "UPDATE moz_formhistory SET "; let queryTerms = makeQueryPredicates(aNewData, ', '); if (!queryTerms) { throw Components.Exception("Update query must define fields to modify.", Cr.NS_ERROR_ILLEGAL_VALUE); } query += queryTerms + " WHERE guid = :existing_guid"; aNewData["existing_guid"] = aGuid; return dbCreateAsyncStatement(query, aNewData, aBindingArrays); } function makeMoveToDeletedStatement(aGuid, aNow, aData, aBindingArrays) { if (supportsDeletedTable) { let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)"; let queryTerms = makeQueryPredicates(aData); if (aGuid) { query += " VALUES (:guid, :timeDeleted)"; } else { // TODO: Add these items to the deleted items table once we've sorted // out the issues from bug 756701 if (!queryTerms) return undefined; query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms; } aData.timeDeleted = aNow; return dbCreateAsyncStatement(query, aData, aBindingArrays); } return null; } function generateGUID() { // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}" let uuid = uuidService.generateUUID().toString(); let raw = ""; // A string with the low bytes set to random values let bytes = 0; for (let i = 1; bytes < 12 ; i+= 2) { // Skip dashes if (uuid[i] == "-") i++; let hexVal = parseInt(uuid[i] + uuid[i + 1], 16); raw += String.fromCharCode(hexVal); bytes++; } return btoa(raw); } /** * Database creation and access */ var _dbConnection = null; XPCOMUtils.defineLazyGetter(this, "dbConnection", function() { let dbFile; try { dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone(); dbFile.append("formhistory.sqlite"); log("Opening database at " + dbFile.path); _dbConnection = Services.storage.openUnsharedDatabase(dbFile); dbInit(); } catch (e) { if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) throw e; dbCleanup(dbFile); _dbConnection = Services.storage.openUnsharedDatabase(dbFile); dbInit(); } return _dbConnection; }); var dbStmts = new Map(); /* * dbCreateAsyncStatement * * Creates a statement, wraps it, and then does parameter replacement */ function dbCreateAsyncStatement(aQuery, aParams, aBindingArrays) { if (!aQuery) return null; let stmt = dbStmts.get(aQuery); if (!stmt) { log("Creating new statement for query: " + aQuery); stmt = dbConnection.createAsyncStatement(aQuery); dbStmts.set(aQuery, stmt); } if (aBindingArrays) { let bindingArray = aBindingArrays.get(stmt); if (!bindingArray) { // first time using a particular statement in update bindingArray = stmt.newBindingParamsArray(); aBindingArrays.set(stmt, bindingArray); } if (aParams) { let bindingParams = bindingArray.newBindingParams(); for (let field in aParams) { bindingParams.bindByName(field, aParams[field]); } bindingArray.addParams(bindingParams); } } else if (aParams) { for (let field in aParams) { stmt.params[field] = aParams[field]; } } return stmt; } /** * dbInit * * Attempts to initialize the database. This creates the file if it doesn't * exist, performs any migrations, etc. */ function dbInit() { log("Initializing Database"); if (!_dbConnection.tableExists("moz_formhistory")) { dbCreate(); return; } // When FormHistory is released, we will no longer support the various schema versions prior to // this release that nsIFormHistory2 once did. let version = _dbConnection.schemaVersion; if (version < 3) { throw Components.Exception("DB version is unsupported.", Cr.NS_ERROR_FILE_CORRUPTED); } else if (version != DB_SCHEMA_VERSION) { dbMigrate(version); } } function dbCreate() { log("Creating DB -- tables"); for (let name in dbSchema.tables) { let table = dbSchema.tables[name]; let tSQL = Object.keys(table).map(col => [col, table[col]].join(" ")).join(", "); log("Creating table " + name + " with " + tSQL); _dbConnection.createTable(name, tSQL); } log("Creating DB -- indices"); for (let name in dbSchema.indices) { let index = dbSchema.indices[name]; let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table + "(" + index.columns.join(", ") + ")"; _dbConnection.executeSimpleSQL(statement); } _dbConnection.schemaVersion = DB_SCHEMA_VERSION; } function dbMigrate(oldVersion) { log("Attempting to migrate from version " + oldVersion); if (oldVersion > DB_SCHEMA_VERSION) { log("Downgrading to version " + DB_SCHEMA_VERSION); // User's DB is newer. Sanity check that our expected columns are // present, and if so mark the lower version and merrily continue // on. If the columns are borked, something is wrong so blow away // the DB and start from scratch. [Future incompatible upgrades // should switch to a different table or file.] if (!dbAreExpectedColumnsPresent()) { throw Components.Exception("DB is missing expected columns", Cr.NS_ERROR_FILE_CORRUPTED); } // Change the stored version to the current version. If the user // runs the newer code again, it will see the lower version number // and re-upgrade (to fixup any entries the old code added). _dbConnection.schemaVersion = DB_SCHEMA_VERSION; return; } // Note that migration is currently performed synchronously. _dbConnection.beginTransaction(); try { for (let v = oldVersion + 1; v <= DB_SCHEMA_VERSION; v++) { this.log("Upgrading to version " + v + "..."); Migrators["dbMigrateToVersion" + v](); } } catch (e) { this.log("Migration failed: " + e); this.dbConnection.rollbackTransaction(); throw e; } _dbConnection.schemaVersion = DB_SCHEMA_VERSION; _dbConnection.commitTransaction(); log("DB migration completed."); } var Migrators = { /* * Updates the DB schema to v3 (bug 506402). * Adds deleted form history table. */ dbMigrateToVersion4: function dbMigrateToVersion4() { if (!_dbConnection.tableExists("moz_deleted_formhistory")) { let table = dbSchema.tables["moz_deleted_formhistory"]; let tSQL = Object.keys(table).map(col => [col, table[col]].join(" ")).join(", "); _dbConnection.createTable("moz_deleted_formhistory", tSQL); } } }; /** * dbAreExpectedColumnsPresent * * Sanity check to ensure that the columns this version of the code expects * are present in the DB we're using. */ function dbAreExpectedColumnsPresent() { for (let name in dbSchema.tables) { let table = dbSchema.tables[name]; let query = "SELECT " + Object.keys(table).join(", ") + " FROM " + name; try { let stmt = _dbConnection.createStatement(query); // (no need to execute statement, if it compiled we're good) stmt.finalize(); } catch (e) { return false; } } log("verified that expected columns are present in DB."); return true; } /** * dbCleanup * * Called when database creation fails. Finalizes database statements, * closes the database connection, deletes the database file. */ function dbCleanup(dbFile) { log("Cleaning up DB file - close & remove & backup"); // Create backup file let backupFile = dbFile.leafName + ".corrupt"; Services.storage.backupDatabaseFile(dbFile, backupFile); dbClose(false); dbFile.remove(false); } function dbClose(aShutdown) { log("dbClose(" + aShutdown + ")"); if (aShutdown) { sendNotification("formhistory-shutdown", null); } // Connection may never have been created if say open failed but we still // end up calling dbClose as part of the rest of dbCleanup. if (!_dbConnection) { return; } log("dbClose finalize statements"); for (let stmt of dbStmts.values()) { stmt.finalize(); } dbStmts = new Map(); let closed = false; _dbConnection.asyncClose(() => closed = true); if (!aShutdown) { let thread = Services.tm.currentThread; while (!closed) { thread.processNextEvent(true); } } } /** * updateFormHistoryWrite * * Constructs and executes database statements from a pre-processed list of * inputted changes. */ function updateFormHistoryWrite(aChanges, aCallbacks) { log("updateFormHistoryWrite " + aChanges.length); // pass 'now' down so that every entry in the batch has the same timestamp let now = Date.now() * 1000; // for each change, we either create and append a new storage statement to // stmts or bind a new set of parameters to an existing storage statement. // stmts and bindingArrays are updated when makeXXXStatement eventually // calls dbCreateAsyncStatement. let stmts = []; let notifications = []; let bindingArrays = new Map(); for (let change of aChanges) { let operation = change.op; delete change.op; let stmt; switch (operation) { case "remove": log("Remove from form history " + change); let delStmt = makeMoveToDeletedStatement(change.guid, now, change, bindingArrays); if (delStmt && stmts.indexOf(delStmt) == -1) stmts.push(delStmt); if ("timeDeleted" in change) delete change.timeDeleted; stmt = makeRemoveStatement(change, bindingArrays); notifications.push([ "formhistory-remove", change.guid ]); break; case "update": log("Update form history " + change); let guid = change.guid; delete change.guid; // a special case for updating the GUID - the new value can be // specified in newGuid. if (change.newGuid) { change.guid = change.newGuid delete change.newGuid; } stmt = makeUpdateStatement(guid, change, bindingArrays); notifications.push([ "formhistory-update", guid ]); break; case "bump": log("Bump form history " + change); if (change.guid) { stmt = makeBumpStatement(change.guid, now, bindingArrays); notifications.push([ "formhistory-update", change.guid ]); } else { change.guid = generateGUID(); stmt = makeAddStatement(change, now, bindingArrays); notifications.push([ "formhistory-add", change.guid ]); } break; case "add": log("Add to form history " + change); change.guid = generateGUID(); stmt = makeAddStatement(change, now, bindingArrays); notifications.push([ "formhistory-add", change.guid ]); break; default: // We should've already guaranteed that change.op is one of the above throw Components.Exception("Invalid operation " + operation, Cr.NS_ERROR_ILLEGAL_VALUE); } // As identical statements are reused, only add statements if they aren't already present. if (stmt && stmts.indexOf(stmt) == -1) { stmts.push(stmt); } } for (let stmt of stmts) { stmt.bindParameters(bindingArrays.get(stmt)); } let handlers = { handleCompletion : function(aReason) { if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) { for (let [notification, param] of notifications) { // We're either sending a GUID or nothing at all. sendNotification(notification, param); } } if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); } }, handleError : function(aError) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError(aError); } }, handleResult : NOOP }; dbConnection.executeAsync(stmts, stmts.length, handlers); } /** * Functions that expire entries in form history and shrinks database * afterwards as necessary initiated by expireOldEntries. */ /** * expireOldEntriesDeletion * * Removes entries from database. */ function expireOldEntriesDeletion(aExpireTime, aBeginningCount) { log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")"); FormHistory.update([ { op: "remove", lastUsedEnd : aExpireTime, }], { handleCompletion: function() { expireOldEntriesVacuum(aExpireTime, aBeginningCount); }, handleError: function(aError) { log("expireOldEntriesDeletionFailure"); } }); } /** * expireOldEntriesVacuum * * Counts number of entries removed and shrinks database as necessary. */ function expireOldEntriesVacuum(aExpireTime, aBeginningCount) { FormHistory.count({}, { handleResult: function(aEndingCount) { if (aBeginningCount - aEndingCount > 500) { log("expireOldEntriesVacuum"); let stmt = dbCreateAsyncStatement("VACUUM"); stmt.executeAsync({ handleResult : NOOP, handleError : function(aError) { log("expireVacuumError"); }, handleCompletion : NOOP }); } sendNotification("formhistory-expireoldentries", aExpireTime); }, handleError: function(aError) { log("expireEndCountFailure"); } }); } this.FormHistory = { get enabled() { return Prefs.enabled; }, search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) { // if no terms selected, select everything aSelectTerms = (aSelectTerms) ? aSelectTerms : validFields; validateSearchData(aSearchData, "Search"); let stmt = makeSearchStatement(aSearchData, aSelectTerms); let handlers = { handleResult : function(aResultSet) { for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) { let result = {}; for (let field of aSelectTerms) { result[field] = row.getResultByName(field); } if (aCallbacks && aCallbacks.handleResult) { aCallbacks.handleResult(result); } } }, handleError : function(aError) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError(aError); } }, handleCompletion : function searchCompletionHandler(aReason) { if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); } } }; stmt.executeAsync(handlers); }, count : function formHistoryCount(aSearchData, aCallbacks) { validateSearchData(aSearchData, "Count"); let stmt = makeCountStatement(aSearchData); let handlers = { handleResult : function countResultHandler(aResultSet) { let row = aResultSet.getNextRow(); let count = row.getResultByName("numEntries"); if (aCallbacks && aCallbacks.handleResult) { aCallbacks.handleResult(count); } }, handleError : function(aError) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError(aError); } }, handleCompletion : function searchCompletionHandler(aReason) { if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); } } }; stmt.executeAsync(handlers); }, update : function formHistoryUpdate(aChanges, aCallbacks) { // Used to keep track of how many searches have been started. When that number // are finished, updateFormHistoryWrite can be called. let numSearches = 0; let completedSearches = 0; let searchFailed = false; function validIdentifier(change) { // The identifier is only valid if one of either the guid or the (fieldname/value) are set return Boolean(change.guid) != Boolean(change.fieldname && change.value); } if (!("length" in aChanges)) aChanges = [aChanges]; let isRemoveOperation = aChanges.every(change => change && change.op && change.op == "remove"); if (!Prefs.enabled && !isRemoveOperation) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError({ message: "Form history is disabled, only remove operations are allowed", result: Ci.mozIStorageError.MISUSE }); } if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(1); } return; } for (let change of aChanges) { switch (change.op) { case "remove": validateSearchData(change, "Remove"); continue; case "update": if (validIdentifier(change)) { validateOpData(change, "Update"); if (change.guid) { continue; } } else { throw Components.Exception( "update op='update' does not correctly reference a entry.", Cr.NS_ERROR_ILLEGAL_VALUE); } break; case "bump": if (validIdentifier(change)) { validateOpData(change, "Bump"); if (change.guid) { continue; } } else { throw Components.Exception( "update op='bump' does not correctly reference a entry.", Cr.NS_ERROR_ILLEGAL_VALUE); } break; case "add": if (change.guid) { throw Components.Exception( "op='add' cannot contain field 'guid'. Either use op='update' " + "explicitly or make 'guid' undefined.", Cr.NS_ERROR_ILLEGAL_VALUE); } else if (change.fieldname && change.value) { validateOpData(change, "Add"); } break; default: throw Components.Exception( "update does not recognize op='" + change.op + "'", Cr.NS_ERROR_ILLEGAL_VALUE); } numSearches++; let changeToUpdate = change; FormHistory.search( [ "guid" ], { fieldname : change.fieldname, value : change.value }, { foundResult : false, handleResult : function(aResult) { if (this.foundResult) { log("Database contains multiple entries with the same fieldname/value pair."); if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError({ message : "Database contains multiple entries with the same fieldname/value pair.", result : 19 // Constraint violation }); } searchFailed = true; return; } this.foundResult = true; changeToUpdate.guid = aResult["guid"]; }, handleError : function(aError) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError(aError); } }, handleCompletion : function(aReason) { completedSearches++; if (completedSearches == numSearches) { if (!aReason && !searchFailed) { updateFormHistoryWrite(aChanges, aCallbacks); } else if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(1); } } } }); } if (numSearches == 0) { // We don't have to wait for any statements to return. updateFormHistoryWrite(aChanges, aCallbacks); } }, getAutoCompleteResults: function getAutoCompleteResults(searchString, params, aCallbacks) { // only do substring matching when the search string contains more than one character let searchTokens; 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 = []; let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS); for (let i = 0; i < searchTokenCount; 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)"; } else if (searchString.length == 1) { where = "AND (value LIKE :valuePrefix ESCAPE '/') "; boundaryCalc = "1"; delete params.prefixWeight; delete params.boundaryWeight; } else { where = ""; boundaryCalc = "1"; delete params.prefixWeight; delete params.boundaryWeight; } params.now = Date.now() * 1000; // convert from ms to microseconds /* 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 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 = dbCreateAsyncStatement(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) { let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS); for (let i = 0; i < searchTokenCount; i++) { let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/"); stmt.params["tokenBegin" + i] = escapedToken + "%"; stmt.params["tokenBoundary" + i] = "% " + escapedToken + "%"; stmt.params["tokenContains" + i] = "%" + escapedToken + "%"; } } else { // no additional params need to be substituted into the query when the // length is zero or one } let pending = stmt.executeAsync({ handleResult : function (aResultSet) { for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) { let value = row.getResultByName("value"); let frecency = row.getResultByName("frecency"); let entry = { text : value, textLowerCase : value.toLowerCase(), frecency : frecency, totalScore : Math.round(frecency * row.getResultByName("boundaryBonuses")) }; if (aCallbacks && aCallbacks.handleResult) { aCallbacks.handleResult(entry); } } }, handleError : function (aError) { if (aCallbacks && aCallbacks.handleError) { aCallbacks.handleError(aError); } }, handleCompletion : function (aReason) { if (aCallbacks && aCallbacks.handleCompletion) { aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); } } }); return pending; }, get schemaVersion() { return dbConnection.schemaVersion; }, // This is used only so that the test can verify deleted table support. get _supportsDeletedTable() { return supportsDeletedTable; }, set _supportsDeletedTable(val) { supportsDeletedTable = val; }, // The remaining methods are called by FormHistoryStartup.js updatePrefs: function updatePrefs() { Prefs.initialized = false; }, expireOldEntries: function expireOldEntries() { log("expireOldEntries"); // Determine how many days of history we're supposed to keep. // Calculate expireTime in microseconds let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000; sendNotification("formhistory-beforeexpireoldentries", expireTime); FormHistory.count({}, { handleResult: function(aBeginningCount) { expireOldEntriesDeletion(expireTime, aBeginningCount); }, handleError: function(aError) { log("expireStartCountFailure"); } }); }, shutdown: function shutdown() { dbClose(true); } }; // Prevent add-ons from redefining this API Object.freeze(FormHistory);