|
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/. */ |
|
4 |
|
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 */ |
|
85 |
|
86 this.EXPORTED_SYMBOLS = ["FormHistory"]; |
|
87 |
|
88 const Cc = Components.classes; |
|
89 const Ci = Components.interfaces; |
|
90 const Cr = Components.results; |
|
91 |
|
92 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
93 Components.utils.import("resource://gre/modules/Services.jsm"); |
|
94 |
|
95 XPCOMUtils.defineLazyServiceGetter(this, "uuidService", |
|
96 "@mozilla.org/uuid-generator;1", |
|
97 "nsIUUIDGenerator"); |
|
98 |
|
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() {}; |
|
103 |
|
104 let supportsDeletedTable = |
|
105 #ifdef ANDROID |
|
106 true; |
|
107 #else |
|
108 false; |
|
109 #endif |
|
110 |
|
111 let Prefs = { |
|
112 initialized: false, |
|
113 |
|
114 get debug() { this.ensureInitialized(); return this._debug; }, |
|
115 get enabled() { this.ensureInitialized(); return this._enabled; }, |
|
116 get expireDays() { this.ensureInitialized(); return this._expireDays; }, |
|
117 |
|
118 ensureInitialized: function() { |
|
119 if (this.initialized) |
|
120 return; |
|
121 |
|
122 this.initialized = true; |
|
123 |
|
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 }; |
|
129 |
|
130 function log(aMessage) { |
|
131 if (Prefs.debug) { |
|
132 Services.console.logStringMessage("FormHistory: " + aMessage); |
|
133 } |
|
134 } |
|
135 |
|
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 } |
|
153 |
|
154 Services.obs.notifyObservers(aData, "satchel-storage-changed", aType); |
|
155 } |
|
156 |
|
157 /** |
|
158 * Current database schema |
|
159 */ |
|
160 |
|
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 }; |
|
193 |
|
194 /** |
|
195 * Validating and processing API querying data |
|
196 */ |
|
197 |
|
198 const validFields = [ |
|
199 "fieldname", |
|
200 "value", |
|
201 "timesUsed", |
|
202 "firstUsed", |
|
203 "lastUsed", |
|
204 "guid", |
|
205 ]; |
|
206 |
|
207 const searchFilters = [ |
|
208 "firstUsedStart", |
|
209 "firstUsedEnd", |
|
210 "lastUsedStart", |
|
211 "lastUsedEnd", |
|
212 ]; |
|
213 |
|
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 } |
|
230 |
|
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 } |
|
240 |
|
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 } |
|
255 |
|
256 /** |
|
257 * Storage statement creation and parameter binding |
|
258 */ |
|
259 |
|
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 } |
|
268 |
|
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 } |
|
275 |
|
276 return dbCreateAsyncStatement(query, aSearchData); |
|
277 } |
|
278 |
|
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)"; |
|
282 |
|
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 } |
|
288 |
|
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 }; |
|
295 |
|
296 return dbCreateAsyncStatement(query, queryParams, aBindingArrays); |
|
297 } |
|
298 |
|
299 function makeRemoveStatement(aSearchData, aBindingArrays) { |
|
300 let query = "DELETE FROM moz_formhistory"; |
|
301 let queryTerms = makeQueryPredicates(aSearchData); |
|
302 |
|
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 } |
|
311 |
|
312 return dbCreateAsyncStatement(query, aSearchData, aBindingArrays); |
|
313 } |
|
314 |
|
315 function makeUpdateStatement(aGuid, aNewData, aBindingArrays) { |
|
316 let query = "UPDATE moz_formhistory SET "; |
|
317 let queryTerms = makeQueryPredicates(aNewData, ', '); |
|
318 |
|
319 if (!queryTerms) { |
|
320 throw Components.Exception("Update query must define fields to modify.", |
|
321 Cr.NS_ERROR_ILLEGAL_VALUE); |
|
322 } |
|
323 |
|
324 query += queryTerms + " WHERE guid = :existing_guid"; |
|
325 aNewData["existing_guid"] = aGuid; |
|
326 |
|
327 return dbCreateAsyncStatement(query, aNewData, aBindingArrays); |
|
328 } |
|
329 |
|
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); |
|
334 |
|
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; |
|
342 |
|
343 query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms; |
|
344 } |
|
345 |
|
346 aData.timeDeleted = aNow; |
|
347 |
|
348 return dbCreateAsyncStatement(query, aData, aBindingArrays); |
|
349 } |
|
350 |
|
351 return null; |
|
352 } |
|
353 |
|
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 } |
|
369 |
|
370 /** |
|
371 * Database creation and access |
|
372 */ |
|
373 |
|
374 let _dbConnection = null; |
|
375 XPCOMUtils.defineLazyGetter(this, "dbConnection", function() { |
|
376 let dbFile; |
|
377 |
|
378 try { |
|
379 dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone(); |
|
380 dbFile.append("formhistory.sqlite"); |
|
381 log("Opening database at " + dbFile.path); |
|
382 |
|
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 } |
|
390 |
|
391 return _dbConnection; |
|
392 }); |
|
393 |
|
394 |
|
395 let dbStmts = new Map(); |
|
396 |
|
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; |
|
405 |
|
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 } |
|
412 |
|
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 } |
|
420 |
|
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 } |
|
435 |
|
436 return stmt; |
|
437 } |
|
438 |
|
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"); |
|
447 |
|
448 if (!_dbConnection.tableExists("moz_formhistory")) { |
|
449 dbCreate(); |
|
450 return; |
|
451 } |
|
452 |
|
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 } |
|
463 |
|
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 } |
|
472 |
|
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 } |
|
480 |
|
481 _dbConnection.schemaVersion = DB_SCHEMA_VERSION; |
|
482 } |
|
483 |
|
484 function dbMigrate(oldVersion) { |
|
485 log("Attempting to migrate from version " + oldVersion); |
|
486 |
|
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.] |
|
494 |
|
495 if (!dbAreExpectedColumnsPresent()) { |
|
496 throw Components.Exception("DB is missing expected columns", |
|
497 Cr.NS_ERROR_FILE_CORRUPTED); |
|
498 } |
|
499 |
|
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 } |
|
506 |
|
507 // Note that migration is currently performed synchronously. |
|
508 _dbConnection.beginTransaction(); |
|
509 |
|
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 } |
|
520 |
|
521 _dbConnection.schemaVersion = DB_SCHEMA_VERSION; |
|
522 _dbConnection.commitTransaction(); |
|
523 |
|
524 log("DB migration completed."); |
|
525 } |
|
526 |
|
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 }; |
|
540 |
|
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 } |
|
561 |
|
562 log("verified that expected columns are present in DB."); |
|
563 return true; |
|
564 } |
|
565 |
|
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"); |
|
574 |
|
575 // Create backup file |
|
576 let backupFile = dbFile.leafName + ".corrupt"; |
|
577 Services.storage.backupDatabaseFile(dbFile, backupFile); |
|
578 |
|
579 dbClose(false); |
|
580 dbFile.remove(false); |
|
581 } |
|
582 |
|
583 function dbClose(aShutdown) { |
|
584 log("dbClose(" + aShutdown + ")"); |
|
585 |
|
586 if (aShutdown) { |
|
587 sendNotification("formhistory-shutdown", null); |
|
588 } |
|
589 |
|
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 } |
|
595 |
|
596 log("dbClose finalize statements"); |
|
597 for (let stmt of dbStmts.values()) { |
|
598 stmt.finalize(); |
|
599 } |
|
600 |
|
601 dbStmts = new Map(); |
|
602 |
|
603 let closed = false; |
|
604 _dbConnection.asyncClose(function () closed = true); |
|
605 |
|
606 if (!aShutdown) { |
|
607 let thread = Services.tm.currentThread; |
|
608 while (!closed) { |
|
609 thread.processNextEvent(true); |
|
610 } |
|
611 } |
|
612 } |
|
613 |
|
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); |
|
622 |
|
623 // pass 'now' down so that every entry in the batch has the same timestamp |
|
624 let now = Date.now() * 1000; |
|
625 |
|
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(); |
|
633 |
|
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 } |
|
684 |
|
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 } |
|
690 |
|
691 for (let stmt of stmts) { |
|
692 stmt.bindParameters(bindingArrays.get(stmt)); |
|
693 } |
|
694 |
|
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 } |
|
703 |
|
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 }; |
|
715 |
|
716 dbConnection.executeAsync(stmts, stmts.length, handlers); |
|
717 } |
|
718 |
|
719 /** |
|
720 * Functions that expire entries in form history and shrinks database |
|
721 * afterwards as necessary initiated by expireOldEntries. |
|
722 */ |
|
723 |
|
724 /** |
|
725 * expireOldEntriesDeletion |
|
726 * |
|
727 * Removes entries from database. |
|
728 */ |
|
729 function expireOldEntriesDeletion(aExpireTime, aBeginningCount) { |
|
730 log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")"); |
|
731 |
|
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 } |
|
745 |
|
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"); |
|
756 |
|
757 let stmt = dbCreateAsyncStatement("VACUUM"); |
|
758 stmt.executeAsync({ |
|
759 handleResult : NOOP, |
|
760 handleError : function(aError) { |
|
761 log("expireVacuumError"); |
|
762 }, |
|
763 handleCompletion : NOOP |
|
764 }); |
|
765 } |
|
766 |
|
767 sendNotification("formhistory-expireoldentries", aExpireTime); |
|
768 }, |
|
769 handleError: function(aError) { |
|
770 log("expireEndCountFailure"); |
|
771 } |
|
772 }); |
|
773 } |
|
774 |
|
775 this.FormHistory = { |
|
776 get enabled() Prefs.enabled, |
|
777 |
|
778 search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) { |
|
779 // if no terms selected, select everything |
|
780 aSelectTerms = (aSelectTerms) ? aSelectTerms : validFields; |
|
781 validateSearchData(aSearchData, "Search"); |
|
782 |
|
783 let stmt = makeSearchStatement(aSearchData, aSelectTerms); |
|
784 |
|
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 } |
|
793 |
|
794 if (aCallbacks && aCallbacks.handleResult) { |
|
795 aCallbacks.handleResult(result); |
|
796 } |
|
797 } |
|
798 }, |
|
799 |
|
800 handleError : function(aError) { |
|
801 if (aCallbacks && aCallbacks.handleError) { |
|
802 aCallbacks.handleError(aError); |
|
803 } |
|
804 }, |
|
805 |
|
806 handleCompletion : function searchCompletionHandler(aReason) { |
|
807 if (aCallbacks && aCallbacks.handleCompletion) { |
|
808 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); |
|
809 } |
|
810 } |
|
811 }; |
|
812 |
|
813 stmt.executeAsync(handlers); |
|
814 }, |
|
815 |
|
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 }, |
|
827 |
|
828 handleError : function(aError) { |
|
829 if (aCallbacks && aCallbacks.handleError) { |
|
830 aCallbacks.handleError(aError); |
|
831 } |
|
832 }, |
|
833 |
|
834 handleCompletion : function searchCompletionHandler(aReason) { |
|
835 if (aCallbacks && aCallbacks.handleCompletion) { |
|
836 aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1); |
|
837 } |
|
838 } |
|
839 }; |
|
840 |
|
841 stmt.executeAsync(handlers); |
|
842 }, |
|
843 |
|
844 update : function formHistoryUpdate(aChanges, aCallbacks) { |
|
845 if (!Prefs.enabled) { |
|
846 return; |
|
847 } |
|
848 |
|
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; |
|
854 |
|
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 } |
|
859 |
|
860 if (!("length" in aChanges)) |
|
861 aChanges = [aChanges]; |
|
862 |
|
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 } |
|
907 |
|
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 } |
|
927 |
|
928 searchFailed = true; |
|
929 return; |
|
930 } |
|
931 |
|
932 this.foundResult = true; |
|
933 changeToUpdate.guid = aResult["guid"]; |
|
934 }, |
|
935 |
|
936 handleError : function(aError) { |
|
937 if (aCallbacks && aCallbacks.handleError) { |
|
938 aCallbacks.handleError(aError); |
|
939 } |
|
940 }, |
|
941 |
|
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 } |
|
955 |
|
956 if (numSearches == 0) { |
|
957 // We don't have to wait for any statements to return. |
|
958 updateFormHistoryWrite(aChanges, aCallbacks); |
|
959 } |
|
960 }, |
|
961 |
|
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+/); |
|
969 |
|
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 } |
|
995 |
|
996 params.now = Date.now() * 1000; // convert from ms to microseconds |
|
997 |
|
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 */ |
|
1007 |
|
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"; |
|
1020 |
|
1021 let stmt = dbCreateAsyncStatement(query, params); |
|
1022 |
|
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 } |
|
1039 |
|
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 }, |
|
1056 |
|
1057 handleError : function (aError) { |
|
1058 if (aCallbacks && aCallbacks.handleError) { |
|
1059 aCallbacks.handleError(aError); |
|
1060 } |
|
1061 }, |
|
1062 |
|
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 }, |
|
1071 |
|
1072 get schemaVersion() { |
|
1073 return dbConnection.schemaVersion; |
|
1074 }, |
|
1075 |
|
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 }, |
|
1083 |
|
1084 // The remaining methods are called by FormHistoryStartup.js |
|
1085 updatePrefs: function updatePrefs() { |
|
1086 Prefs.initialized = false; |
|
1087 }, |
|
1088 |
|
1089 expireOldEntries: function expireOldEntries() { |
|
1090 log("expireOldEntries"); |
|
1091 |
|
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; |
|
1095 |
|
1096 sendNotification("formhistory-beforeexpireoldentries", expireTime); |
|
1097 |
|
1098 FormHistory.count({}, { |
|
1099 handleResult: function(aBeginningCount) { |
|
1100 expireOldEntriesDeletion(expireTime, aBeginningCount); |
|
1101 }, |
|
1102 handleError: function(aError) { |
|
1103 log("expireStartCountFailure"); |
|
1104 } |
|
1105 }); |
|
1106 }, |
|
1107 |
|
1108 shutdown: function shutdown() { dbClose(true); } |
|
1109 }; |
|
1110 |
|
1111 // Prevent add-ons from redefining this API |
|
1112 Object.freeze(FormHistory); |