michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: this.EXPORTED_SYMBOLS = ['HistoryEngine', 'HistoryRec']; michael@0: michael@0: const Cc = Components.classes; michael@0: const Ci = Components.interfaces; michael@0: const Cu = Components.utils; michael@0: const Cr = Components.results; michael@0: michael@0: const HISTORY_TTL = 5184000; // 60 days michael@0: michael@0: Cu.import("resource://gre/modules/PlacesUtils.jsm", this); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://services-common/async.js"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-sync/constants.js"); michael@0: Cu.import("resource://services-sync/engines.js"); michael@0: Cu.import("resource://services-sync/record.js"); michael@0: Cu.import("resource://services-sync/util.js"); michael@0: michael@0: this.HistoryRec = function HistoryRec(collection, id) { michael@0: CryptoWrapper.call(this, collection, id); michael@0: } michael@0: HistoryRec.prototype = { michael@0: __proto__: CryptoWrapper.prototype, michael@0: _logName: "Sync.Record.History", michael@0: ttl: HISTORY_TTL michael@0: }; michael@0: michael@0: Utils.deferGetSet(HistoryRec, "cleartext", ["histUri", "title", "visits"]); michael@0: michael@0: michael@0: this.HistoryEngine = function HistoryEngine(service) { michael@0: SyncEngine.call(this, "History", service); michael@0: } michael@0: HistoryEngine.prototype = { michael@0: __proto__: SyncEngine.prototype, michael@0: _recordObj: HistoryRec, michael@0: _storeObj: HistoryStore, michael@0: _trackerObj: HistoryTracker, michael@0: downloadLimit: MAX_HISTORY_DOWNLOAD, michael@0: applyIncomingBatchSize: HISTORY_STORE_BATCH_SIZE michael@0: }; michael@0: michael@0: function HistoryStore(name, engine) { michael@0: Store.call(this, name, engine); michael@0: michael@0: // Explicitly nullify our references to our cached services so we don't leak michael@0: Svc.Obs.add("places-shutdown", function() { michael@0: for each ([query, stmt] in Iterator(this._stmts)) { michael@0: stmt.finalize(); michael@0: } michael@0: this._stmts = {}; michael@0: }, this); michael@0: } michael@0: HistoryStore.prototype = { michael@0: __proto__: Store.prototype, michael@0: michael@0: __asyncHistory: null, michael@0: get _asyncHistory() { michael@0: if (!this.__asyncHistory) { michael@0: this.__asyncHistory = Cc["@mozilla.org/browser/history;1"] michael@0: .getService(Ci.mozIAsyncHistory); michael@0: } michael@0: return this.__asyncHistory; michael@0: }, michael@0: michael@0: _stmts: {}, michael@0: _getStmt: function(query) { michael@0: if (query in this._stmts) { michael@0: return this._stmts[query]; michael@0: } michael@0: michael@0: this._log.trace("Creating SQL statement: " + query); michael@0: let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) michael@0: .DBConnection; michael@0: return this._stmts[query] = db.createAsyncStatement(query); michael@0: }, michael@0: michael@0: get _setGUIDStm() { michael@0: return this._getStmt( michael@0: "UPDATE moz_places " + michael@0: "SET guid = :guid " + michael@0: "WHERE url = :page_url"); michael@0: }, michael@0: michael@0: // Some helper functions to handle GUIDs michael@0: setGUID: function setGUID(uri, guid) { michael@0: uri = uri.spec ? uri.spec : uri; michael@0: michael@0: if (!guid) { michael@0: guid = Utils.makeGUID(); michael@0: } michael@0: michael@0: let stmt = this._setGUIDStm; michael@0: stmt.params.guid = guid; michael@0: stmt.params.page_url = uri; michael@0: Async.querySpinningly(stmt); michael@0: return guid; michael@0: }, michael@0: michael@0: get _guidStm() { michael@0: return this._getStmt( michael@0: "SELECT guid " + michael@0: "FROM moz_places " + michael@0: "WHERE url = :page_url"); michael@0: }, michael@0: _guidCols: ["guid"], michael@0: michael@0: GUIDForUri: function GUIDForUri(uri, create) { michael@0: let stm = this._guidStm; michael@0: stm.params.page_url = uri.spec ? uri.spec : uri; michael@0: michael@0: // Use the existing GUID if it exists michael@0: let result = Async.querySpinningly(stm, this._guidCols)[0]; michael@0: if (result && result.guid) michael@0: return result.guid; michael@0: michael@0: // Give the uri a GUID if it doesn't have one michael@0: if (create) michael@0: return this.setGUID(uri); michael@0: }, michael@0: michael@0: get _visitStm() { michael@0: return this._getStmt( michael@0: "/* do not warn (bug 599936) */ " + michael@0: "SELECT visit_type type, visit_date date " + michael@0: "FROM moz_historyvisits " + michael@0: "WHERE place_id = (SELECT id FROM moz_places WHERE url = :url) " + michael@0: "ORDER BY date DESC LIMIT 10"); michael@0: }, michael@0: _visitCols: ["date", "type"], michael@0: michael@0: get _urlStm() { michael@0: return this._getStmt( michael@0: "SELECT url, title, frecency " + michael@0: "FROM moz_places " + michael@0: "WHERE guid = :guid"); michael@0: }, michael@0: _urlCols: ["url", "title", "frecency"], michael@0: michael@0: get _allUrlStm() { michael@0: return this._getStmt( michael@0: "SELECT url " + michael@0: "FROM moz_places " + michael@0: "WHERE last_visit_date > :cutoff_date " + michael@0: "ORDER BY frecency DESC " + michael@0: "LIMIT :max_results"); michael@0: }, michael@0: _allUrlCols: ["url"], michael@0: michael@0: // See bug 320831 for why we use SQL here michael@0: _getVisits: function HistStore__getVisits(uri) { michael@0: this._visitStm.params.url = uri; michael@0: return Async.querySpinningly(this._visitStm, this._visitCols); michael@0: }, michael@0: michael@0: // See bug 468732 for why we use SQL here michael@0: _findURLByGUID: function HistStore__findURLByGUID(guid) { michael@0: this._urlStm.params.guid = guid; michael@0: return Async.querySpinningly(this._urlStm, this._urlCols)[0]; michael@0: }, michael@0: michael@0: changeItemID: function HStore_changeItemID(oldID, newID) { michael@0: this.setGUID(this._findURLByGUID(oldID).url, newID); michael@0: }, michael@0: michael@0: michael@0: getAllIDs: function HistStore_getAllIDs() { michael@0: // Only get places visited within the last 30 days (30*24*60*60*1000ms) michael@0: this._allUrlStm.params.cutoff_date = (Date.now() - 2592000000) * 1000; michael@0: this._allUrlStm.params.max_results = MAX_HISTORY_UPLOAD; michael@0: michael@0: let urls = Async.querySpinningly(this._allUrlStm, this._allUrlCols); michael@0: let self = this; michael@0: return urls.reduce(function(ids, item) { michael@0: ids[self.GUIDForUri(item.url, true)] = item.url; michael@0: return ids; michael@0: }, {}); michael@0: }, michael@0: michael@0: applyIncomingBatch: function applyIncomingBatch(records) { michael@0: let failed = []; michael@0: michael@0: // Convert incoming records to mozIPlaceInfo objects. Some records can be michael@0: // ignored or handled directly, so we're rewriting the array in-place. michael@0: let i, k; michael@0: for (i = 0, k = 0; i < records.length; i++) { michael@0: let record = records[k] = records[i]; michael@0: let shouldApply; michael@0: michael@0: // This is still synchronous I/O for now. michael@0: try { michael@0: if (record.deleted) { michael@0: // Consider using nsIBrowserHistory::removePages() here. michael@0: this.remove(record); michael@0: // No further processing needed. Remove it from the list. michael@0: shouldApply = false; michael@0: } else { michael@0: shouldApply = this._recordToPlaceInfo(record); michael@0: } michael@0: } catch(ex) { michael@0: failed.push(record.id); michael@0: shouldApply = false; michael@0: } michael@0: michael@0: if (shouldApply) { michael@0: k += 1; michael@0: } michael@0: } michael@0: records.length = k; // truncate array michael@0: michael@0: // Nothing to do. michael@0: if (!records.length) { michael@0: return failed; michael@0: } michael@0: michael@0: let updatePlacesCallback = { michael@0: handleResult: function handleResult() {}, michael@0: handleError: function handleError(resultCode, placeInfo) { michael@0: failed.push(placeInfo.guid); michael@0: }, michael@0: handleCompletion: Async.makeSyncCallback() michael@0: }; michael@0: this._asyncHistory.updatePlaces(records, updatePlacesCallback); michael@0: Async.waitForSyncCallback(updatePlacesCallback.handleCompletion); michael@0: return failed; michael@0: }, michael@0: michael@0: /** michael@0: * Converts a Sync history record to a mozIPlaceInfo. michael@0: * michael@0: * Throws if an invalid record is encountered (invalid URI, etc.), michael@0: * returns true if the record is to be applied, false otherwise michael@0: * (no visits to add, etc.), michael@0: */ michael@0: _recordToPlaceInfo: function _recordToPlaceInfo(record) { michael@0: // Sort out invalid URIs and ones Places just simply doesn't want. michael@0: record.uri = Utils.makeURI(record.histUri); michael@0: if (!record.uri) { michael@0: this._log.warn("Attempted to process invalid URI, skipping."); michael@0: throw "Invalid URI in record"; michael@0: } michael@0: michael@0: if (!Utils.checkGUID(record.id)) { michael@0: this._log.warn("Encountered record with invalid GUID: " + record.id); michael@0: return false; michael@0: } michael@0: record.guid = record.id; michael@0: michael@0: if (!PlacesUtils.history.canAddURI(record.uri)) { michael@0: this._log.trace("Ignoring record " + record.id + " with URI " michael@0: + record.uri.spec + ": can't add this URI."); michael@0: return false; michael@0: } michael@0: michael@0: // We dupe visits by date and type. So an incoming visit that has michael@0: // the same timestamp and type as a local one won't get applied. michael@0: // To avoid creating new objects, we rewrite the query result so we michael@0: // can simply check for containment below. michael@0: let curVisits = this._getVisits(record.histUri); michael@0: let i, k; michael@0: for (i = 0; i < curVisits.length; i++) { michael@0: curVisits[i] = curVisits[i].date + "," + curVisits[i].type; michael@0: } michael@0: michael@0: // Walk through the visits, make sure we have sound data, and eliminate michael@0: // dupes. The latter is done by rewriting the array in-place. michael@0: for (i = 0, k = 0; i < record.visits.length; i++) { michael@0: let visit = record.visits[k] = record.visits[i]; michael@0: michael@0: if (!visit.date || typeof visit.date != "number") { michael@0: this._log.warn("Encountered record with invalid visit date: " michael@0: + visit.date); michael@0: throw "Visit has no date!"; michael@0: } michael@0: michael@0: if (!visit.type || !(visit.type >= PlacesUtils.history.TRANSITION_LINK && michael@0: visit.type <= PlacesUtils.history.TRANSITION_FRAMED_LINK)) { michael@0: this._log.warn("Encountered record with invalid visit type: " michael@0: + visit.type); michael@0: throw "Invalid visit type!"; michael@0: } michael@0: michael@0: // Dates need to be integers. michael@0: visit.date = Math.round(visit.date); michael@0: michael@0: if (curVisits.indexOf(visit.date + "," + visit.type) != -1) { michael@0: // Visit is a dupe, don't increment 'k' so the element will be michael@0: // overwritten. michael@0: continue; michael@0: } michael@0: visit.visitDate = visit.date; michael@0: visit.transitionType = visit.type; michael@0: k += 1; michael@0: } michael@0: record.visits.length = k; // truncate array michael@0: michael@0: // No update if there aren't any visits to apply. michael@0: // mozIAsyncHistory::updatePlaces() wants at least one visit. michael@0: // In any case, the only thing we could change would be the title michael@0: // and that shouldn't change without a visit. michael@0: if (!record.visits.length) { michael@0: this._log.trace("Ignoring record " + record.id + " with URI " michael@0: + record.uri.spec + ": no visits to add."); michael@0: return false; michael@0: } michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: remove: function HistStore_remove(record) { michael@0: let page = this._findURLByGUID(record.id); michael@0: if (page == null) { michael@0: this._log.debug("Page already removed: " + record.id); michael@0: return; michael@0: } michael@0: michael@0: let uri = Utils.makeURI(page.url); michael@0: PlacesUtils.history.removePage(uri); michael@0: this._log.trace("Removed page: " + [record.id, page.url, page.title]); michael@0: }, michael@0: michael@0: itemExists: function HistStore_itemExists(id) { michael@0: return !!this._findURLByGUID(id); michael@0: }, michael@0: michael@0: createRecord: function createRecord(id, collection) { michael@0: let foo = this._findURLByGUID(id); michael@0: let record = new HistoryRec(collection, id); michael@0: if (foo) { michael@0: record.histUri = foo.url; michael@0: record.title = foo.title; michael@0: record.sortindex = foo.frecency; michael@0: record.visits = this._getVisits(record.histUri); michael@0: } else { michael@0: record.deleted = true; michael@0: } michael@0: michael@0: return record; michael@0: }, michael@0: michael@0: wipe: function HistStore_wipe() { michael@0: PlacesUtils.history.removeAllPages(); michael@0: } michael@0: }; michael@0: michael@0: function HistoryTracker(name, engine) { michael@0: Tracker.call(this, name, engine); michael@0: } michael@0: HistoryTracker.prototype = { michael@0: __proto__: Tracker.prototype, michael@0: michael@0: startTracking: function() { michael@0: this._log.info("Adding Places observer."); michael@0: PlacesUtils.history.addObserver(this, true); michael@0: }, michael@0: michael@0: stopTracking: function() { michael@0: this._log.info("Removing Places observer."); michael@0: PlacesUtils.history.removeObserver(this); michael@0: }, michael@0: michael@0: QueryInterface: XPCOMUtils.generateQI([ michael@0: Ci.nsINavHistoryObserver, michael@0: Ci.nsISupportsWeakReference michael@0: ]), michael@0: michael@0: onDeleteAffectsGUID: function (uri, guid, reason, source, increment) { michael@0: if (this.ignoreAll || reason == Ci.nsINavHistoryObserver.REASON_EXPIRED) { michael@0: return; michael@0: } michael@0: this._log.trace(source + ": " + uri.spec + ", reason " + reason); michael@0: if (this.addChangedID(guid)) { michael@0: this.score += increment; michael@0: } michael@0: }, michael@0: michael@0: onDeleteVisits: function (uri, visitTime, guid, reason) { michael@0: this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteVisits", SCORE_INCREMENT_SMALL); michael@0: }, michael@0: michael@0: onDeleteURI: function (uri, guid, reason) { michael@0: this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteURI", SCORE_INCREMENT_XLARGE); michael@0: }, michael@0: michael@0: onVisit: function (uri, vid, time, session, referrer, trans, guid) { michael@0: if (this.ignoreAll) { michael@0: this._log.trace("ignoreAll: ignoring visit for " + guid); michael@0: return; michael@0: } michael@0: michael@0: this._log.trace("onVisit: " + uri.spec); michael@0: if (this.addChangedID(guid)) { michael@0: this.score += SCORE_INCREMENT_SMALL; michael@0: } michael@0: }, michael@0: michael@0: onClearHistory: function () { michael@0: this._log.trace("onClearHistory"); michael@0: // Note that we're going to trigger a sync, but none of the cleared michael@0: // pages are tracked, so the deletions will not be propagated. michael@0: // See Bug 578694. michael@0: this.score += SCORE_INCREMENT_XLARGE; michael@0: }, michael@0: michael@0: onBeginUpdateBatch: function () {}, michael@0: onEndUpdateBatch: function () {}, michael@0: onPageChanged: function () {}, michael@0: onTitleChanged: function () {}, michael@0: onBeforeDeleteURI: function () {}, michael@0: };