Fri, 16 Jan 2015 18:13:44 +0100
Integrate suggestion from review to improve consistency with existing code.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6 * FormHistory
7 *
8 * Used to store values that have been entered into forms which may later
9 * be used to automatically fill in the values when the form is visited again.
10 *
11 * search(terms, queryData, callback)
12 * Look up values that have been previously stored.
13 * terms - array of terms to return data for
14 * queryData - object that contains the query terms
15 * The query object contains properties for each search criteria to match, where the value
16 * of the property specifies the value that term must have. For example,
17 * { term1: value1, term2: value2 }
18 * callback - callback that is called when results are available or an error occurs.
19 * The callback is passed a result array containing each found entry. Each element in
20 * the array is an object containing a property for each search term specified by 'terms'.
21 * count(queryData, callback)
22 * Find the number of stored entries that match the given criteria.
23 * queryData - array of objects that indicate the query. See the search method for details.
24 * callback - callback that is called when results are available or an error occurs.
25 * The callback is passed the number of found entries.
26 * update(changes, callback)
27 * Write data to form history storage.
28 * changes - an array of changes to be made. If only one change is to be made, it
29 * may be passed as an object rather than a one-element array.
30 * Each change object is of the form:
31 * { op: operation, term1: value1, term2: value2, ... }
32 * Valid operations are:
33 * add - add a new entry
34 * update - update an existing entry
35 * remove - remove an entry
36 * bump - update the last accessed time on an entry
37 * The terms specified allow matching of one or more specific entries. If no terms
38 * are specified then all entries are matched. This means that { op: "remove" } is
39 * used to remove all entries and clear the form history.
40 * callback - callback that is called when results have been stored.
41 * getAutoCompeteResults(searchString, params, callback)
42 * Retrieve an array of form history values suitable for display in an autocomplete list.
43 * Returns an mozIStoragePendingStatement that can be used to cancel the operation if
44 * needed.
45 * searchString - the string to search for, typically the entered value of a textbox
46 * params - zero or more filter arguments:
47 * fieldname - form field name
48 * agedWeight
49 * bucketSize
50 * expiryDate
51 * maxTimeGroundings
52 * timeGroupingSize
53 * prefixWeight
54 * boundaryWeight
55 * callback - callback that is called with the array of results. Each result in the array
56 * is an object with four arguments:
57 * text, textLowerCase, frecency, totalScore
58 * schemaVersion
59 * This property holds the version of the database schema
60 *
61 * Terms:
62 * guid - entry identifier. For 'add', a guid will be generated.
63 * fieldname - form field name
64 * value - form value
65 * timesUsed - the number of times the entry has been accessed
66 * firstUsed - the time the the entry was first created
67 * lastUsed - the time the entry was last accessed
68 * firstUsedStart - search for entries created after or at this time
69 * firstUsedEnd - search for entries created before or at this time
70 * lastUsedStart - search for entries last accessed after or at this time
71 * lastUsedEnd - search for entries last accessed before or at this time
72 * newGuid - a special case valid only for 'update' and allows the guid for
73 * an existing record to be updated. The 'guid' term is the only
74 * other term which can be used (ie, you can not also specify a
75 * fieldname, value etc) and indicates the guid of the existing
76 * record that should be updated.
77 *
78 * In all of the above methods, the callback argument should be an object with
79 * handleResult(result), handleFailure(error) and handleCompletion(reason) functions.
80 * For search and getAutoCompeteResults, result is an object containing the desired
81 * properties. For count, result is the integer count. For, update, handleResult is
82 * not called. For handleCompletion, reason is either 0 if successful or 1 if
83 * an error occurred.
84 */
86 this.EXPORTED_SYMBOLS = ["FormHistory"];
88 const Cc = Components.classes;
89 const Ci = Components.interfaces;
90 const Cr = Components.results;
92 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
93 Components.utils.import("resource://gre/modules/Services.jsm");
95 XPCOMUtils.defineLazyServiceGetter(this, "uuidService",
96 "@mozilla.org/uuid-generator;1",
97 "nsIUUIDGenerator");
99 const DB_SCHEMA_VERSION = 4;
100 const DAY_IN_MS = 86400000; // 1 day in milliseconds
101 const MAX_SEARCH_TOKENS = 10;
102 const NOOP = function noop() {};
104 let supportsDeletedTable =
105 #ifdef ANDROID
106 true;
107 #else
108 false;
109 #endif
111 let Prefs = {
112 initialized: false,
114 get debug() { this.ensureInitialized(); return this._debug; },
115 get enabled() { this.ensureInitialized(); return this._enabled; },
116 get expireDays() { this.ensureInitialized(); return this._expireDays; },
118 ensureInitialized: function() {
119 if (this.initialized)
120 return;
122 this.initialized = true;
124 this._debug = Services.prefs.getBoolPref("browser.formfill.debug");
125 this._enabled = Services.prefs.getBoolPref("browser.formfill.enable");
126 this._expireDays = Services.prefs.getIntPref("browser.formfill.expire_days");
127 }
128 };
130 function log(aMessage) {
131 if (Prefs.debug) {
132 Services.console.logStringMessage("FormHistory: " + aMessage);
133 }
134 }
136 function sendNotification(aType, aData) {
137 if (typeof aData == "string") {
138 let strWrapper = Cc["@mozilla.org/supports-string;1"].
139 createInstance(Ci.nsISupportsString);
140 strWrapper.data = aData;
141 aData = strWrapper;
142 }
143 else if (typeof aData == "number") {
144 let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].
145 createInstance(Ci.nsISupportsPRInt64);
146 intWrapper.data = aData;
147 aData = intWrapper;
148 }
149 else if (aData) {
150 throw Components.Exception("Invalid type " + (typeof aType) + " passed to sendNotification",
151 Cr.NS_ERROR_ILLEGAL_VALUE);
152 }
154 Services.obs.notifyObservers(aData, "satchel-storage-changed", aType);
155 }
157 /**
158 * Current database schema
159 */
161 const dbSchema = {
162 tables : {
163 moz_formhistory : {
164 "id" : "INTEGER PRIMARY KEY",
165 "fieldname" : "TEXT NOT NULL",
166 "value" : "TEXT NOT NULL",
167 "timesUsed" : "INTEGER",
168 "firstUsed" : "INTEGER",
169 "lastUsed" : "INTEGER",
170 "guid" : "TEXT",
171 },
172 moz_deleted_formhistory: {
173 "id" : "INTEGER PRIMARY KEY",
174 "timeDeleted" : "INTEGER",
175 "guid" : "TEXT"
176 }
177 },
178 indices : {
179 moz_formhistory_index : {
180 table : "moz_formhistory",
181 columns : [ "fieldname" ]
182 },
183 moz_formhistory_lastused_index : {
184 table : "moz_formhistory",
185 columns : [ "lastUsed" ]
186 },
187 moz_formhistory_guid_index : {
188 table : "moz_formhistory",
189 columns : [ "guid" ]
190 },
191 }
192 };
194 /**
195 * Validating and processing API querying data
196 */
198 const validFields = [
199 "fieldname",
200 "value",
201 "timesUsed",
202 "firstUsed",
203 "lastUsed",
204 "guid",
205 ];
207 const searchFilters = [
208 "firstUsedStart",
209 "firstUsedEnd",
210 "lastUsedStart",
211 "lastUsedEnd",
212 ];
214 function validateOpData(aData, aDataType) {
215 let thisValidFields = validFields;
216 // A special case to update the GUID - in this case there can be a 'newGuid'
217 // field and of the normally valid fields, only 'guid' is accepted.
218 if (aDataType == "Update" && "newGuid" in aData) {
219 thisValidFields = ["guid", "newGuid"];
220 }
221 for (let field in aData) {
222 if (field != "op" && thisValidFields.indexOf(field) == -1) {
223 throw Components.Exception(
224 aDataType + " query contains an unrecognized field: " + field,
225 Cr.NS_ERROR_ILLEGAL_VALUE);
226 }
227 }
228 return aData;
229 }
231 function validateSearchData(aData, aDataType) {
232 for (let field in aData) {
233 if (field != "op" && validFields.indexOf(field) == -1 && searchFilters.indexOf(field) == -1) {
234 throw Components.Exception(
235 aDataType + " query contains an unrecognized field: " + field,
236 Cr.NS_ERROR_ILLEGAL_VALUE);
237 }
238 }
239 }
241 function makeQueryPredicates(aQueryData, delimiter = ' AND ') {
242 return Object.keys(aQueryData).map(function(field) {
243 if (field == "firstUsedStart") {
244 return "firstUsed >= :" + field;
245 } else if (field == "firstUsedEnd") {
246 return "firstUsed <= :" + field;
247 } else if (field == "lastUsedStart") {
248 return "lastUsed >= :" + field;
249 } else if (field == "lastUsedEnd") {
250 return "lastUsed <= :" + field;
251 }
252 return field + " = :" + field;
253 }).join(delimiter);
254 }
256 /**
257 * Storage statement creation and parameter binding
258 */
260 function makeCountStatement(aSearchData) {
261 let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
262 let queryTerms = makeQueryPredicates(aSearchData);
263 if (queryTerms) {
264 query += " WHERE " + queryTerms;
265 }
266 return dbCreateAsyncStatement(query, aSearchData);
267 }
269 function makeSearchStatement(aSearchData, aSelectTerms) {
270 let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory";
271 let queryTerms = makeQueryPredicates(aSearchData);
272 if (queryTerms) {
273 query += " WHERE " + queryTerms;
274 }
276 return dbCreateAsyncStatement(query, aSearchData);
277 }
279 function makeAddStatement(aNewData, aNow, aBindingArrays) {
280 let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " +
281 "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)";
283 aNewData.timesUsed = aNewData.timesUsed || 1;
284 aNewData.firstUsed = aNewData.firstUsed || aNow;
285 aNewData.lastUsed = aNewData.lastUsed || aNow;
286 return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
287 }
289 function makeBumpStatement(aGuid, aNow, aBindingArrays) {
290 let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
291 let queryParams = {
292 lastUsed : aNow,
293 guid : aGuid,
294 };
296 return dbCreateAsyncStatement(query, queryParams, aBindingArrays);
297 }
299 function makeRemoveStatement(aSearchData, aBindingArrays) {
300 let query = "DELETE FROM moz_formhistory";
301 let queryTerms = makeQueryPredicates(aSearchData);
303 if (queryTerms) {
304 log("removeEntries");
305 query += " WHERE " + queryTerms;
306 } else {
307 log("removeAllEntries");
308 // Not specifying any fields means we should remove all entries. We
309 // won't need to modify the query in this case.
310 }
312 return dbCreateAsyncStatement(query, aSearchData, aBindingArrays);
313 }
315 function makeUpdateStatement(aGuid, aNewData, aBindingArrays) {
316 let query = "UPDATE moz_formhistory SET ";
317 let queryTerms = makeQueryPredicates(aNewData, ', ');
319 if (!queryTerms) {
320 throw Components.Exception("Update query must define fields to modify.",
321 Cr.NS_ERROR_ILLEGAL_VALUE);
322 }
324 query += queryTerms + " WHERE guid = :existing_guid";
325 aNewData["existing_guid"] = aGuid;
327 return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
328 }
330 function makeMoveToDeletedStatement(aGuid, aNow, aData, aBindingArrays) {
331 if (supportsDeletedTable) {
332 let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
333 let queryTerms = makeQueryPredicates(aData);
335 if (aGuid) {
336 query += " VALUES (:guid, :timeDeleted)";
337 } else {
338 // TODO: Add these items to the deleted items table once we've sorted
339 // out the issues from bug 756701
340 if (!queryTerms)
341 return;
343 query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms;
344 }
346 aData.timeDeleted = aNow;
348 return dbCreateAsyncStatement(query, aData, aBindingArrays);
349 }
351 return null;
352 }
354 function generateGUID() {
355 // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
356 let uuid = uuidService.generateUUID().toString();
357 let raw = ""; // A string with the low bytes set to random values
358 let bytes = 0;
359 for (let i = 1; bytes < 12 ; i+= 2) {
360 // Skip dashes
361 if (uuid[i] == "-")
362 i++;
363 let hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
364 raw += String.fromCharCode(hexVal);
365 bytes++;
366 }
367 return btoa(raw);
368 }
370 /**
371 * Database creation and access
372 */
374 let _dbConnection = null;
375 XPCOMUtils.defineLazyGetter(this, "dbConnection", function() {
376 let dbFile;
378 try {
379 dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
380 dbFile.append("formhistory.sqlite");
381 log("Opening database at " + dbFile.path);
383 _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
384 dbInit();
385 } catch (e if e.result == Cr.NS_ERROR_FILE_CORRUPTED) {
386 dbCleanup(dbFile);
387 _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
388 dbInit();
389 }
391 return _dbConnection;
392 });
395 let dbStmts = new Map();
397 /*
398 * dbCreateAsyncStatement
399 *
400 * Creates a statement, wraps it, and then does parameter replacement
401 */
402 function dbCreateAsyncStatement(aQuery, aParams, aBindingArrays) {
403 if (!aQuery)
404 return null;
406 let stmt = dbStmts.get(aQuery);
407 if (!stmt) {
408 log("Creating new statement for query: " + aQuery);
409 stmt = dbConnection.createAsyncStatement(aQuery);
410 dbStmts.set(aQuery, stmt);
411 }
413 if (aBindingArrays) {
414 let bindingArray = aBindingArrays.get(stmt);
415 if (!bindingArray) {
416 // first time using a particular statement in update
417 bindingArray = stmt.newBindingParamsArray();
418 aBindingArrays.set(stmt, bindingArray);
419 }
421 if (aParams) {
422 let bindingParams = bindingArray.newBindingParams();
423 for (let field in aParams) {
424 bindingParams.bindByName(field, aParams[field]);
425 }
426 bindingArray.addParams(bindingParams);
427 }
428 } else {
429 if (aParams) {
430 for (let field in aParams) {
431 stmt.params[field] = aParams[field];
432 }
433 }
434 }
436 return stmt;
437 }
439 /**
440 * dbInit
441 *
442 * Attempts to initialize the database. This creates the file if it doesn't
443 * exist, performs any migrations, etc.
444 */
445 function dbInit() {
446 log("Initializing Database");
448 if (!_dbConnection.tableExists("moz_formhistory")) {
449 dbCreate();
450 return;
451 }
453 // When FormHistory is released, we will no longer support the various schema versions prior to
454 // this release that nsIFormHistory2 once did.
455 let version = _dbConnection.schemaVersion;
456 if (version < 3) {
457 throw Components.Exception("DB version is unsupported.",
458 Cr.NS_ERROR_FILE_CORRUPTED);
459 } else if (version != DB_SCHEMA_VERSION) {
460 dbMigrate(version);
461 }
462 }
464 function dbCreate() {
465 log("Creating DB -- tables");
466 for (let name in dbSchema.tables) {
467 let table = dbSchema.tables[name];
468 let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", ");
469 log("Creating table " + name + " with " + tSQL);
470 _dbConnection.createTable(name, tSQL);
471 }
473 log("Creating DB -- indices");
474 for (let name in dbSchema.indices) {
475 let index = dbSchema.indices[name];
476 let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table +
477 "(" + index.columns.join(", ") + ")";
478 _dbConnection.executeSimpleSQL(statement);
479 }
481 _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
482 }
484 function dbMigrate(oldVersion) {
485 log("Attempting to migrate from version " + oldVersion);
487 if (oldVersion > DB_SCHEMA_VERSION) {
488 log("Downgrading to version " + DB_SCHEMA_VERSION);
489 // User's DB is newer. Sanity check that our expected columns are
490 // present, and if so mark the lower version and merrily continue
491 // on. If the columns are borked, something is wrong so blow away
492 // the DB and start from scratch. [Future incompatible upgrades
493 // should switch to a different table or file.]
495 if (!dbAreExpectedColumnsPresent()) {
496 throw Components.Exception("DB is missing expected columns",
497 Cr.NS_ERROR_FILE_CORRUPTED);
498 }
500 // Change the stored version to the current version. If the user
501 // runs the newer code again, it will see the lower version number
502 // and re-upgrade (to fixup any entries the old code added).
503 _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
504 return;
505 }
507 // Note that migration is currently performed synchronously.
508 _dbConnection.beginTransaction();
510 try {
511 for (let v = oldVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
512 this.log("Upgrading to version " + v + "...");
513 Migrators["dbMigrateToVersion" + v]();
514 }
515 } catch (e) {
516 this.log("Migration failed: " + e);
517 this.dbConnection.rollbackTransaction();
518 throw e;
519 }
521 _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
522 _dbConnection.commitTransaction();
524 log("DB migration completed.");
525 }
527 var Migrators = {
528 /*
529 * Updates the DB schema to v3 (bug 506402).
530 * Adds deleted form history table.
531 */
532 dbMigrateToVersion4: function dbMigrateToVersion4() {
533 if (!_dbConnection.tableExists("moz_deleted_formhistory")) {
534 let table = dbSchema.tables["moz_deleted_formhistory"];
535 let tSQL = [[col, table[col]].join(" ") for (col in table)].join(", ");
536 _dbConnection.createTable("moz_deleted_formhistory", tSQL);
537 }
538 }
539 };
541 /**
542 * dbAreExpectedColumnsPresent
543 *
544 * Sanity check to ensure that the columns this version of the code expects
545 * are present in the DB we're using.
546 */
547 function dbAreExpectedColumnsPresent() {
548 for (let name in dbSchema.tables) {
549 let table = dbSchema.tables[name];
550 let query = "SELECT " +
551 [col for (col in table)].join(", ") +
552 " FROM " + name;
553 try {
554 let stmt = _dbConnection.createStatement(query);
555 // (no need to execute statement, if it compiled we're good)
556 stmt.finalize();
557 } catch (e) {
558 return false;
559 }
560 }
562 log("verified that expected columns are present in DB.");
563 return true;
564 }
566 /**
567 * dbCleanup
568 *
569 * Called when database creation fails. Finalizes database statements,
570 * closes the database connection, deletes the database file.
571 */
572 function dbCleanup(dbFile) {
573 log("Cleaning up DB file - close & remove & backup");
575 // Create backup file
576 let backupFile = dbFile.leafName + ".corrupt";
577 Services.storage.backupDatabaseFile(dbFile, backupFile);
579 dbClose(false);
580 dbFile.remove(false);
581 }
583 function dbClose(aShutdown) {
584 log("dbClose(" + aShutdown + ")");
586 if (aShutdown) {
587 sendNotification("formhistory-shutdown", null);
588 }
590 // Connection may never have been created if say open failed but we still
591 // end up calling dbClose as part of the rest of dbCleanup.
592 if (!_dbConnection) {
593 return;
594 }
596 log("dbClose finalize statements");
597 for (let stmt of dbStmts.values()) {
598 stmt.finalize();
599 }
601 dbStmts = new Map();
603 let closed = false;
604 _dbConnection.asyncClose(function () closed = true);
606 if (!aShutdown) {
607 let thread = Services.tm.currentThread;
608 while (!closed) {
609 thread.processNextEvent(true);
610 }
611 }
612 }
614 /**
615 * updateFormHistoryWrite
616 *
617 * Constructs and executes database statements from a pre-processed list of
618 * inputted changes.
619 */
620 function updateFormHistoryWrite(aChanges, aCallbacks) {
621 log("updateFormHistoryWrite " + aChanges.length);
623 // pass 'now' down so that every entry in the batch has the same timestamp
624 let now = Date.now() * 1000;
626 // for each change, we either create and append a new storage statement to
627 // stmts or bind a new set of parameters to an existing storage statement.
628 // stmts and bindingArrays are updated when makeXXXStatement eventually
629 // calls dbCreateAsyncStatement.
630 let stmts = [];
631 let notifications = [];
632 let bindingArrays = new Map();
634 for each (let change in aChanges) {
635 let operation = change.op;
636 delete change.op;
637 let stmt;
638 switch (operation) {
639 case "remove":
640 log("Remove from form history " + change);
641 let delStmt = makeMoveToDeletedStatement(change.guid, now, change, bindingArrays);
642 if (delStmt && stmts.indexOf(delStmt) == -1)
643 stmts.push(delStmt);
644 if ("timeDeleted" in change)
645 delete change.timeDeleted;
646 stmt = makeRemoveStatement(change, bindingArrays);
647 notifications.push([ "formhistory-remove", change.guid ]);
648 break;
649 case "update":
650 log("Update form history " + change);
651 let guid = change.guid;
652 delete change.guid;
653 // a special case for updating the GUID - the new value can be
654 // specified in newGuid.
655 if (change.newGuid) {
656 change.guid = change.newGuid
657 delete change.newGuid;
658 }
659 stmt = makeUpdateStatement(guid, change, bindingArrays);
660 notifications.push([ "formhistory-update", guid ]);
661 break;
662 case "bump":
663 log("Bump form history " + change);
664 if (change.guid) {
665 stmt = makeBumpStatement(change.guid, now, bindingArrays);
666 notifications.push([ "formhistory-update", change.guid ]);
667 } else {
668 change.guid = generateGUID();
669 stmt = makeAddStatement(change, now, bindingArrays);
670 notifications.push([ "formhistory-add", change.guid ]);
671 }
672 break;
673 case "add":
674 log("Add to form history " + change);
675 change.guid = generateGUID();
676 stmt = makeAddStatement(change, now, bindingArrays);
677 notifications.push([ "formhistory-add", change.guid ]);
678 break;
679 default:
680 // We should've already guaranteed that change.op is one of the above
681 throw Components.Exception("Invalid operation " + operation,
682 Cr.NS_ERROR_ILLEGAL_VALUE);
683 }
685 // As identical statements are reused, only add statements if they aren't already present.
686 if (stmt && stmts.indexOf(stmt) == -1) {
687 stmts.push(stmt);
688 }
689 }
691 for (let stmt of stmts) {
692 stmt.bindParameters(bindingArrays.get(stmt));
693 }
695 let handlers = {
696 handleCompletion : function(aReason) {
697 if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
698 for (let [notification, param] of notifications) {
699 // We're either sending a GUID or nothing at all.
700 sendNotification(notification, param);
701 }
702 }
704 if (aCallbacks && aCallbacks.handleCompletion) {
705 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
706 }
707 },
708 handleError : function(aError) {
709 if (aCallbacks && aCallbacks.handleError) {
710 aCallbacks.handleError(aError);
711 }
712 },
713 handleResult : NOOP
714 };
716 dbConnection.executeAsync(stmts, stmts.length, handlers);
717 }
719 /**
720 * Functions that expire entries in form history and shrinks database
721 * afterwards as necessary initiated by expireOldEntries.
722 */
724 /**
725 * expireOldEntriesDeletion
726 *
727 * Removes entries from database.
728 */
729 function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
730 log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")");
732 FormHistory.update([
733 {
734 op: "remove",
735 lastUsedEnd : aExpireTime,
736 }], {
737 handleCompletion: function() {
738 expireOldEntriesVacuum(aExpireTime, aBeginningCount);
739 },
740 handleError: function(aError) {
741 log("expireOldEntriesDeletionFailure");
742 }
743 });
744 }
746 /**
747 * expireOldEntriesVacuum
748 *
749 * Counts number of entries removed and shrinks database as necessary.
750 */
751 function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
752 FormHistory.count({}, {
753 handleResult: function(aEndingCount) {
754 if (aBeginningCount - aEndingCount > 500) {
755 log("expireOldEntriesVacuum");
757 let stmt = dbCreateAsyncStatement("VACUUM");
758 stmt.executeAsync({
759 handleResult : NOOP,
760 handleError : function(aError) {
761 log("expireVacuumError");
762 },
763 handleCompletion : NOOP
764 });
765 }
767 sendNotification("formhistory-expireoldentries", aExpireTime);
768 },
769 handleError: function(aError) {
770 log("expireEndCountFailure");
771 }
772 });
773 }
775 this.FormHistory = {
776 get enabled() Prefs.enabled,
778 search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) {
779 // if no terms selected, select everything
780 aSelectTerms = (aSelectTerms) ? aSelectTerms : validFields;
781 validateSearchData(aSearchData, "Search");
783 let stmt = makeSearchStatement(aSearchData, aSelectTerms);
785 let handlers = {
786 handleResult : function(aResultSet) {
787 let formHistoryFields = dbSchema.tables.moz_formhistory;
788 for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
789 let result = {};
790 for each (let field in aSelectTerms) {
791 result[field] = row.getResultByName(field);
792 }
794 if (aCallbacks && aCallbacks.handleResult) {
795 aCallbacks.handleResult(result);
796 }
797 }
798 },
800 handleError : function(aError) {
801 if (aCallbacks && aCallbacks.handleError) {
802 aCallbacks.handleError(aError);
803 }
804 },
806 handleCompletion : function searchCompletionHandler(aReason) {
807 if (aCallbacks && aCallbacks.handleCompletion) {
808 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
809 }
810 }
811 };
813 stmt.executeAsync(handlers);
814 },
816 count : function formHistoryCount(aSearchData, aCallbacks) {
817 validateSearchData(aSearchData, "Count");
818 let stmt = makeCountStatement(aSearchData);
819 let handlers = {
820 handleResult : function countResultHandler(aResultSet) {
821 let row = aResultSet.getNextRow();
822 let count = row.getResultByName("numEntries");
823 if (aCallbacks && aCallbacks.handleResult) {
824 aCallbacks.handleResult(count);
825 }
826 },
828 handleError : function(aError) {
829 if (aCallbacks && aCallbacks.handleError) {
830 aCallbacks.handleError(aError);
831 }
832 },
834 handleCompletion : function searchCompletionHandler(aReason) {
835 if (aCallbacks && aCallbacks.handleCompletion) {
836 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
837 }
838 }
839 };
841 stmt.executeAsync(handlers);
842 },
844 update : function formHistoryUpdate(aChanges, aCallbacks) {
845 if (!Prefs.enabled) {
846 return;
847 }
849 // Used to keep track of how many searches have been started. When that number
850 // are finished, updateFormHistoryWrite can be called.
851 let numSearches = 0;
852 let completedSearches = 0;
853 let searchFailed = false;
855 function validIdentifier(change) {
856 // The identifier is only valid if one of either the guid or the (fieldname/value) are set
857 return Boolean(change.guid) != Boolean(change.fieldname && change.value);
858 }
860 if (!("length" in aChanges))
861 aChanges = [aChanges];
863 for each (let change in aChanges) {
864 switch (change.op) {
865 case "remove":
866 validateSearchData(change, "Remove");
867 continue;
868 case "update":
869 if (validIdentifier(change)) {
870 validateOpData(change, "Update");
871 if (change.guid) {
872 continue;
873 }
874 } else {
875 throw Components.Exception(
876 "update op='update' does not correctly reference a entry.",
877 Cr.NS_ERROR_ILLEGAL_VALUE);
878 }
879 break;
880 case "bump":
881 if (validIdentifier(change)) {
882 validateOpData(change, "Bump");
883 if (change.guid) {
884 continue;
885 }
886 } else {
887 throw Components.Exception(
888 "update op='bump' does not correctly reference a entry.",
889 Cr.NS_ERROR_ILLEGAL_VALUE);
890 }
891 break;
892 case "add":
893 if (change.guid) {
894 throw Components.Exception(
895 "op='add' cannot contain field 'guid'. Either use op='update' " +
896 "explicitly or make 'guid' undefined.",
897 Cr.NS_ERROR_ILLEGAL_VALUE);
898 } else if (change.fieldname && change.value) {
899 validateOpData(change, "Add");
900 }
901 break;
902 default:
903 throw Components.Exception(
904 "update does not recognize op='" + change.op + "'",
905 Cr.NS_ERROR_ILLEGAL_VALUE);
906 }
908 numSearches++;
909 let changeToUpdate = change;
910 FormHistory.search(
911 [ "guid" ],
912 {
913 fieldname : change.fieldname,
914 value : change.value
915 }, {
916 foundResult : false,
917 handleResult : function(aResult) {
918 if (this.foundResult) {
919 log("Database contains multiple entries with the same fieldname/value pair.");
920 if (aCallbacks && aCallbacks.handleError) {
921 aCallbacks.handleError({
922 message :
923 "Database contains multiple entries with the same fieldname/value pair.",
924 result : 19 // Constraint violation
925 });
926 }
928 searchFailed = true;
929 return;
930 }
932 this.foundResult = true;
933 changeToUpdate.guid = aResult["guid"];
934 },
936 handleError : function(aError) {
937 if (aCallbacks && aCallbacks.handleError) {
938 aCallbacks.handleError(aError);
939 }
940 },
942 handleCompletion : function(aReason) {
943 completedSearches++;
944 if (completedSearches == numSearches) {
945 if (!aReason && !searchFailed) {
946 updateFormHistoryWrite(aChanges, aCallbacks);
947 }
948 else if (aCallbacks && aCallbacks.handleCompletion) {
949 aCallbacks.handleCompletion(1);
950 }
951 }
952 }
953 });
954 }
956 if (numSearches == 0) {
957 // We don't have to wait for any statements to return.
958 updateFormHistoryWrite(aChanges, aCallbacks);
959 }
960 },
962 getAutoCompleteResults: function getAutoCompleteResults(searchString, params, aCallbacks) {
963 // only do substring matching when the search string contains more than one character
964 let searchTokens;
965 let where = ""
966 let boundaryCalc = "";
967 if (searchString.length > 1) {
968 searchTokens = searchString.split(/\s+/);
970 // build up the word boundary and prefix match bonus calculation
971 boundaryCalc = "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
972 // for each word, calculate word boundary weights for the SELECT clause and
973 // add word to the WHERE clause of the query
974 let tokenCalc = [];
975 let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
976 for (let i = 0; i < searchTokenCount; i++) {
977 tokenCalc.push("(value LIKE :tokenBegin" + i + " ESCAPE '/') + " +
978 "(value LIKE :tokenBoundary" + i + " ESCAPE '/')");
979 where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') ";
980 }
981 // add more weight if we have a traditional prefix match and
982 // multiply boundary bonuses by boundary weight
983 boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
984 } else if (searchString.length == 1) {
985 where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
986 boundaryCalc = "1";
987 delete params.prefixWeight;
988 delete params.boundaryWeight;
989 } else {
990 where = "";
991 boundaryCalc = "1";
992 delete params.prefixWeight;
993 delete params.boundaryWeight;
994 }
996 params.now = Date.now() * 1000; // convert from ms to microseconds
998 /* Three factors in the frecency calculation for an entry (in order of use in calculation):
999 * 1) average number of times used - items used more are ranked higher
1000 * 2) how recently it was last used - items used recently are ranked higher
1001 * 3) additional weight for aged entries surviving expiry - these entries are relevant
1002 * since they have been used multiple times over a large time span so rank them higher
1003 * The score is then divided by the bucket size and we round the result so that entries
1004 * with a very similar frecency are bucketed together with an alphabetical sort. This is
1005 * to reduce the amount of moving around by entries while typing.
1006 */
1008 let query = "/* do not warn (bug 496471): can't use an index */ " +
1009 "SELECT value, " +
1010 "ROUND( " +
1011 "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
1012 "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+
1013 "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
1014 ":bucketSize "+
1015 ", 3) AS frecency, " +
1016 boundaryCalc + " AS boundaryBonuses " +
1017 "FROM moz_formhistory " +
1018 "WHERE fieldname=:fieldname " + where +
1019 "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
1021 let stmt = dbCreateAsyncStatement(query, params);
1023 // Chicken and egg problem: Need the statement to escape the params we
1024 // pass to the function that gives us the statement. So, fix it up now.
1025 if (searchString.length >= 1)
1026 stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%";
1027 if (searchString.length > 1) {
1028 let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
1029 for (let i = 0; i < searchTokenCount; i++) {
1030 let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/");
1031 stmt.params["tokenBegin" + i] = escapedToken + "%";
1032 stmt.params["tokenBoundary" + i] = "% " + escapedToken + "%";
1033 stmt.params["tokenContains" + i] = "%" + escapedToken + "%";
1034 }
1035 } else {
1036 // no additional params need to be substituted into the query when the
1037 // length is zero or one
1038 }
1040 let pending = stmt.executeAsync({
1041 handleResult : function (aResultSet) {
1042 for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
1043 let value = row.getResultByName("value");
1044 let frecency = row.getResultByName("frecency");
1045 let entry = {
1046 text : value,
1047 textLowerCase : value.toLowerCase(),
1048 frecency : frecency,
1049 totalScore : Math.round(frecency * row.getResultByName("boundaryBonuses"))
1050 };
1051 if (aCallbacks && aCallbacks.handleResult) {
1052 aCallbacks.handleResult(entry);
1053 }
1054 }
1055 },
1057 handleError : function (aError) {
1058 if (aCallbacks && aCallbacks.handleError) {
1059 aCallbacks.handleError(aError);
1060 }
1061 },
1063 handleCompletion : function (aReason) {
1064 if (aCallbacks && aCallbacks.handleCompletion) {
1065 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
1066 }
1067 }
1068 });
1069 return pending;
1070 },
1072 get schemaVersion() {
1073 return dbConnection.schemaVersion;
1074 },
1076 // This is used only so that the test can verify deleted table support.
1077 get _supportsDeletedTable() {
1078 return supportsDeletedTable;
1079 },
1080 set _supportsDeletedTable(val) {
1081 supportsDeletedTable = val;
1082 },
1084 // The remaining methods are called by FormHistoryStartup.js
1085 updatePrefs: function updatePrefs() {
1086 Prefs.initialized = false;
1087 },
1089 expireOldEntries: function expireOldEntries() {
1090 log("expireOldEntries");
1092 // Determine how many days of history we're supposed to keep.
1093 // Calculate expireTime in microseconds
1094 let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000;
1096 sendNotification("formhistory-beforeexpireoldentries", expireTime);
1098 FormHistory.count({}, {
1099 handleResult: function(aBeginningCount) {
1100 expireOldEntriesDeletion(expireTime, aBeginningCount);
1101 },
1102 handleError: function(aError) {
1103 log("expireStartCountFailure");
1104 }
1105 });
1106 },
1108 shutdown: function shutdown() { dbClose(true); }
1109 };
1111 // Prevent add-ons from redefining this API
1112 Object.freeze(FormHistory);