1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/components/satchel/FormHistory.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1112 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +/** 1.9 + * FormHistory 1.10 + * 1.11 + * Used to store values that have been entered into forms which may later 1.12 + * be used to automatically fill in the values when the form is visited again. 1.13 + * 1.14 + * search(terms, queryData, callback) 1.15 + * Look up values that have been previously stored. 1.16 + * terms - array of terms to return data for 1.17 + * queryData - object that contains the query terms 1.18 + * The query object contains properties for each search criteria to match, where the value 1.19 + * of the property specifies the value that term must have. For example, 1.20 + * { term1: value1, term2: value2 } 1.21 + * callback - callback that is called when results are available or an error occurs. 1.22 + * The callback is passed a result array containing each found entry. Each element in 1.23 + * the array is an object containing a property for each search term specified by 'terms'. 1.24 + * count(queryData, callback) 1.25 + * Find the number of stored entries that match the given criteria. 1.26 + * queryData - array of objects that indicate the query. See the search method for details. 1.27 + * callback - callback that is called when results are available or an error occurs. 1.28 + * The callback is passed the number of found entries. 1.29 + * update(changes, callback) 1.30 + * Write data to form history storage. 1.31 + * changes - an array of changes to be made. If only one change is to be made, it 1.32 + * may be passed as an object rather than a one-element array. 1.33 + * Each change object is of the form: 1.34 + * { op: operation, term1: value1, term2: value2, ... } 1.35 + * Valid operations are: 1.36 + * add - add a new entry 1.37 + * update - update an existing entry 1.38 + * remove - remove an entry 1.39 + * bump - update the last accessed time on an entry 1.40 + * The terms specified allow matching of one or more specific entries. If no terms 1.41 + * are specified then all entries are matched. This means that { op: "remove" } is 1.42 + * used to remove all entries and clear the form history. 1.43 + * callback - callback that is called when results have been stored. 1.44 + * getAutoCompeteResults(searchString, params, callback) 1.45 + * Retrieve an array of form history values suitable for display in an autocomplete list. 1.46 + * Returns an mozIStoragePendingStatement that can be used to cancel the operation if 1.47 + * needed. 1.48 + * searchString - the string to search for, typically the entered value of a textbox 1.49 + * params - zero or more filter arguments: 1.50 + * fieldname - form field name 1.51 + * agedWeight 1.52 + * bucketSize 1.53 + * expiryDate 1.54 + * maxTimeGroundings 1.55 + * timeGroupingSize 1.56 + * prefixWeight 1.57 + * boundaryWeight 1.58 + * callback - callback that is called with the array of results. Each result in the array 1.59 + * is an object with four arguments: 1.60 + * text, textLowerCase, frecency, totalScore 1.61 + * schemaVersion 1.62 + * This property holds the version of the database schema 1.63 + * 1.64 + * Terms: 1.65 + * guid - entry identifier. For 'add', a guid will be generated. 1.66 + * fieldname - form field name 1.67 + * value - form value 1.68 + * timesUsed - the number of times the entry has been accessed 1.69 + * firstUsed - the time the the entry was first created 1.70 + * lastUsed - the time the entry was last accessed 1.71 + * firstUsedStart - search for entries created after or at this time 1.72 + * firstUsedEnd - search for entries created before or at this time 1.73 + * lastUsedStart - search for entries last accessed after or at this time 1.74 + * lastUsedEnd - search for entries last accessed before or at this time 1.75 + * newGuid - a special case valid only for 'update' and allows the guid for 1.76 + * an existing record to be updated. The 'guid' term is the only 1.77 + * other term which can be used (ie, you can not also specify a 1.78 + * fieldname, value etc) and indicates the guid of the existing 1.79 + * record that should be updated. 1.80 + * 1.81 + * In all of the above methods, the callback argument should be an object with 1.82 + * handleResult(result), handleFailure(error) and handleCompletion(reason) functions. 1.83 + * For search and getAutoCompeteResults, result is an object containing the desired 1.84 + * properties. For count, result is the integer count. For, update, handleResult is 1.85 + * not called. For handleCompletion, reason is either 0 if successful or 1 if 1.86 + * an error occurred. 1.87 + */ 1.88 + 1.89 +this.EXPORTED_SYMBOLS = ["FormHistory"]; 1.90 + 1.91 +const Cc = Components.classes; 1.92 +const Ci = Components.interfaces; 1.93 +const Cr = Components.results; 1.94 + 1.95 +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); 1.96 +Components.utils.import("resource://gre/modules/Services.jsm"); 1.97 + 1.98 +XPCOMUtils.defineLazyServiceGetter(this, "uuidService", 1.99 + "@mozilla.org/uuid-generator;1", 1.100 + "nsIUUIDGenerator"); 1.101 + 1.102 +const DB_SCHEMA_VERSION = 4; 1.103 +const DAY_IN_MS = 86400000; // 1 day in milliseconds 1.104 +const MAX_SEARCH_TOKENS = 10; 1.105 +const NOOP = function noop() {}; 1.106 + 1.107 +let supportsDeletedTable = 1.108 +#ifdef ANDROID 1.109 + true; 1.110 +#else 1.111 + false; 1.112 +#endif 1.113 + 1.114 +let Prefs = { 1.115 + initialized: false, 1.116 + 1.117 + get debug() { this.ensureInitialized(); return this._debug; }, 1.118 + get enabled() { this.ensureInitialized(); return this._enabled; }, 1.119 + get expireDays() { this.ensureInitialized(); return this._expireDays; }, 1.120 + 1.121 + ensureInitialized: function() { 1.122 + if (this.initialized) 1.123 + return; 1.124 + 1.125 + this.initialized = true; 1.126 + 1.127 + this._debug = Services.prefs.getBoolPref("browser.formfill.debug"); 1.128 + this._enabled = Services.prefs.getBoolPref("browser.formfill.enable"); 1.129 + this._expireDays = Services.prefs.getIntPref("browser.formfill.expire_days"); 1.130 + } 1.131 +}; 1.132 + 1.133 +function log(aMessage) { 1.134 + if (Prefs.debug) { 1.135 + Services.console.logStringMessage("FormHistory: " + aMessage); 1.136 + } 1.137 +} 1.138 + 1.139 +function sendNotification(aType, aData) { 1.140 + if (typeof aData == "string") { 1.141 + let strWrapper = Cc["@mozilla.org/supports-string;1"]. 1.142 + createInstance(Ci.nsISupportsString); 1.143 + strWrapper.data = aData; 1.144 + aData = strWrapper; 1.145 + } 1.146 + else if (typeof aData == "number") { 1.147 + let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"]. 1.148 + createInstance(Ci.nsISupportsPRInt64); 1.149 + intWrapper.data = aData; 1.150 + aData = intWrapper; 1.151 + } 1.152 + else if (aData) { 1.153 + throw Components.Exception("Invalid type " + (typeof aType) + " passed to sendNotification", 1.154 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.155 + } 1.156 + 1.157 + Services.obs.notifyObservers(aData, "satchel-storage-changed", aType); 1.158 +} 1.159 + 1.160 +/** 1.161 + * Current database schema 1.162 + */ 1.163 + 1.164 +const dbSchema = { 1.165 + tables : { 1.166 + moz_formhistory : { 1.167 + "id" : "INTEGER PRIMARY KEY", 1.168 + "fieldname" : "TEXT NOT NULL", 1.169 + "value" : "TEXT NOT NULL", 1.170 + "timesUsed" : "INTEGER", 1.171 + "firstUsed" : "INTEGER", 1.172 + "lastUsed" : "INTEGER", 1.173 + "guid" : "TEXT", 1.174 + }, 1.175 + moz_deleted_formhistory: { 1.176 + "id" : "INTEGER PRIMARY KEY", 1.177 + "timeDeleted" : "INTEGER", 1.178 + "guid" : "TEXT" 1.179 + } 1.180 + }, 1.181 + indices : { 1.182 + moz_formhistory_index : { 1.183 + table : "moz_formhistory", 1.184 + columns : [ "fieldname" ] 1.185 + }, 1.186 + moz_formhistory_lastused_index : { 1.187 + table : "moz_formhistory", 1.188 + columns : [ "lastUsed" ] 1.189 + }, 1.190 + moz_formhistory_guid_index : { 1.191 + table : "moz_formhistory", 1.192 + columns : [ "guid" ] 1.193 + }, 1.194 + } 1.195 +}; 1.196 + 1.197 +/** 1.198 + * Validating and processing API querying data 1.199 + */ 1.200 + 1.201 +const validFields = [ 1.202 + "fieldname", 1.203 + "value", 1.204 + "timesUsed", 1.205 + "firstUsed", 1.206 + "lastUsed", 1.207 + "guid", 1.208 +]; 1.209 + 1.210 +const searchFilters = [ 1.211 + "firstUsedStart", 1.212 + "firstUsedEnd", 1.213 + "lastUsedStart", 1.214 + "lastUsedEnd", 1.215 +]; 1.216 + 1.217 +function validateOpData(aData, aDataType) { 1.218 + let thisValidFields = validFields; 1.219 + // A special case to update the GUID - in this case there can be a 'newGuid' 1.220 + // field and of the normally valid fields, only 'guid' is accepted. 1.221 + if (aDataType == "Update" && "newGuid" in aData) { 1.222 + thisValidFields = ["guid", "newGuid"]; 1.223 + } 1.224 + for (let field in aData) { 1.225 + if (field != "op" && thisValidFields.indexOf(field) == -1) { 1.226 + throw Components.Exception( 1.227 + aDataType + " query contains an unrecognized field: " + field, 1.228 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.229 + } 1.230 + } 1.231 + return aData; 1.232 +} 1.233 + 1.234 +function validateSearchData(aData, aDataType) { 1.235 + for (let field in aData) { 1.236 + if (field != "op" && validFields.indexOf(field) == -1 && searchFilters.indexOf(field) == -1) { 1.237 + throw Components.Exception( 1.238 + aDataType + " query contains an unrecognized field: " + field, 1.239 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.240 + } 1.241 + } 1.242 +} 1.243 + 1.244 +function makeQueryPredicates(aQueryData, delimiter = ' AND ') { 1.245 + return Object.keys(aQueryData).map(function(field) { 1.246 + if (field == "firstUsedStart") { 1.247 + return "firstUsed >= :" + field; 1.248 + } else if (field == "firstUsedEnd") { 1.249 + return "firstUsed <= :" + field; 1.250 + } else if (field == "lastUsedStart") { 1.251 + return "lastUsed >= :" + field; 1.252 + } else if (field == "lastUsedEnd") { 1.253 + return "lastUsed <= :" + field; 1.254 + } 1.255 + return field + " = :" + field; 1.256 + }).join(delimiter); 1.257 +} 1.258 + 1.259 +/** 1.260 + * Storage statement creation and parameter binding 1.261 + */ 1.262 + 1.263 +function makeCountStatement(aSearchData) { 1.264 + let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory"; 1.265 + let queryTerms = makeQueryPredicates(aSearchData); 1.266 + if (queryTerms) { 1.267 + query += " WHERE " + queryTerms; 1.268 + } 1.269 + return dbCreateAsyncStatement(query, aSearchData); 1.270 +} 1.271 + 1.272 +function makeSearchStatement(aSearchData, aSelectTerms) { 1.273 + let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory"; 1.274 + let queryTerms = makeQueryPredicates(aSearchData); 1.275 + if (queryTerms) { 1.276 + query += " WHERE " + queryTerms; 1.277 + } 1.278 + 1.279 + return dbCreateAsyncStatement(query, aSearchData); 1.280 +} 1.281 + 1.282 +function makeAddStatement(aNewData, aNow, aBindingArrays) { 1.283 + let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " + 1.284 + "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)"; 1.285 + 1.286 + aNewData.timesUsed = aNewData.timesUsed || 1; 1.287 + aNewData.firstUsed = aNewData.firstUsed || aNow; 1.288 + aNewData.lastUsed = aNewData.lastUsed || aNow; 1.289 + return dbCreateAsyncStatement(query, aNewData, aBindingArrays); 1.290 +} 1.291 + 1.292 +function makeBumpStatement(aGuid, aNow, aBindingArrays) { 1.293 + let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid"; 1.294 + let queryParams = { 1.295 + lastUsed : aNow, 1.296 + guid : aGuid, 1.297 + }; 1.298 + 1.299 + return dbCreateAsyncStatement(query, queryParams, aBindingArrays); 1.300 +} 1.301 + 1.302 +function makeRemoveStatement(aSearchData, aBindingArrays) { 1.303 + let query = "DELETE FROM moz_formhistory"; 1.304 + let queryTerms = makeQueryPredicates(aSearchData); 1.305 + 1.306 + if (queryTerms) { 1.307 + log("removeEntries"); 1.308 + query += " WHERE " + queryTerms; 1.309 + } else { 1.310 + log("removeAllEntries"); 1.311 + // Not specifying any fields means we should remove all entries. We 1.312 + // won't need to modify the query in this case. 1.313 + } 1.314 + 1.315 + return dbCreateAsyncStatement(query, aSearchData, aBindingArrays); 1.316 +} 1.317 + 1.318 +function makeUpdateStatement(aGuid, aNewData, aBindingArrays) { 1.319 + let query = "UPDATE moz_formhistory SET "; 1.320 + let queryTerms = makeQueryPredicates(aNewData, ', '); 1.321 + 1.322 + if (!queryTerms) { 1.323 + throw Components.Exception("Update query must define fields to modify.", 1.324 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.325 + } 1.326 + 1.327 + query += queryTerms + " WHERE guid = :existing_guid"; 1.328 + aNewData["existing_guid"] = aGuid; 1.329 + 1.330 + return dbCreateAsyncStatement(query, aNewData, aBindingArrays); 1.331 +} 1.332 + 1.333 +function makeMoveToDeletedStatement(aGuid, aNow, aData, aBindingArrays) { 1.334 + if (supportsDeletedTable) { 1.335 + let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)"; 1.336 + let queryTerms = makeQueryPredicates(aData); 1.337 + 1.338 + if (aGuid) { 1.339 + query += " VALUES (:guid, :timeDeleted)"; 1.340 + } else { 1.341 + // TODO: Add these items to the deleted items table once we've sorted 1.342 + // out the issues from bug 756701 1.343 + if (!queryTerms) 1.344 + return; 1.345 + 1.346 + query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms; 1.347 + } 1.348 + 1.349 + aData.timeDeleted = aNow; 1.350 + 1.351 + return dbCreateAsyncStatement(query, aData, aBindingArrays); 1.352 + } 1.353 + 1.354 + return null; 1.355 +} 1.356 + 1.357 +function generateGUID() { 1.358 + // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}" 1.359 + let uuid = uuidService.generateUUID().toString(); 1.360 + let raw = ""; // A string with the low bytes set to random values 1.361 + let bytes = 0; 1.362 + for (let i = 1; bytes < 12 ; i+= 2) { 1.363 + // Skip dashes 1.364 + if (uuid[i] == "-") 1.365 + i++; 1.366 + let hexVal = parseInt(uuid[i] + uuid[i + 1], 16); 1.367 + raw += String.fromCharCode(hexVal); 1.368 + bytes++; 1.369 + } 1.370 + return btoa(raw); 1.371 +} 1.372 + 1.373 +/** 1.374 + * Database creation and access 1.375 + */ 1.376 + 1.377 +let _dbConnection = null; 1.378 +XPCOMUtils.defineLazyGetter(this, "dbConnection", function() { 1.379 + let dbFile; 1.380 + 1.381 + try { 1.382 + dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone(); 1.383 + dbFile.append("formhistory.sqlite"); 1.384 + log("Opening database at " + dbFile.path); 1.385 + 1.386 + _dbConnection = Services.storage.openUnsharedDatabase(dbFile); 1.387 + dbInit(); 1.388 + } catch (e if e.result == Cr.NS_ERROR_FILE_CORRUPTED) { 1.389 + dbCleanup(dbFile); 1.390 + _dbConnection = Services.storage.openUnsharedDatabase(dbFile); 1.391 + dbInit(); 1.392 + } 1.393 + 1.394 + return _dbConnection; 1.395 +}); 1.396 + 1.397 + 1.398 +let dbStmts = new Map(); 1.399 + 1.400 +/* 1.401 + * dbCreateAsyncStatement 1.402 + * 1.403 + * Creates a statement, wraps it, and then does parameter replacement 1.404 + */ 1.405 +function dbCreateAsyncStatement(aQuery, aParams, aBindingArrays) { 1.406 + if (!aQuery) 1.407 + return null; 1.408 + 1.409 + let stmt = dbStmts.get(aQuery); 1.410 + if (!stmt) { 1.411 + log("Creating new statement for query: " + aQuery); 1.412 + stmt = dbConnection.createAsyncStatement(aQuery); 1.413 + dbStmts.set(aQuery, stmt); 1.414 + } 1.415 + 1.416 + if (aBindingArrays) { 1.417 + let bindingArray = aBindingArrays.get(stmt); 1.418 + if (!bindingArray) { 1.419 + // first time using a particular statement in update 1.420 + bindingArray = stmt.newBindingParamsArray(); 1.421 + aBindingArrays.set(stmt, bindingArray); 1.422 + } 1.423 + 1.424 + if (aParams) { 1.425 + let bindingParams = bindingArray.newBindingParams(); 1.426 + for (let field in aParams) { 1.427 + bindingParams.bindByName(field, aParams[field]); 1.428 + } 1.429 + bindingArray.addParams(bindingParams); 1.430 + } 1.431 + } else { 1.432 + if (aParams) { 1.433 + for (let field in aParams) { 1.434 + stmt.params[field] = aParams[field]; 1.435 + } 1.436 + } 1.437 + } 1.438 + 1.439 + return stmt; 1.440 +} 1.441 + 1.442 +/** 1.443 + * dbInit 1.444 + * 1.445 + * Attempts to initialize the database. This creates the file if it doesn't 1.446 + * exist, performs any migrations, etc. 1.447 + */ 1.448 +function dbInit() { 1.449 + log("Initializing Database"); 1.450 + 1.451 + if (!_dbConnection.tableExists("moz_formhistory")) { 1.452 + dbCreate(); 1.453 + return; 1.454 + } 1.455 + 1.456 + // When FormHistory is released, we will no longer support the various schema versions prior to 1.457 + // this release that nsIFormHistory2 once did. 1.458 + let version = _dbConnection.schemaVersion; 1.459 + if (version < 3) { 1.460 + throw Components.Exception("DB version is unsupported.", 1.461 + Cr.NS_ERROR_FILE_CORRUPTED); 1.462 + } else if (version != DB_SCHEMA_VERSION) { 1.463 + dbMigrate(version); 1.464 + } 1.465 +} 1.466 + 1.467 +function dbCreate() { 1.468 + log("Creating DB -- tables"); 1.469 + for (let name in dbSchema.tables) { 1.470 + let table = dbSchema.tables[name]; 1.471 + let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", "); 1.472 + log("Creating table " + name + " with " + tSQL); 1.473 + _dbConnection.createTable(name, tSQL); 1.474 + } 1.475 + 1.476 + log("Creating DB -- indices"); 1.477 + for (let name in dbSchema.indices) { 1.478 + let index = dbSchema.indices[name]; 1.479 + let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table + 1.480 + "(" + index.columns.join(", ") + ")"; 1.481 + _dbConnection.executeSimpleSQL(statement); 1.482 + } 1.483 + 1.484 + _dbConnection.schemaVersion = DB_SCHEMA_VERSION; 1.485 +} 1.486 + 1.487 +function dbMigrate(oldVersion) { 1.488 + log("Attempting to migrate from version " + oldVersion); 1.489 + 1.490 + if (oldVersion > DB_SCHEMA_VERSION) { 1.491 + log("Downgrading to version " + DB_SCHEMA_VERSION); 1.492 + // User's DB is newer. Sanity check that our expected columns are 1.493 + // present, and if so mark the lower version and merrily continue 1.494 + // on. If the columns are borked, something is wrong so blow away 1.495 + // the DB and start from scratch. [Future incompatible upgrades 1.496 + // should switch to a different table or file.] 1.497 + 1.498 + if (!dbAreExpectedColumnsPresent()) { 1.499 + throw Components.Exception("DB is missing expected columns", 1.500 + Cr.NS_ERROR_FILE_CORRUPTED); 1.501 + } 1.502 + 1.503 + // Change the stored version to the current version. If the user 1.504 + // runs the newer code again, it will see the lower version number 1.505 + // and re-upgrade (to fixup any entries the old code added). 1.506 + _dbConnection.schemaVersion = DB_SCHEMA_VERSION; 1.507 + return; 1.508 + } 1.509 + 1.510 + // Note that migration is currently performed synchronously. 1.511 + _dbConnection.beginTransaction(); 1.512 + 1.513 + try { 1.514 + for (let v = oldVersion + 1; v <= DB_SCHEMA_VERSION; v++) { 1.515 + this.log("Upgrading to version " + v + "..."); 1.516 + Migrators["dbMigrateToVersion" + v](); 1.517 + } 1.518 + } catch (e) { 1.519 + this.log("Migration failed: " + e); 1.520 + this.dbConnection.rollbackTransaction(); 1.521 + throw e; 1.522 + } 1.523 + 1.524 + _dbConnection.schemaVersion = DB_SCHEMA_VERSION; 1.525 + _dbConnection.commitTransaction(); 1.526 + 1.527 + log("DB migration completed."); 1.528 +} 1.529 + 1.530 +var Migrators = { 1.531 + /* 1.532 + * Updates the DB schema to v3 (bug 506402). 1.533 + * Adds deleted form history table. 1.534 + */ 1.535 + dbMigrateToVersion4: function dbMigrateToVersion4() { 1.536 + if (!_dbConnection.tableExists("moz_deleted_formhistory")) { 1.537 + let table = dbSchema.tables["moz_deleted_formhistory"]; 1.538 + let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", "); 1.539 + _dbConnection.createTable("moz_deleted_formhistory", tSQL); 1.540 + } 1.541 + } 1.542 +}; 1.543 + 1.544 +/** 1.545 + * dbAreExpectedColumnsPresent 1.546 + * 1.547 + * Sanity check to ensure that the columns this version of the code expects 1.548 + * are present in the DB we're using. 1.549 + */ 1.550 +function dbAreExpectedColumnsPresent() { 1.551 + for (let name in dbSchema.tables) { 1.552 + let table = dbSchema.tables[name]; 1.553 + let query = "SELECT " + 1.554 + [col for (col in table)].join(", ") + 1.555 + " FROM " + name; 1.556 + try { 1.557 + let stmt = _dbConnection.createStatement(query); 1.558 + // (no need to execute statement, if it compiled we're good) 1.559 + stmt.finalize(); 1.560 + } catch (e) { 1.561 + return false; 1.562 + } 1.563 + } 1.564 + 1.565 + log("verified that expected columns are present in DB."); 1.566 + return true; 1.567 +} 1.568 + 1.569 +/** 1.570 + * dbCleanup 1.571 + * 1.572 + * Called when database creation fails. Finalizes database statements, 1.573 + * closes the database connection, deletes the database file. 1.574 + */ 1.575 +function dbCleanup(dbFile) { 1.576 + log("Cleaning up DB file - close & remove & backup"); 1.577 + 1.578 + // Create backup file 1.579 + let backupFile = dbFile.leafName + ".corrupt"; 1.580 + Services.storage.backupDatabaseFile(dbFile, backupFile); 1.581 + 1.582 + dbClose(false); 1.583 + dbFile.remove(false); 1.584 +} 1.585 + 1.586 +function dbClose(aShutdown) { 1.587 + log("dbClose(" + aShutdown + ")"); 1.588 + 1.589 + if (aShutdown) { 1.590 + sendNotification("formhistory-shutdown", null); 1.591 + } 1.592 + 1.593 + // Connection may never have been created if say open failed but we still 1.594 + // end up calling dbClose as part of the rest of dbCleanup. 1.595 + if (!_dbConnection) { 1.596 + return; 1.597 + } 1.598 + 1.599 + log("dbClose finalize statements"); 1.600 + for (let stmt of dbStmts.values()) { 1.601 + stmt.finalize(); 1.602 + } 1.603 + 1.604 + dbStmts = new Map(); 1.605 + 1.606 + let closed = false; 1.607 + _dbConnection.asyncClose(function () closed = true); 1.608 + 1.609 + if (!aShutdown) { 1.610 + let thread = Services.tm.currentThread; 1.611 + while (!closed) { 1.612 + thread.processNextEvent(true); 1.613 + } 1.614 + } 1.615 +} 1.616 + 1.617 +/** 1.618 + * updateFormHistoryWrite 1.619 + * 1.620 + * Constructs and executes database statements from a pre-processed list of 1.621 + * inputted changes. 1.622 + */ 1.623 +function updateFormHistoryWrite(aChanges, aCallbacks) { 1.624 + log("updateFormHistoryWrite " + aChanges.length); 1.625 + 1.626 + // pass 'now' down so that every entry in the batch has the same timestamp 1.627 + let now = Date.now() * 1000; 1.628 + 1.629 + // for each change, we either create and append a new storage statement to 1.630 + // stmts or bind a new set of parameters to an existing storage statement. 1.631 + // stmts and bindingArrays are updated when makeXXXStatement eventually 1.632 + // calls dbCreateAsyncStatement. 1.633 + let stmts = []; 1.634 + let notifications = []; 1.635 + let bindingArrays = new Map(); 1.636 + 1.637 + for each (let change in aChanges) { 1.638 + let operation = change.op; 1.639 + delete change.op; 1.640 + let stmt; 1.641 + switch (operation) { 1.642 + case "remove": 1.643 + log("Remove from form history " + change); 1.644 + let delStmt = makeMoveToDeletedStatement(change.guid, now, change, bindingArrays); 1.645 + if (delStmt && stmts.indexOf(delStmt) == -1) 1.646 + stmts.push(delStmt); 1.647 + if ("timeDeleted" in change) 1.648 + delete change.timeDeleted; 1.649 + stmt = makeRemoveStatement(change, bindingArrays); 1.650 + notifications.push([ "formhistory-remove", change.guid ]); 1.651 + break; 1.652 + case "update": 1.653 + log("Update form history " + change); 1.654 + let guid = change.guid; 1.655 + delete change.guid; 1.656 + // a special case for updating the GUID - the new value can be 1.657 + // specified in newGuid. 1.658 + if (change.newGuid) { 1.659 + change.guid = change.newGuid 1.660 + delete change.newGuid; 1.661 + } 1.662 + stmt = makeUpdateStatement(guid, change, bindingArrays); 1.663 + notifications.push([ "formhistory-update", guid ]); 1.664 + break; 1.665 + case "bump": 1.666 + log("Bump form history " + change); 1.667 + if (change.guid) { 1.668 + stmt = makeBumpStatement(change.guid, now, bindingArrays); 1.669 + notifications.push([ "formhistory-update", change.guid ]); 1.670 + } else { 1.671 + change.guid = generateGUID(); 1.672 + stmt = makeAddStatement(change, now, bindingArrays); 1.673 + notifications.push([ "formhistory-add", change.guid ]); 1.674 + } 1.675 + break; 1.676 + case "add": 1.677 + log("Add to form history " + change); 1.678 + change.guid = generateGUID(); 1.679 + stmt = makeAddStatement(change, now, bindingArrays); 1.680 + notifications.push([ "formhistory-add", change.guid ]); 1.681 + break; 1.682 + default: 1.683 + // We should've already guaranteed that change.op is one of the above 1.684 + throw Components.Exception("Invalid operation " + operation, 1.685 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.686 + } 1.687 + 1.688 + // As identical statements are reused, only add statements if they aren't already present. 1.689 + if (stmt && stmts.indexOf(stmt) == -1) { 1.690 + stmts.push(stmt); 1.691 + } 1.692 + } 1.693 + 1.694 + for (let stmt of stmts) { 1.695 + stmt.bindParameters(bindingArrays.get(stmt)); 1.696 + } 1.697 + 1.698 + let handlers = { 1.699 + handleCompletion : function(aReason) { 1.700 + if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) { 1.701 + for (let [notification, param] of notifications) { 1.702 + // We're either sending a GUID or nothing at all. 1.703 + sendNotification(notification, param); 1.704 + } 1.705 + } 1.706 + 1.707 + if (aCallbacks && aCallbacks.handleCompletion) { 1.708 + aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); 1.709 + } 1.710 + }, 1.711 + handleError : function(aError) { 1.712 + if (aCallbacks && aCallbacks.handleError) { 1.713 + aCallbacks.handleError(aError); 1.714 + } 1.715 + }, 1.716 + handleResult : NOOP 1.717 + }; 1.718 + 1.719 + dbConnection.executeAsync(stmts, stmts.length, handlers); 1.720 +} 1.721 + 1.722 +/** 1.723 + * Functions that expire entries in form history and shrinks database 1.724 + * afterwards as necessary initiated by expireOldEntries. 1.725 + */ 1.726 + 1.727 +/** 1.728 + * expireOldEntriesDeletion 1.729 + * 1.730 + * Removes entries from database. 1.731 + */ 1.732 +function expireOldEntriesDeletion(aExpireTime, aBeginningCount) { 1.733 + log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")"); 1.734 + 1.735 + FormHistory.update([ 1.736 + { 1.737 + op: "remove", 1.738 + lastUsedEnd : aExpireTime, 1.739 + }], { 1.740 + handleCompletion: function() { 1.741 + expireOldEntriesVacuum(aExpireTime, aBeginningCount); 1.742 + }, 1.743 + handleError: function(aError) { 1.744 + log("expireOldEntriesDeletionFailure"); 1.745 + } 1.746 + }); 1.747 +} 1.748 + 1.749 +/** 1.750 + * expireOldEntriesVacuum 1.751 + * 1.752 + * Counts number of entries removed and shrinks database as necessary. 1.753 + */ 1.754 +function expireOldEntriesVacuum(aExpireTime, aBeginningCount) { 1.755 + FormHistory.count({}, { 1.756 + handleResult: function(aEndingCount) { 1.757 + if (aBeginningCount - aEndingCount > 500) { 1.758 + log("expireOldEntriesVacuum"); 1.759 + 1.760 + let stmt = dbCreateAsyncStatement("VACUUM"); 1.761 + stmt.executeAsync({ 1.762 + handleResult : NOOP, 1.763 + handleError : function(aError) { 1.764 + log("expireVacuumError"); 1.765 + }, 1.766 + handleCompletion : NOOP 1.767 + }); 1.768 + } 1.769 + 1.770 + sendNotification("formhistory-expireoldentries", aExpireTime); 1.771 + }, 1.772 + handleError: function(aError) { 1.773 + log("expireEndCountFailure"); 1.774 + } 1.775 + }); 1.776 +} 1.777 + 1.778 +this.FormHistory = { 1.779 + get enabled() Prefs.enabled, 1.780 + 1.781 + search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) { 1.782 + // if no terms selected, select everything 1.783 + aSelectTerms = (aSelectTerms) ? aSelectTerms : validFields; 1.784 + validateSearchData(aSearchData, "Search"); 1.785 + 1.786 + let stmt = makeSearchStatement(aSearchData, aSelectTerms); 1.787 + 1.788 + let handlers = { 1.789 + handleResult : function(aResultSet) { 1.790 + let formHistoryFields = dbSchema.tables.moz_formhistory; 1.791 + for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) { 1.792 + let result = {}; 1.793 + for each (let field in aSelectTerms) { 1.794 + result[field] = row.getResultByName(field); 1.795 + } 1.796 + 1.797 + if (aCallbacks && aCallbacks.handleResult) { 1.798 + aCallbacks.handleResult(result); 1.799 + } 1.800 + } 1.801 + }, 1.802 + 1.803 + handleError : function(aError) { 1.804 + if (aCallbacks && aCallbacks.handleError) { 1.805 + aCallbacks.handleError(aError); 1.806 + } 1.807 + }, 1.808 + 1.809 + handleCompletion : function searchCompletionHandler(aReason) { 1.810 + if (aCallbacks && aCallbacks.handleCompletion) { 1.811 + aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); 1.812 + } 1.813 + } 1.814 + }; 1.815 + 1.816 + stmt.executeAsync(handlers); 1.817 + }, 1.818 + 1.819 + count : function formHistoryCount(aSearchData, aCallbacks) { 1.820 + validateSearchData(aSearchData, "Count"); 1.821 + let stmt = makeCountStatement(aSearchData); 1.822 + let handlers = { 1.823 + handleResult : function countResultHandler(aResultSet) { 1.824 + let row = aResultSet.getNextRow(); 1.825 + let count = row.getResultByName("numEntries"); 1.826 + if (aCallbacks && aCallbacks.handleResult) { 1.827 + aCallbacks.handleResult(count); 1.828 + } 1.829 + }, 1.830 + 1.831 + handleError : function(aError) { 1.832 + if (aCallbacks && aCallbacks.handleError) { 1.833 + aCallbacks.handleError(aError); 1.834 + } 1.835 + }, 1.836 + 1.837 + handleCompletion : function searchCompletionHandler(aReason) { 1.838 + if (aCallbacks && aCallbacks.handleCompletion) { 1.839 + aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); 1.840 + } 1.841 + } 1.842 + }; 1.843 + 1.844 + stmt.executeAsync(handlers); 1.845 + }, 1.846 + 1.847 + update : function formHistoryUpdate(aChanges, aCallbacks) { 1.848 + if (!Prefs.enabled) { 1.849 + return; 1.850 + } 1.851 + 1.852 + // Used to keep track of how many searches have been started. When that number 1.853 + // are finished, updateFormHistoryWrite can be called. 1.854 + let numSearches = 0; 1.855 + let completedSearches = 0; 1.856 + let searchFailed = false; 1.857 + 1.858 + function validIdentifier(change) { 1.859 + // The identifier is only valid if one of either the guid or the (fieldname/value) are set 1.860 + return Boolean(change.guid) != Boolean(change.fieldname && change.value); 1.861 + } 1.862 + 1.863 + if (!("length" in aChanges)) 1.864 + aChanges = [aChanges]; 1.865 + 1.866 + for each (let change in aChanges) { 1.867 + switch (change.op) { 1.868 + case "remove": 1.869 + validateSearchData(change, "Remove"); 1.870 + continue; 1.871 + case "update": 1.872 + if (validIdentifier(change)) { 1.873 + validateOpData(change, "Update"); 1.874 + if (change.guid) { 1.875 + continue; 1.876 + } 1.877 + } else { 1.878 + throw Components.Exception( 1.879 + "update op='update' does not correctly reference a entry.", 1.880 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.881 + } 1.882 + break; 1.883 + case "bump": 1.884 + if (validIdentifier(change)) { 1.885 + validateOpData(change, "Bump"); 1.886 + if (change.guid) { 1.887 + continue; 1.888 + } 1.889 + } else { 1.890 + throw Components.Exception( 1.891 + "update op='bump' does not correctly reference a entry.", 1.892 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.893 + } 1.894 + break; 1.895 + case "add": 1.896 + if (change.guid) { 1.897 + throw Components.Exception( 1.898 + "op='add' cannot contain field 'guid'. Either use op='update' " + 1.899 + "explicitly or make 'guid' undefined.", 1.900 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.901 + } else if (change.fieldname && change.value) { 1.902 + validateOpData(change, "Add"); 1.903 + } 1.904 + break; 1.905 + default: 1.906 + throw Components.Exception( 1.907 + "update does not recognize op='" + change.op + "'", 1.908 + Cr.NS_ERROR_ILLEGAL_VALUE); 1.909 + } 1.910 + 1.911 + numSearches++; 1.912 + let changeToUpdate = change; 1.913 + FormHistory.search( 1.914 + [ "guid" ], 1.915 + { 1.916 + fieldname : change.fieldname, 1.917 + value : change.value 1.918 + }, { 1.919 + foundResult : false, 1.920 + handleResult : function(aResult) { 1.921 + if (this.foundResult) { 1.922 + log("Database contains multiple entries with the same fieldname/value pair."); 1.923 + if (aCallbacks && aCallbacks.handleError) { 1.924 + aCallbacks.handleError({ 1.925 + message : 1.926 + "Database contains multiple entries with the same fieldname/value pair.", 1.927 + result : 19 // Constraint violation 1.928 + }); 1.929 + } 1.930 + 1.931 + searchFailed = true; 1.932 + return; 1.933 + } 1.934 + 1.935 + this.foundResult = true; 1.936 + changeToUpdate.guid = aResult["guid"]; 1.937 + }, 1.938 + 1.939 + handleError : function(aError) { 1.940 + if (aCallbacks && aCallbacks.handleError) { 1.941 + aCallbacks.handleError(aError); 1.942 + } 1.943 + }, 1.944 + 1.945 + handleCompletion : function(aReason) { 1.946 + completedSearches++; 1.947 + if (completedSearches == numSearches) { 1.948 + if (!aReason && !searchFailed) { 1.949 + updateFormHistoryWrite(aChanges, aCallbacks); 1.950 + } 1.951 + else if (aCallbacks && aCallbacks.handleCompletion) { 1.952 + aCallbacks.handleCompletion(1); 1.953 + } 1.954 + } 1.955 + } 1.956 + }); 1.957 + } 1.958 + 1.959 + if (numSearches == 0) { 1.960 + // We don't have to wait for any statements to return. 1.961 + updateFormHistoryWrite(aChanges, aCallbacks); 1.962 + } 1.963 + }, 1.964 + 1.965 + getAutoCompleteResults: function getAutoCompleteResults(searchString, params, aCallbacks) { 1.966 + // only do substring matching when the search string contains more than one character 1.967 + let searchTokens; 1.968 + let where = "" 1.969 + let boundaryCalc = ""; 1.970 + if (searchString.length > 1) { 1.971 + searchTokens = searchString.split(/\s+/); 1.972 + 1.973 + // build up the word boundary and prefix match bonus calculation 1.974 + boundaryCalc = "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + ("; 1.975 + // for each word, calculate word boundary weights for the SELECT clause and 1.976 + // add word to the WHERE clause of the query 1.977 + let tokenCalc = []; 1.978 + let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS); 1.979 + for (let i = 0; i < searchTokenCount; i++) { 1.980 + tokenCalc.push("(value LIKE :tokenBegin" + i + " ESCAPE '/') + " + 1.981 + "(value LIKE :tokenBoundary" + i + " ESCAPE '/')"); 1.982 + where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') "; 1.983 + } 1.984 + // add more weight if we have a traditional prefix match and 1.985 + // multiply boundary bonuses by boundary weight 1.986 + boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)"; 1.987 + } else if (searchString.length == 1) { 1.988 + where = "AND (value LIKE :valuePrefix ESCAPE '/') "; 1.989 + boundaryCalc = "1"; 1.990 + delete params.prefixWeight; 1.991 + delete params.boundaryWeight; 1.992 + } else { 1.993 + where = ""; 1.994 + boundaryCalc = "1"; 1.995 + delete params.prefixWeight; 1.996 + delete params.boundaryWeight; 1.997 + } 1.998 + 1.999 + params.now = Date.now() * 1000; // convert from ms to microseconds 1.1000 + 1.1001 + /* Three factors in the frecency calculation for an entry (in order of use in calculation): 1.1002 + * 1) average number of times used - items used more are ranked higher 1.1003 + * 2) how recently it was last used - items used recently are ranked higher 1.1004 + * 3) additional weight for aged entries surviving expiry - these entries are relevant 1.1005 + * since they have been used multiple times over a large time span so rank them higher 1.1006 + * The score is then divided by the bucket size and we round the result so that entries 1.1007 + * with a very similar frecency are bucketed together with an alphabetical sort. This is 1.1008 + * to reduce the amount of moving around by entries while typing. 1.1009 + */ 1.1010 + 1.1011 + let query = "/* do not warn (bug 496471): can't use an index */ " + 1.1012 + "SELECT value, " + 1.1013 + "ROUND( " + 1.1014 + "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " + 1.1015 + "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+ 1.1016 + "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " + 1.1017 + ":bucketSize "+ 1.1018 + ", 3) AS frecency, " + 1.1019 + boundaryCalc + " AS boundaryBonuses " + 1.1020 + "FROM moz_formhistory " + 1.1021 + "WHERE fieldname=:fieldname " + where + 1.1022 + "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC"; 1.1023 + 1.1024 + let stmt = dbCreateAsyncStatement(query, params); 1.1025 + 1.1026 + // Chicken and egg problem: Need the statement to escape the params we 1.1027 + // pass to the function that gives us the statement. So, fix it up now. 1.1028 + if (searchString.length >= 1) 1.1029 + stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%"; 1.1030 + if (searchString.length > 1) { 1.1031 + let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS); 1.1032 + for (let i = 0; i < searchTokenCount; i++) { 1.1033 + let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/"); 1.1034 + stmt.params["tokenBegin" + i] = escapedToken + "%"; 1.1035 + stmt.params["tokenBoundary" + i] = "% " + escapedToken + "%"; 1.1036 + stmt.params["tokenContains" + i] = "%" + escapedToken + "%"; 1.1037 + } 1.1038 + } else { 1.1039 + // no additional params need to be substituted into the query when the 1.1040 + // length is zero or one 1.1041 + } 1.1042 + 1.1043 + let pending = stmt.executeAsync({ 1.1044 + handleResult : function (aResultSet) { 1.1045 + for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) { 1.1046 + let value = row.getResultByName("value"); 1.1047 + let frecency = row.getResultByName("frecency"); 1.1048 + let entry = { 1.1049 + text : value, 1.1050 + textLowerCase : value.toLowerCase(), 1.1051 + frecency : frecency, 1.1052 + totalScore : Math.round(frecency * row.getResultByName("boundaryBonuses")) 1.1053 + }; 1.1054 + if (aCallbacks && aCallbacks.handleResult) { 1.1055 + aCallbacks.handleResult(entry); 1.1056 + } 1.1057 + } 1.1058 + }, 1.1059 + 1.1060 + handleError : function (aError) { 1.1061 + if (aCallbacks && aCallbacks.handleError) { 1.1062 + aCallbacks.handleError(aError); 1.1063 + } 1.1064 + }, 1.1065 + 1.1066 + handleCompletion : function (aReason) { 1.1067 + if (aCallbacks && aCallbacks.handleCompletion) { 1.1068 + aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); 1.1069 + } 1.1070 + } 1.1071 + }); 1.1072 + return pending; 1.1073 + }, 1.1074 + 1.1075 + get schemaVersion() { 1.1076 + return dbConnection.schemaVersion; 1.1077 + }, 1.1078 + 1.1079 + // This is used only so that the test can verify deleted table support. 1.1080 + get _supportsDeletedTable() { 1.1081 + return supportsDeletedTable; 1.1082 + }, 1.1083 + set _supportsDeletedTable(val) { 1.1084 + supportsDeletedTable = val; 1.1085 + }, 1.1086 + 1.1087 + // The remaining methods are called by FormHistoryStartup.js 1.1088 + updatePrefs: function updatePrefs() { 1.1089 + Prefs.initialized = false; 1.1090 + }, 1.1091 + 1.1092 + expireOldEntries: function expireOldEntries() { 1.1093 + log("expireOldEntries"); 1.1094 + 1.1095 + // Determine how many days of history we're supposed to keep. 1.1096 + // Calculate expireTime in microseconds 1.1097 + let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000; 1.1098 + 1.1099 + sendNotification("formhistory-beforeexpireoldentries", expireTime); 1.1100 + 1.1101 + FormHistory.count({}, { 1.1102 + handleResult: function(aBeginningCount) { 1.1103 + expireOldEntriesDeletion(expireTime, aBeginningCount); 1.1104 + }, 1.1105 + handleError: function(aError) { 1.1106 + log("expireStartCountFailure"); 1.1107 + } 1.1108 + }); 1.1109 + }, 1.1110 + 1.1111 + shutdown: function shutdown() { dbClose(true); } 1.1112 +}; 1.1113 + 1.1114 +// Prevent add-ons from redefining this API 1.1115 +Object.freeze(FormHistory);