summaryrefslogtreecommitdiff
path: root/toolkit/components/satchel/FormHistory.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel/FormHistory.jsm')
-rw-r--r--toolkit/components/satchel/FormHistory.jsm1119
1 files changed, 1119 insertions, 0 deletions
diff --git a/toolkit/components/satchel/FormHistory.jsm b/toolkit/components/satchel/FormHistory.jsm
new file mode 100644
index 0000000000..3d4a9fc436
--- /dev/null
+++ b/toolkit/components/satchel/FormHistory.jsm
@@ -0,0 +1,1119 @@
+/* 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);