/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; this.EXPORTED_SYMBOLS = [ "Sync", ]; const { classes: Cc, interfaces: Ci, utils: Cu } = Components; Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ReadingList", "resource:///modules/readinglist/ReadingList.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ServerClient", "resource:///modules/readinglist/ServerClient.jsm"); // The Last-Modified header of server responses is stored here. const SERVER_LAST_MODIFIED_HEADER_PREF = "readinglist.sync.serverLastModified"; // Maps local record properties to server record properties. const SERVER_PROPERTIES_BY_LOCAL_PROPERTIES = { guid: "id", serverLastModified: "last_modified", url: "url", preview: "preview", title: "title", resolvedURL: "resolved_url", resolvedTitle: "resolved_title", excerpt: "excerpt", archived: "archived", deleted: "deleted", favorite: "favorite", isArticle: "is_article", wordCount: "word_count", unread: "unread", addedBy: "added_by", addedOn: "added_on", storedOn: "stored_on", markedReadBy: "marked_read_by", markedReadOn: "marked_read_on", readPosition: "read_position", }; // Local record properties that can be uploaded in new items. const NEW_RECORD_PROPERTIES = ` url title resolvedURL resolvedTitle excerpt favorite isArticle wordCount unread addedBy addedOn markedReadBy markedReadOn readPosition preview `.trim().split(/\s+/); // Local record properties that can be uploaded in changed items. const MUTABLE_RECORD_PROPERTIES = ` title resolvedURL resolvedTitle excerpt favorite isArticle wordCount unread markedReadBy markedReadOn readPosition preview `.trim().split(/\s+/); let log = Log.repository.getLogger("readinglist.sync"); /** * An object that syncs reading list state with a server. To sync, make a new * SyncImpl object and then call start() on it. * * @param readingList The ReadingList to sync. */ function SyncImpl(readingList) { this.list = readingList; this._client = new ServerClient(); } /** * This implementation uses the sync algorithm described here: * https://github.com/mozilla-services/readinglist/wiki/Client-phases * The "phases" mentioned in the methods below refer to the phases in that * document. */ SyncImpl.prototype = { /** * Starts sync, if it's not already started. * * @return Promise this.promise, i.e., a promise that will be resolved * when sync completes, rejected on error. */ start() { if (!this.promise) { this.promise = Task.spawn(function* () { yield this._start(); delete this.promise; }.bind(this)); } return this.promise; }, /** * A Promise that will be non-null when sync is ongoing. Resolved when * sync completes, rejected on error. */ promise: null, /** * See the document linked above that describes the sync algorithm. */ _start: Task.async(function* () { log.info("Starting sync"); yield this._uploadStatusChanges(); yield this._uploadNewItems(); yield this._uploadDeletedItems(); yield this._downloadModifiedItems(); // TODO: "Repeat [this phase] until no conflicts occur," says the doc. yield this._uploadMaterialChanges(); log.info("Sync done"); }), /** * Phase 1 part 1 * * Uploads not-new items with status-only changes. By design, status-only * changes will never conflict with what's on the server. */ _uploadStatusChanges: Task.async(function* () { log.debug("Phase 1 part 1: Uploading status changes"); yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_STATUS, ReadingList.SyncStatusProperties.STATUS); }), /** * There are two phases for uploading changed not-new items: one for items * with status-only changes, one for items with material changes. The two * work similarly mechanically, and this method is a helper for both. * * @param syncStatus Local items matching this sync status will be uploaded. * @param localProperties An array of local record property names. The * uploaded item records will include only these properties. */ _uploadChanges: Task.async(function* (syncStatus, localProperties) { // Get local items that match the given syncStatus. let requests = []; yield this.list.forEachItem(localItem => { requests.push({ path: "/articles/" + localItem.guid, body: serverRecordFromLocalItem(localItem, localProperties), }); }, { syncStatus: syncStatus }); if (!requests.length) { log.debug("No local changes to upload"); return; } // Send the request. let request = { method: "POST", path: "/batch", body: { defaults: { method: "PATCH", }, requests: requests, }, headers: {}, }; if (this._serverLastModifiedHeader) { request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader; } let batchResponse = yield this._sendRequest(request); if (batchResponse.status != 200) { this._handleUnexpectedResponse("uploading changes", batchResponse); return; } // Update local items based on the response. for (let response of batchResponse.body.responses) { if (response.status == 404) { // item deleted yield this._deleteItemForGUID(response.body.id); continue; } if (response.status == 412 || response.status == 409) { // 412 Precondition failed: The item was modified since the last sync. // 409 Conflict: A change violated a uniqueness constraint. // In either case, mark the item as having material changes, and // reconcile and upload it in the material-changes phase. // TODO continue; } if (response.status != 200) { this._handleUnexpectedResponse("uploading a change", response); continue; } let item = yield this._itemForGUID(response.body.id); yield this._updateItemWithServerRecord(item, response.body); } }), /** * Phase 1 part 2 * * Uploads new items. */ _uploadNewItems: Task.async(function* () { log.debug("Phase 1 part 2: Uploading new items"); // Get new local items. let requests = []; yield this.list.forEachItem(localItem => { requests.push({ body: serverRecordFromLocalItem(localItem, NEW_RECORD_PROPERTIES), }); }, { syncStatus: ReadingList.SyncStatus.NEW }); if (!requests.length) { log.debug("No new local items to upload"); return; } // Send the request. let request = { method: "POST", path: "/batch", body: { defaults: { method: "POST", path: "/articles", }, requests: requests, }, headers: {}, }; if (this._serverLastModifiedHeader) { request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader; } let batchResponse = yield this._sendRequest(request); if (batchResponse.status != 200) { this._handleUnexpectedResponse("uploading new items", batchResponse); return; } // Update local items based on the response. for (let response of batchResponse.body.responses) { if (response.status == 303) { // "See Other": An item with the URL already exists. Mark the item as // having material changes, and reconcile and upload it in the // material-changes phase. // TODO continue; } // Note that the server seems to return a 200 if an identical item already // exists, but we shouldn't be uploading identical items in this phase in // normal usage, so treat 200 as an unexpected response. if (response.status != 201) { this._handleUnexpectedResponse("uploading a new item", response); continue; } let item = yield this.list.itemForURL(response.body.url); yield this._updateItemWithServerRecord(item, response.body); } }), /** * Phase 1 part 3 * * Uploads deleted synced items. */ _uploadDeletedItems: Task.async(function* () { log.debug("Phase 1 part 3: Uploading deleted items"); // Get deleted synced local items. let requests = []; yield this.list.forEachSyncedDeletedItem(localItem => { requests.push({ path: "/articles/" + localItem.guid, }); }); if (!requests.length) { log.debug("No local deleted synced items to upload"); return; } // Send the request. let request = { method: "POST", path: "/batch", body: { defaults: { method: "DELETE", }, requests: requests, }, headers: {}, }; if (this._serverLastModifiedHeader) { request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader; } let batchResponse = yield this._sendRequest(request); if (batchResponse.status != 200) { this._handleUnexpectedResponse("uploading deleted items", batchResponse); return; } // Delete local items based on the response. for (let response of batchResponse.body.responses) { if (response.status == 412) { // "Precondition failed": The item was modified since the last sync. // Mark the item as having material changes, and reconcile and upload it // in the material-changes phase. // TODO continue; } // A 404 means the item was already deleted on the server, which is OK. // We still need to make sure it's deleted locally, though. if (response.status != 200 && response.status != 404) { this._handleUnexpectedResponse("uploading a deleted item", response); continue; } yield this._deleteItemForGUID(response.body.id); } }), /** * Phase 2 * * Downloads items that were modified since the last sync. */ _downloadModifiedItems: Task.async(function* () { log.debug("Phase 2: Downloading modified items"); // Get modified items from the server. let path = "/articles"; if (this._serverLastModifiedHeader) { path += "?_since=" + this._serverLastModifiedHeader; } let request = { method: "GET", path: path, headers: {}, }; if (this._serverLastModifiedHeader) { request.headers["If-Modified-Since"] = this._serverLastModifiedHeader; } let response = yield this._sendRequest(request); if (response.status == 304) { // not modified log.debug("No server changes"); return; } if (response.status != 200) { this._handleUnexpectedResponse("downloading modified items", response); return; } // Update local items based on the response. for (let serverRecord of response.body.items) { let localItem = yield this._itemForGUID(serverRecord.id); if (localItem) { if (localItem.serverLastModified == serverRecord.last_modified) { // We just uploaded this item in the new-items phase. continue; } // The local item may have materially changed. In that case, don't // overwrite the local changes with the server record. Instead, mark // the item as having material changes and reconcile and upload it in // the material-changes phase. // TODO if (serverRecord.deleted) { yield this._deleteItemForGUID(serverRecord.id); continue; } yield this._updateItemWithServerRecord(localItem, serverRecord); continue; } // new item yield this.list.addItem(localRecordFromServerRecord(serverRecord)); } }), /** * Phase 3 (material changes) * * Uploads not-new items with material changes. */ _uploadMaterialChanges: Task.async(function* () { log.debug("Phase 3: Uploading material changes"); yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_MATERIAL, MUTABLE_RECORD_PROPERTIES); }), /** * Gets the local ReadingListItem with the given GUID. * * @param guid The item's GUID. * @return The matching ReadingListItem. */ _itemForGUID: Task.async(function* (guid) { return (yield this.list.item({ guid: guid })); }), /** * Updates the given local ReadingListItem with the given server record. The * local item's sync status is updated to reflect the fact that the item has * been synced and is up to date. * * @param item A local ReadingListItem. * @param serverRecord A server record representing the item. */ _updateItemWithServerRecord: Task.async(function* (localItem, serverRecord) { if (!localItem) { throw new Error("Item should exist"); } localItem._record = localRecordFromServerRecord(serverRecord); yield this.list.updateItem(localItem); }), /** * Truly deletes the local ReadingListItem with the given GUID. * * @param guid The item's GUID. */ _deleteItemForGUID: Task.async(function* (guid) { let item = yield this._itemForGUID(guid); if (item) { // If item is non-null, then it hasn't been deleted locally. Therefore // it's important to delete it through its list so that the list and its // consumers are notified properly. Set the syncStatus to NEW so that the // list truly deletes the item. item._record.syncStatus = ReadingList.SyncStatus.NEW; yield this.list.deleteItem(item); return; } // If item is null, then it may not actually exist locally, or it may have // been synced and then deleted so that it's marked as being deleted. In // that case, try to delete it directly from the store. As far as the list // is concerned, the item has already been deleted. log.debug("Item not present in list, deleting it by GUID instead"); this.list._store.deleteItemByGUID(guid); }), /** * Sends a request to the server. * * @param req The request object: { method, path, body, headers }. * @return Promise Resolved with the server's response object: * { status, body, headers }. */ _sendRequest: Task.async(function* (req) { log.debug("Sending request", req); let response = yield this._client.request(req); log.debug("Received response", response); // Response header names are lowercase. if (response.headers && "last-modified" in response.headers) { this._serverLastModifiedHeader = response.headers["last-modified"]; } return response; }), _handleUnexpectedResponse(contextMsgFragment, response) { log.warn(`Unexpected response ${contextMsgFragment}`, response); }, // TODO: Wipe this pref when user logs out. get _serverLastModifiedHeader() { if (!("__serverLastModifiedHeader" in this)) { this.__serverLastModifiedHeader = Preferences.get(SERVER_LAST_MODIFIED_HEADER_PREF, undefined); } return this.__serverLastModifiedHeader; }, set _serverLastModifiedHeader(val) { this.__serverLastModifiedHeader = val; Preferences.set(SERVER_LAST_MODIFIED_HEADER_PREF, val); }, }; /** * Translates a local ReadingListItem into a server record. * * @param localItem The local ReadingListItem. * @param localProperties An array of local item property names. Only these * properties will be included in the server record. * @return The server record. */ function serverRecordFromLocalItem(localItem, localProperties) { let serverRecord = {}; for (let localProp of localProperties) { let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp]; if (localProp in localItem._record) { serverRecord[serverProp] = localItem._record[localProp]; } } return serverRecord; } /** * Translates a server record into a local record. The returned local record's * syncStatus will reflect the fact that the local record is up-to-date synced. * * @param serverRecord The server record. * @return The local record. */ function localRecordFromServerRecord(serverRecord) { let localRecord = { // Mark the record as being up-to-date synced. syncStatus: ReadingList.SyncStatus.SYNCED, }; for (let localProp in SERVER_PROPERTIES_BY_LOCAL_PROPERTIES) { let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp]; if (serverProp in serverRecord) { localRecord[localProp] = serverRecord[serverProp]; } } return localRecord; } Object.defineProperty(this, "Sync", { get() { if (!this._singleton) { this._singleton = new SyncImpl(ReadingList); } return this._singleton; }, });