Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 4 | |
michael@0 | 5 | this.EXPORTED_SYMBOLS = ['HistoryEngine', 'HistoryRec']; |
michael@0 | 6 | |
michael@0 | 7 | const Cc = Components.classes; |
michael@0 | 8 | const Ci = Components.interfaces; |
michael@0 | 9 | const Cu = Components.utils; |
michael@0 | 10 | const Cr = Components.results; |
michael@0 | 11 | |
michael@0 | 12 | const HISTORY_TTL = 5184000; // 60 days |
michael@0 | 13 | |
michael@0 | 14 | Cu.import("resource://gre/modules/PlacesUtils.jsm", this); |
michael@0 | 15 | Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
michael@0 | 16 | Cu.import("resource://services-common/async.js"); |
michael@0 | 17 | Cu.import("resource://gre/modules/Log.jsm"); |
michael@0 | 18 | Cu.import("resource://services-sync/constants.js"); |
michael@0 | 19 | Cu.import("resource://services-sync/engines.js"); |
michael@0 | 20 | Cu.import("resource://services-sync/record.js"); |
michael@0 | 21 | Cu.import("resource://services-sync/util.js"); |
michael@0 | 22 | |
michael@0 | 23 | this.HistoryRec = function HistoryRec(collection, id) { |
michael@0 | 24 | CryptoWrapper.call(this, collection, id); |
michael@0 | 25 | } |
michael@0 | 26 | HistoryRec.prototype = { |
michael@0 | 27 | __proto__: CryptoWrapper.prototype, |
michael@0 | 28 | _logName: "Sync.Record.History", |
michael@0 | 29 | ttl: HISTORY_TTL |
michael@0 | 30 | }; |
michael@0 | 31 | |
michael@0 | 32 | Utils.deferGetSet(HistoryRec, "cleartext", ["histUri", "title", "visits"]); |
michael@0 | 33 | |
michael@0 | 34 | |
michael@0 | 35 | this.HistoryEngine = function HistoryEngine(service) { |
michael@0 | 36 | SyncEngine.call(this, "History", service); |
michael@0 | 37 | } |
michael@0 | 38 | HistoryEngine.prototype = { |
michael@0 | 39 | __proto__: SyncEngine.prototype, |
michael@0 | 40 | _recordObj: HistoryRec, |
michael@0 | 41 | _storeObj: HistoryStore, |
michael@0 | 42 | _trackerObj: HistoryTracker, |
michael@0 | 43 | downloadLimit: MAX_HISTORY_DOWNLOAD, |
michael@0 | 44 | applyIncomingBatchSize: HISTORY_STORE_BATCH_SIZE |
michael@0 | 45 | }; |
michael@0 | 46 | |
michael@0 | 47 | function HistoryStore(name, engine) { |
michael@0 | 48 | Store.call(this, name, engine); |
michael@0 | 49 | |
michael@0 | 50 | // Explicitly nullify our references to our cached services so we don't leak |
michael@0 | 51 | Svc.Obs.add("places-shutdown", function() { |
michael@0 | 52 | for each ([query, stmt] in Iterator(this._stmts)) { |
michael@0 | 53 | stmt.finalize(); |
michael@0 | 54 | } |
michael@0 | 55 | this._stmts = {}; |
michael@0 | 56 | }, this); |
michael@0 | 57 | } |
michael@0 | 58 | HistoryStore.prototype = { |
michael@0 | 59 | __proto__: Store.prototype, |
michael@0 | 60 | |
michael@0 | 61 | __asyncHistory: null, |
michael@0 | 62 | get _asyncHistory() { |
michael@0 | 63 | if (!this.__asyncHistory) { |
michael@0 | 64 | this.__asyncHistory = Cc["@mozilla.org/browser/history;1"] |
michael@0 | 65 | .getService(Ci.mozIAsyncHistory); |
michael@0 | 66 | } |
michael@0 | 67 | return this.__asyncHistory; |
michael@0 | 68 | }, |
michael@0 | 69 | |
michael@0 | 70 | _stmts: {}, |
michael@0 | 71 | _getStmt: function(query) { |
michael@0 | 72 | if (query in this._stmts) { |
michael@0 | 73 | return this._stmts[query]; |
michael@0 | 74 | } |
michael@0 | 75 | |
michael@0 | 76 | this._log.trace("Creating SQL statement: " + query); |
michael@0 | 77 | let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) |
michael@0 | 78 | .DBConnection; |
michael@0 | 79 | return this._stmts[query] = db.createAsyncStatement(query); |
michael@0 | 80 | }, |
michael@0 | 81 | |
michael@0 | 82 | get _setGUIDStm() { |
michael@0 | 83 | return this._getStmt( |
michael@0 | 84 | "UPDATE moz_places " + |
michael@0 | 85 | "SET guid = :guid " + |
michael@0 | 86 | "WHERE url = :page_url"); |
michael@0 | 87 | }, |
michael@0 | 88 | |
michael@0 | 89 | // Some helper functions to handle GUIDs |
michael@0 | 90 | setGUID: function setGUID(uri, guid) { |
michael@0 | 91 | uri = uri.spec ? uri.spec : uri; |
michael@0 | 92 | |
michael@0 | 93 | if (!guid) { |
michael@0 | 94 | guid = Utils.makeGUID(); |
michael@0 | 95 | } |
michael@0 | 96 | |
michael@0 | 97 | let stmt = this._setGUIDStm; |
michael@0 | 98 | stmt.params.guid = guid; |
michael@0 | 99 | stmt.params.page_url = uri; |
michael@0 | 100 | Async.querySpinningly(stmt); |
michael@0 | 101 | return guid; |
michael@0 | 102 | }, |
michael@0 | 103 | |
michael@0 | 104 | get _guidStm() { |
michael@0 | 105 | return this._getStmt( |
michael@0 | 106 | "SELECT guid " + |
michael@0 | 107 | "FROM moz_places " + |
michael@0 | 108 | "WHERE url = :page_url"); |
michael@0 | 109 | }, |
michael@0 | 110 | _guidCols: ["guid"], |
michael@0 | 111 | |
michael@0 | 112 | GUIDForUri: function GUIDForUri(uri, create) { |
michael@0 | 113 | let stm = this._guidStm; |
michael@0 | 114 | stm.params.page_url = uri.spec ? uri.spec : uri; |
michael@0 | 115 | |
michael@0 | 116 | // Use the existing GUID if it exists |
michael@0 | 117 | let result = Async.querySpinningly(stm, this._guidCols)[0]; |
michael@0 | 118 | if (result && result.guid) |
michael@0 | 119 | return result.guid; |
michael@0 | 120 | |
michael@0 | 121 | // Give the uri a GUID if it doesn't have one |
michael@0 | 122 | if (create) |
michael@0 | 123 | return this.setGUID(uri); |
michael@0 | 124 | }, |
michael@0 | 125 | |
michael@0 | 126 | get _visitStm() { |
michael@0 | 127 | return this._getStmt( |
michael@0 | 128 | "/* do not warn (bug 599936) */ " + |
michael@0 | 129 | "SELECT visit_type type, visit_date date " + |
michael@0 | 130 | "FROM moz_historyvisits " + |
michael@0 | 131 | "WHERE place_id = (SELECT id FROM moz_places WHERE url = :url) " + |
michael@0 | 132 | "ORDER BY date DESC LIMIT 10"); |
michael@0 | 133 | }, |
michael@0 | 134 | _visitCols: ["date", "type"], |
michael@0 | 135 | |
michael@0 | 136 | get _urlStm() { |
michael@0 | 137 | return this._getStmt( |
michael@0 | 138 | "SELECT url, title, frecency " + |
michael@0 | 139 | "FROM moz_places " + |
michael@0 | 140 | "WHERE guid = :guid"); |
michael@0 | 141 | }, |
michael@0 | 142 | _urlCols: ["url", "title", "frecency"], |
michael@0 | 143 | |
michael@0 | 144 | get _allUrlStm() { |
michael@0 | 145 | return this._getStmt( |
michael@0 | 146 | "SELECT url " + |
michael@0 | 147 | "FROM moz_places " + |
michael@0 | 148 | "WHERE last_visit_date > :cutoff_date " + |
michael@0 | 149 | "ORDER BY frecency DESC " + |
michael@0 | 150 | "LIMIT :max_results"); |
michael@0 | 151 | }, |
michael@0 | 152 | _allUrlCols: ["url"], |
michael@0 | 153 | |
michael@0 | 154 | // See bug 320831 for why we use SQL here |
michael@0 | 155 | _getVisits: function HistStore__getVisits(uri) { |
michael@0 | 156 | this._visitStm.params.url = uri; |
michael@0 | 157 | return Async.querySpinningly(this._visitStm, this._visitCols); |
michael@0 | 158 | }, |
michael@0 | 159 | |
michael@0 | 160 | // See bug 468732 for why we use SQL here |
michael@0 | 161 | _findURLByGUID: function HistStore__findURLByGUID(guid) { |
michael@0 | 162 | this._urlStm.params.guid = guid; |
michael@0 | 163 | return Async.querySpinningly(this._urlStm, this._urlCols)[0]; |
michael@0 | 164 | }, |
michael@0 | 165 | |
michael@0 | 166 | changeItemID: function HStore_changeItemID(oldID, newID) { |
michael@0 | 167 | this.setGUID(this._findURLByGUID(oldID).url, newID); |
michael@0 | 168 | }, |
michael@0 | 169 | |
michael@0 | 170 | |
michael@0 | 171 | getAllIDs: function HistStore_getAllIDs() { |
michael@0 | 172 | // Only get places visited within the last 30 days (30*24*60*60*1000ms) |
michael@0 | 173 | this._allUrlStm.params.cutoff_date = (Date.now() - 2592000000) * 1000; |
michael@0 | 174 | this._allUrlStm.params.max_results = MAX_HISTORY_UPLOAD; |
michael@0 | 175 | |
michael@0 | 176 | let urls = Async.querySpinningly(this._allUrlStm, this._allUrlCols); |
michael@0 | 177 | let self = this; |
michael@0 | 178 | return urls.reduce(function(ids, item) { |
michael@0 | 179 | ids[self.GUIDForUri(item.url, true)] = item.url; |
michael@0 | 180 | return ids; |
michael@0 | 181 | }, {}); |
michael@0 | 182 | }, |
michael@0 | 183 | |
michael@0 | 184 | applyIncomingBatch: function applyIncomingBatch(records) { |
michael@0 | 185 | let failed = []; |
michael@0 | 186 | |
michael@0 | 187 | // Convert incoming records to mozIPlaceInfo objects. Some records can be |
michael@0 | 188 | // ignored or handled directly, so we're rewriting the array in-place. |
michael@0 | 189 | let i, k; |
michael@0 | 190 | for (i = 0, k = 0; i < records.length; i++) { |
michael@0 | 191 | let record = records[k] = records[i]; |
michael@0 | 192 | let shouldApply; |
michael@0 | 193 | |
michael@0 | 194 | // This is still synchronous I/O for now. |
michael@0 | 195 | try { |
michael@0 | 196 | if (record.deleted) { |
michael@0 | 197 | // Consider using nsIBrowserHistory::removePages() here. |
michael@0 | 198 | this.remove(record); |
michael@0 | 199 | // No further processing needed. Remove it from the list. |
michael@0 | 200 | shouldApply = false; |
michael@0 | 201 | } else { |
michael@0 | 202 | shouldApply = this._recordToPlaceInfo(record); |
michael@0 | 203 | } |
michael@0 | 204 | } catch(ex) { |
michael@0 | 205 | failed.push(record.id); |
michael@0 | 206 | shouldApply = false; |
michael@0 | 207 | } |
michael@0 | 208 | |
michael@0 | 209 | if (shouldApply) { |
michael@0 | 210 | k += 1; |
michael@0 | 211 | } |
michael@0 | 212 | } |
michael@0 | 213 | records.length = k; // truncate array |
michael@0 | 214 | |
michael@0 | 215 | // Nothing to do. |
michael@0 | 216 | if (!records.length) { |
michael@0 | 217 | return failed; |
michael@0 | 218 | } |
michael@0 | 219 | |
michael@0 | 220 | let updatePlacesCallback = { |
michael@0 | 221 | handleResult: function handleResult() {}, |
michael@0 | 222 | handleError: function handleError(resultCode, placeInfo) { |
michael@0 | 223 | failed.push(placeInfo.guid); |
michael@0 | 224 | }, |
michael@0 | 225 | handleCompletion: Async.makeSyncCallback() |
michael@0 | 226 | }; |
michael@0 | 227 | this._asyncHistory.updatePlaces(records, updatePlacesCallback); |
michael@0 | 228 | Async.waitForSyncCallback(updatePlacesCallback.handleCompletion); |
michael@0 | 229 | return failed; |
michael@0 | 230 | }, |
michael@0 | 231 | |
michael@0 | 232 | /** |
michael@0 | 233 | * Converts a Sync history record to a mozIPlaceInfo. |
michael@0 | 234 | * |
michael@0 | 235 | * Throws if an invalid record is encountered (invalid URI, etc.), |
michael@0 | 236 | * returns true if the record is to be applied, false otherwise |
michael@0 | 237 | * (no visits to add, etc.), |
michael@0 | 238 | */ |
michael@0 | 239 | _recordToPlaceInfo: function _recordToPlaceInfo(record) { |
michael@0 | 240 | // Sort out invalid URIs and ones Places just simply doesn't want. |
michael@0 | 241 | record.uri = Utils.makeURI(record.histUri); |
michael@0 | 242 | if (!record.uri) { |
michael@0 | 243 | this._log.warn("Attempted to process invalid URI, skipping."); |
michael@0 | 244 | throw "Invalid URI in record"; |
michael@0 | 245 | } |
michael@0 | 246 | |
michael@0 | 247 | if (!Utils.checkGUID(record.id)) { |
michael@0 | 248 | this._log.warn("Encountered record with invalid GUID: " + record.id); |
michael@0 | 249 | return false; |
michael@0 | 250 | } |
michael@0 | 251 | record.guid = record.id; |
michael@0 | 252 | |
michael@0 | 253 | if (!PlacesUtils.history.canAddURI(record.uri)) { |
michael@0 | 254 | this._log.trace("Ignoring record " + record.id + " with URI " |
michael@0 | 255 | + record.uri.spec + ": can't add this URI."); |
michael@0 | 256 | return false; |
michael@0 | 257 | } |
michael@0 | 258 | |
michael@0 | 259 | // We dupe visits by date and type. So an incoming visit that has |
michael@0 | 260 | // the same timestamp and type as a local one won't get applied. |
michael@0 | 261 | // To avoid creating new objects, we rewrite the query result so we |
michael@0 | 262 | // can simply check for containment below. |
michael@0 | 263 | let curVisits = this._getVisits(record.histUri); |
michael@0 | 264 | let i, k; |
michael@0 | 265 | for (i = 0; i < curVisits.length; i++) { |
michael@0 | 266 | curVisits[i] = curVisits[i].date + "," + curVisits[i].type; |
michael@0 | 267 | } |
michael@0 | 268 | |
michael@0 | 269 | // Walk through the visits, make sure we have sound data, and eliminate |
michael@0 | 270 | // dupes. The latter is done by rewriting the array in-place. |
michael@0 | 271 | for (i = 0, k = 0; i < record.visits.length; i++) { |
michael@0 | 272 | let visit = record.visits[k] = record.visits[i]; |
michael@0 | 273 | |
michael@0 | 274 | if (!visit.date || typeof visit.date != "number") { |
michael@0 | 275 | this._log.warn("Encountered record with invalid visit date: " |
michael@0 | 276 | + visit.date); |
michael@0 | 277 | throw "Visit has no date!"; |
michael@0 | 278 | } |
michael@0 | 279 | |
michael@0 | 280 | if (!visit.type || !(visit.type >= PlacesUtils.history.TRANSITION_LINK && |
michael@0 | 281 | visit.type <= PlacesUtils.history.TRANSITION_FRAMED_LINK)) { |
michael@0 | 282 | this._log.warn("Encountered record with invalid visit type: " |
michael@0 | 283 | + visit.type); |
michael@0 | 284 | throw "Invalid visit type!"; |
michael@0 | 285 | } |
michael@0 | 286 | |
michael@0 | 287 | // Dates need to be integers. |
michael@0 | 288 | visit.date = Math.round(visit.date); |
michael@0 | 289 | |
michael@0 | 290 | if (curVisits.indexOf(visit.date + "," + visit.type) != -1) { |
michael@0 | 291 | // Visit is a dupe, don't increment 'k' so the element will be |
michael@0 | 292 | // overwritten. |
michael@0 | 293 | continue; |
michael@0 | 294 | } |
michael@0 | 295 | visit.visitDate = visit.date; |
michael@0 | 296 | visit.transitionType = visit.type; |
michael@0 | 297 | k += 1; |
michael@0 | 298 | } |
michael@0 | 299 | record.visits.length = k; // truncate array |
michael@0 | 300 | |
michael@0 | 301 | // No update if there aren't any visits to apply. |
michael@0 | 302 | // mozIAsyncHistory::updatePlaces() wants at least one visit. |
michael@0 | 303 | // In any case, the only thing we could change would be the title |
michael@0 | 304 | // and that shouldn't change without a visit. |
michael@0 | 305 | if (!record.visits.length) { |
michael@0 | 306 | this._log.trace("Ignoring record " + record.id + " with URI " |
michael@0 | 307 | + record.uri.spec + ": no visits to add."); |
michael@0 | 308 | return false; |
michael@0 | 309 | } |
michael@0 | 310 | |
michael@0 | 311 | return true; |
michael@0 | 312 | }, |
michael@0 | 313 | |
michael@0 | 314 | remove: function HistStore_remove(record) { |
michael@0 | 315 | let page = this._findURLByGUID(record.id); |
michael@0 | 316 | if (page == null) { |
michael@0 | 317 | this._log.debug("Page already removed: " + record.id); |
michael@0 | 318 | return; |
michael@0 | 319 | } |
michael@0 | 320 | |
michael@0 | 321 | let uri = Utils.makeURI(page.url); |
michael@0 | 322 | PlacesUtils.history.removePage(uri); |
michael@0 | 323 | this._log.trace("Removed page: " + [record.id, page.url, page.title]); |
michael@0 | 324 | }, |
michael@0 | 325 | |
michael@0 | 326 | itemExists: function HistStore_itemExists(id) { |
michael@0 | 327 | return !!this._findURLByGUID(id); |
michael@0 | 328 | }, |
michael@0 | 329 | |
michael@0 | 330 | createRecord: function createRecord(id, collection) { |
michael@0 | 331 | let foo = this._findURLByGUID(id); |
michael@0 | 332 | let record = new HistoryRec(collection, id); |
michael@0 | 333 | if (foo) { |
michael@0 | 334 | record.histUri = foo.url; |
michael@0 | 335 | record.title = foo.title; |
michael@0 | 336 | record.sortindex = foo.frecency; |
michael@0 | 337 | record.visits = this._getVisits(record.histUri); |
michael@0 | 338 | } else { |
michael@0 | 339 | record.deleted = true; |
michael@0 | 340 | } |
michael@0 | 341 | |
michael@0 | 342 | return record; |
michael@0 | 343 | }, |
michael@0 | 344 | |
michael@0 | 345 | wipe: function HistStore_wipe() { |
michael@0 | 346 | PlacesUtils.history.removeAllPages(); |
michael@0 | 347 | } |
michael@0 | 348 | }; |
michael@0 | 349 | |
michael@0 | 350 | function HistoryTracker(name, engine) { |
michael@0 | 351 | Tracker.call(this, name, engine); |
michael@0 | 352 | } |
michael@0 | 353 | HistoryTracker.prototype = { |
michael@0 | 354 | __proto__: Tracker.prototype, |
michael@0 | 355 | |
michael@0 | 356 | startTracking: function() { |
michael@0 | 357 | this._log.info("Adding Places observer."); |
michael@0 | 358 | PlacesUtils.history.addObserver(this, true); |
michael@0 | 359 | }, |
michael@0 | 360 | |
michael@0 | 361 | stopTracking: function() { |
michael@0 | 362 | this._log.info("Removing Places observer."); |
michael@0 | 363 | PlacesUtils.history.removeObserver(this); |
michael@0 | 364 | }, |
michael@0 | 365 | |
michael@0 | 366 | QueryInterface: XPCOMUtils.generateQI([ |
michael@0 | 367 | Ci.nsINavHistoryObserver, |
michael@0 | 368 | Ci.nsISupportsWeakReference |
michael@0 | 369 | ]), |
michael@0 | 370 | |
michael@0 | 371 | onDeleteAffectsGUID: function (uri, guid, reason, source, increment) { |
michael@0 | 372 | if (this.ignoreAll || reason == Ci.nsINavHistoryObserver.REASON_EXPIRED) { |
michael@0 | 373 | return; |
michael@0 | 374 | } |
michael@0 | 375 | this._log.trace(source + ": " + uri.spec + ", reason " + reason); |
michael@0 | 376 | if (this.addChangedID(guid)) { |
michael@0 | 377 | this.score += increment; |
michael@0 | 378 | } |
michael@0 | 379 | }, |
michael@0 | 380 | |
michael@0 | 381 | onDeleteVisits: function (uri, visitTime, guid, reason) { |
michael@0 | 382 | this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteVisits", SCORE_INCREMENT_SMALL); |
michael@0 | 383 | }, |
michael@0 | 384 | |
michael@0 | 385 | onDeleteURI: function (uri, guid, reason) { |
michael@0 | 386 | this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteURI", SCORE_INCREMENT_XLARGE); |
michael@0 | 387 | }, |
michael@0 | 388 | |
michael@0 | 389 | onVisit: function (uri, vid, time, session, referrer, trans, guid) { |
michael@0 | 390 | if (this.ignoreAll) { |
michael@0 | 391 | this._log.trace("ignoreAll: ignoring visit for " + guid); |
michael@0 | 392 | return; |
michael@0 | 393 | } |
michael@0 | 394 | |
michael@0 | 395 | this._log.trace("onVisit: " + uri.spec); |
michael@0 | 396 | if (this.addChangedID(guid)) { |
michael@0 | 397 | this.score += SCORE_INCREMENT_SMALL; |
michael@0 | 398 | } |
michael@0 | 399 | }, |
michael@0 | 400 | |
michael@0 | 401 | onClearHistory: function () { |
michael@0 | 402 | this._log.trace("onClearHistory"); |
michael@0 | 403 | // Note that we're going to trigger a sync, but none of the cleared |
michael@0 | 404 | // pages are tracked, so the deletions will not be propagated. |
michael@0 | 405 | // See Bug 578694. |
michael@0 | 406 | this.score += SCORE_INCREMENT_XLARGE; |
michael@0 | 407 | }, |
michael@0 | 408 | |
michael@0 | 409 | onBeginUpdateBatch: function () {}, |
michael@0 | 410 | onEndUpdateBatch: function () {}, |
michael@0 | 411 | onPageChanged: function () {}, |
michael@0 | 412 | onTitleChanged: function () {}, |
michael@0 | 413 | onBeforeDeleteURI: function () {}, |
michael@0 | 414 | }; |