Bug 1131416 - Desktop syncing module for reading list service (sync module). r=markh

This commit is contained in:
Drew Willcoxon 2015-03-20 15:48:53 -07:00
parent 407619ed84
commit 2559961e02
9 changed files with 1287 additions and 146 deletions

View File

@ -36,16 +36,21 @@ let log = Log.repository.getLogger("readinglist.api");
// Each ReadingListItem has a _record property, an object containing the raw
// data from the server and local store. These are the names of the properties
// in that object.
//
// Not important, but FYI: The order that these are listed in follows the order
// that the server doc lists the fields in the article data model, more or less:
// http://readinglist.readthedocs.org/en/latest/model.html
const ITEM_RECORD_PROPERTIES = `
guid
lastModified
serverLastModified
url
preview
title
resolvedURL
resolvedTitle
excerpt
preview
status
archived
deleted
favorite
isArticle
wordCount
@ -56,6 +61,7 @@ const ITEM_RECORD_PROPERTIES = `
markedReadBy
markedReadOn
readPosition
syncStatus
`.trim().split(/\s+/);
// Article objects that are passed to ReadingList.addItem may contain
@ -69,6 +75,37 @@ const ITEM_DISREGARDED_PROPERTIES = `
length
`.trim().split(/\s+/);
// Each local item has a syncStatus indicating the state of the item in relation
// to the sync server. See also Sync.jsm.
const SYNC_STATUS_SYNCED = 0;
const SYNC_STATUS_NEW = 1;
const SYNC_STATUS_CHANGED_STATUS = 2;
const SYNC_STATUS_CHANGED_MATERIAL = 3;
const SYNC_STATUS_DELETED = 4;
// These options are passed as the "control" options to store methods and filter
// out all records in the store with syncStatus SYNC_STATUS_DELETED.
const STORE_OPTIONS_IGNORE_DELETED = {
syncStatus: [
SYNC_STATUS_SYNCED,
SYNC_STATUS_NEW,
SYNC_STATUS_CHANGED_STATUS,
SYNC_STATUS_CHANGED_MATERIAL,
],
};
// Changes to the following item properties are considered "status," or
// "status-only," changes, in relation to the sync server. Changes to other
// properties are considered "material" changes. See also Sync.jsm.
const SYNC_STATUS_PROPERTIES_STATUS = `
favorite
markedReadBy
markedReadOn
readPosition
unread
`.trim().split(/\s+/);
/**
* A reading list contains ReadingListItems.
*
@ -131,6 +168,18 @@ ReadingListImpl.prototype = {
ItemRecordProperties: ITEM_RECORD_PROPERTIES,
SyncStatus: {
SYNCED: SYNC_STATUS_SYNCED,
NEW: SYNC_STATUS_NEW,
CHANGED_STATUS: SYNC_STATUS_CHANGED_STATUS,
CHANGED_MATERIAL: SYNC_STATUS_CHANGED_MATERIAL,
DELETED: SYNC_STATUS_DELETED,
},
SyncStatusProperties: {
STATUS: SYNC_STATUS_PROPERTIES_STATUS,
},
/**
* Yields the number of items in the list.
*
@ -140,7 +189,7 @@ ReadingListImpl.prototype = {
* with an Error on error.
*/
count: Task.async(function* (...optsList) {
return (yield this._store.count(...optsList));
return (yield this._store.count(optsList, STORE_OPTIONS_IGNORE_DELETED));
}),
/**
@ -151,7 +200,7 @@ ReadingListImpl.prototype = {
* whether the URL is in the list or not.
*/
hasItemForURL: Task.async(function* (url) {
url = normalizeURI(url).spec;
url = normalizeURI(url);
// This is used on every tab switch and page load of the current tab, so we
// want it to be quick and avoid a DB query whenever possible.
@ -189,6 +238,26 @@ ReadingListImpl.prototype = {
* an Error on error.
*/
forEachItem: Task.async(function* (callback, ...optsList) {
yield this._forEachItem(callback, optsList, STORE_OPTIONS_IGNORE_DELETED);
}),
/**
* Like forEachItem, but enumerates only previously synced items that are
* marked as being locally deleted.
*/
forEachSyncedDeletedItem: Task.async(function* (callback, ...optsList) {
yield this._forEachItem(callback, optsList, {
syncStatus: SYNC_STATUS_DELETED,
});
}),
/**
* See forEachItem.
*
* @param storeOptions An options object passed to the store as the "control"
* options.
*/
_forEachItem: Task.async(function* (callback, optsList, storeOptions) {
let promiseChain = Promise.resolve();
yield this._store.forEachItem(record => {
promiseChain = promiseChain.then(() => {
@ -201,7 +270,7 @@ ReadingListImpl.prototype = {
return undefined;
});
});
}, ...optsList);
}, optsList, storeOptions);
yield promiseChain;
}),
@ -236,10 +305,25 @@ ReadingListImpl.prototype = {
*/
addItem: Task.async(function* (record) {
record = normalizeRecord(record);
record.addedOn = Date.now();
if (Services.prefs.prefHasUserValue("services.sync.client.name")) {
record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
if (!record.url) {
throw new Error("The item must have a url");
}
if (!("addedOn" in record)) {
record.addedOn = Date.now();
}
if (!("addedBy" in record)) {
let pref = "services.sync.client.name";
if (Services.prefs.prefHasUserValue(pref)) {
record.addedBy = Services.prefs.getCharPref(pref);
}
if (!record.addedBy) {
record.addedBy = "Firefox";
}
}
if (!("syncStatus" in record)) {
record.syncStatus = SYNC_STATUS_NEW;
}
yield this._store.addItem(record);
this._invalidateIterators();
let item = this._itemFromRecord(record);
@ -264,6 +348,9 @@ ReadingListImpl.prototype = {
* Error on error.
*/
updateItem: Task.async(function* (item) {
if (!item._record.url) {
throw new Error("The item must have a url");
}
this._ensureItemBelongsToList(item);
yield this._store.updateItem(item._record);
this._invalidateIterators();
@ -282,7 +369,26 @@ ReadingListImpl.prototype = {
*/
deleteItem: Task.async(function* (item) {
this._ensureItemBelongsToList(item);
yield this._store.deleteItemByURL(item.url);
// If the item is new and therefore hasn't been synced yet, delete it from
// the store. Otherwise mark it as deleted but don't actually delete it so
// that its status can be synced.
if (item._record.syncStatus == SYNC_STATUS_NEW) {
yield this._store.deleteItemByURL(item.url);
}
else {
// To prevent data leakage, only keep the record fields needed to sync
// the deleted status: guid and syncStatus.
let newRecord = {};
for (let prop of ITEM_RECORD_PROPERTIES) {
newRecord[prop] = null;
}
newRecord.guid = item._record.guid;
newRecord.syncStatus = SYNC_STATUS_DELETED;
item._record = newRecord;
yield this._store.updateItemByGUID(item._record);
}
item.list = null;
this._itemsByNormalizedURL.delete(item.url);
this._invalidateIterators();
@ -309,7 +415,7 @@ ReadingListImpl.prototype = {
* @return The first matching item, or null if there are no matching items.
*/
itemForURL: Task.async(function* (uri) {
let url = normalizeURI(uri).spec;
let url = normalizeURI(uri);
return (yield this.item({ url: url }, { resolvedURL: url }));
}),
@ -508,7 +614,7 @@ ReadingListItem.prototype = {
* @type string
*/
get url() {
return this._record.url;
return this._record.url || undefined;
},
/**
@ -529,7 +635,7 @@ ReadingListItem.prototype = {
* @type string
*/
get resolvedURL() {
return this._record.resolvedURL;
return this._record.resolvedURL || undefined;
},
set resolvedURL(val) {
this._updateRecord({ resolvedURL: val });
@ -554,7 +660,7 @@ ReadingListItem.prototype = {
* @type string
*/
get title() {
return this._record.title;
return this._record.title || undefined;
},
set title(val) {
this._updateRecord({ title: val });
@ -565,7 +671,7 @@ ReadingListItem.prototype = {
* @type string
*/
get resolvedTitle() {
return this._record.resolvedTitle;
return this._record.resolvedTitle || undefined;
},
set resolvedTitle(val) {
this._updateRecord({ resolvedTitle: val });
@ -576,21 +682,21 @@ ReadingListItem.prototype = {
* @type string
*/
get excerpt() {
return this._record.excerpt;
return this._record.excerpt || undefined;
},
set excerpt(val) {
this._updateRecord({ excerpt: val });
},
/**
* The item's status.
* @type integer
* The item's archived status.
* @type boolean
*/
get status() {
return this._record.status;
get archived() {
return !!this._record.archived;
},
set status(val) {
this._updateRecord({ status: val });
set archived(val) {
this._updateRecord({ archived: !!val });
},
/**
@ -620,7 +726,7 @@ ReadingListItem.prototype = {
* @type integer
*/
get wordCount() {
return this._record.wordCount;
return this._record.wordCount || undefined;
},
set wordCount(val) {
this._updateRecord({ wordCount: val });
@ -668,7 +774,7 @@ ReadingListItem.prototype = {
* @type string
*/
get markedReadBy() {
return this._record.markedReadBy;
return this._record.markedReadBy || undefined;
},
set markedReadBy(val) {
this._updateRecord({ markedReadBy: val });
@ -692,7 +798,7 @@ ReadingListItem.prototype = {
* @param integer
*/
get readPosition() {
return this._record.readPosition;
return this._record.readPosition || undefined;
},
set readPosition(val) {
this._updateRecord({ readPosition: val });
@ -703,7 +809,7 @@ ReadingListItem.prototype = {
* @type string
*/
get preview() {
return this._record.preview;
return this._record.preview || undefined;
},
/**
@ -730,6 +836,11 @@ ReadingListItem.prototype = {
* not normalized, but everywhere else, records are always normalized unless
* otherwise stated. The setter normalizes the passed-in value, so it will
* throw an error if the value is not a valid record.
*
* This object should reflect the item's representation in the local store, so
* when calling the setter, be careful that it doesn't drift away from the
* store's record. If you set it, you should also call updateItem() around
* the same time.
*/
get _record() {
return this.__record;
@ -746,6 +857,18 @@ ReadingListItem.prototype = {
*/
_updateRecord(partialRecord) {
let record = this._record;
// The syncStatus flag can change from SYNCED to either CHANGED_STATUS or
// CHANGED_MATERIAL, or from CHANGED_STATUS to CHANGED_MATERIAL.
if (record.syncStatus == SYNC_STATUS_SYNCED ||
record.syncStatus == SYNC_STATUS_CHANGED_STATUS) {
let allStatusChanges = Object.keys(partialRecord).every(prop => {
return SYNC_STATUS_PROPERTIES_STATUS.indexOf(prop) >= 0;
});
record.syncStatus = allStatusChanges ? SYNC_STATUS_CHANGED_STATUS :
SYNC_STATUS_CHANGED_MATERIAL;
}
for (let prop in partialRecord) {
record[prop] = partialRecord[prop];
}
@ -864,17 +987,20 @@ ReadingListItemIterator.prototype = {
function normalizeRecord(nonNormalizedRecord) {
let record = {};
for (let prop in nonNormalizedRecord) {
if (ITEM_DISREGARDED_PROPERTIES.includes(prop)) {
if (ITEM_DISREGARDED_PROPERTIES.indexOf(prop) >= 0) {
continue;
}
if (!ITEM_RECORD_PROPERTIES.includes(prop)) {
if (ITEM_RECORD_PROPERTIES.indexOf(prop) < 0) {
throw new Error("Unrecognized item property: " + prop);
}
switch (prop) {
case "url":
case "resolvedURL":
if (nonNormalizedRecord[prop]) {
record[prop] = normalizeURI(nonNormalizedRecord[prop]).spec;
record[prop] = normalizeURI(nonNormalizedRecord[prop]);
}
else {
record[prop] = nonNormalizedRecord[prop];
}
break;
default:
@ -890,17 +1016,22 @@ function normalizeRecord(nonNormalizedRecord) {
* or compare against.
*
* @param {nsIURI/String} uri - URI to normalize.
* @returns {nsIURI} Cloned and normalized version of the input URI.
* @returns {String} String spec of a cloned and normalized version of the
* input URI.
*/
function normalizeURI(uri) {
if (typeof uri == "string") {
uri = Services.io.newURI(uri, "", null);
try {
uri = Services.io.newURI(uri, "", null);
} catch (ex) {
return uri;
}
}
uri = uri.cloneIgnoringRef();
try {
uri.userPass = "";
} catch (ex) {} // Not all nsURI impls (eg, nsSimpleURI) support .userPass
return uri;
return uri.spec;
};
function hash(str) {
@ -944,7 +1075,7 @@ function getMetadataFromBrowser(browser) {
Object.defineProperty(this, "ReadingList", {
get() {
if (!this._singleton) {
let store = new SQLiteStore("reading-list-temp2.sqlite");
let store = new SQLiteStore("reading-list.sqlite");
this._singleton = new ReadingListImpl(store);
}
return this._singleton;

View File

@ -35,13 +35,16 @@ this.SQLiteStore.prototype = {
/**
* Yields the number of items in the store that match the given options.
*
* @param optsList A variable number of options objects that control the
* @param userOptsList A variable number of options objects that control the
* items that are matched. See Options Objects in ReadingList.jsm.
* @param controlOpts A single options object. Use this to filter out items
* that don't match it -- in other words, to override the user options.
* See Options Objects in ReadingList.jsm.
* @return Promise<number> The number of matching items in the store.
* Rejected with an Error on error.
*/
count: Task.async(function* (...optsList) {
let [sql, args] = sqlFromOptions(optsList);
count: Task.async(function* (userOptsList=[], controlOpts={}) {
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
let count = 0;
let conn = yield this._connectionPromise;
yield conn.executeCached(`
@ -55,13 +58,16 @@ this.SQLiteStore.prototype = {
*
* @param callback Called for each item in the enumeration. It's passed a
* single object, an item.
* @param optsList A variable number of options objects that control the
* @param userOptsList A variable number of options objects that control the
* items that are matched. See Options Objects in ReadingList.jsm.
* @param controlOpts A single options object. Use this to filter out items
* that don't match it -- in other words, to override the user options.
* See Options Objects in ReadingList.jsm.
* @return Promise<null> Resolved when the enumeration completes. Rejected
* with an Error on error.
*/
forEachItem: Task.async(function* (callback, ...optsList) {
let [sql, args] = sqlFromOptions(optsList);
forEachItem: Task.async(function* (callback, userOptsList=[], controlOpts={}) {
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
let colNames = ReadingList.ItemRecordProperties;
let conn = yield this._connectionPromise;
yield conn.executeCached(`
@ -99,18 +105,23 @@ this.SQLiteStore.prototype = {
* Error on error.
*/
updateItem: Task.async(function* (item) {
let assignments = [];
for (let propName in item) {
assignments.push(`${propName} = :${propName}`);
}
let conn = yield this._connectionPromise;
yield conn.executeCached(`
UPDATE items SET ${assignments} WHERE url = :url;
`, item);
yield this._updateItem(item, "url");
}),
/**
* Deletes an item from the store.
* Same as updateItem, but the item is keyed off of its `guid` instead of its
* `url`.
*
* @param item The item to update. It must have a `guid`.
* @return Promise<null> Resolved when the store is updated. Rejected with an
* Error on error.
*/
updateItemByGUID: Task.async(function* (item) {
yield this._updateItem(item, "guid");
}),
/**
* Deletes an item from the store by its URL.
*
* @param url The URL string of the item to delete.
* @return Promise<null> Resolved when the store is updated. Rejected with an
@ -123,6 +134,20 @@ this.SQLiteStore.prototype = {
`, { url: url });
}),
/**
* Deletes an item from the store by its GUID.
*
* @param guid The GUID string of the item to delete.
* @return Promise<null> Resolved when the store is updated. Rejected with an
* Error on error.
*/
deleteItemByGUID: Task.async(function* (guid) {
let conn = yield this._connectionPromise;
yield conn.executeCached(`
DELETE FROM items WHERE guid = :guid;
`, { guid: guid });
}),
/**
* Call this when you're done with the store. Don't use it afterward.
*/
@ -161,6 +186,30 @@ this.SQLiteStore.prototype = {
}
}),
/**
* Updates the properties of an item that's already present in the store. See
* ReadingList.prototype.updateItem.
*
* @param item The item to update. It must have the property named by
* keyProp.
* @param keyProp The item is keyed off of this property.
* @return Promise<null> Resolved when the store is updated. Rejected with an
* Error on error.
*/
_updateItem: Task.async(function* (item, keyProp) {
let assignments = [];
for (let propName in item) {
assignments.push(`${propName} = :${propName}`);
}
let conn = yield this._connectionPromise;
if (!item[keyProp]) {
throw new Error("Item must have " + keyProp);
}
yield conn.executeCached(`
UPDATE items SET ${assignments} WHERE ${keyProp} = :${keyProp};
`, item);
}),
// Promise<Sqlite.OpenedConnection>
_connectionPromise: null,
@ -184,17 +233,23 @@ this.SQLiteStore.prototype = {
yield conn.execute(`
PRAGMA journal_size_limit = 524288;
`);
// Not important, but FYI: The order that these columns are listed in
// follows the order that the server doc lists the fields in the article
// data model, more or less:
// http://readinglist.readthedocs.org/en/latest/model.html
yield conn.execute(`
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
url TEXT NOT NULL UNIQUE,
resolvedURL TEXT UNIQUE,
lastModified INTEGER,
serverLastModified INTEGER,
url TEXT UNIQUE,
preview TEXT,
title TEXT,
resolvedURL TEXT UNIQUE,
resolvedTitle TEXT,
excerpt TEXT,
status INTEGER,
archived BOOLEAN,
deleted BOOLEAN,
favorite BOOLEAN,
isArticle BOOLEAN,
wordCount INTEGER,
@ -205,7 +260,7 @@ this.SQLiteStore.prototype = {
markedReadBy TEXT,
markedReadOn INTEGER,
readPosition INTEGER,
preview TEXT
syncStatus INTEGER
);
`);
yield conn.execute(`
@ -236,20 +291,24 @@ function itemFromRow(row) {
* Returns the back part of a SELECT statement generated from the given list of
* options.
*
* @param optsList See Options Objects in ReadingList.jsm.
* @param userOptsList A variable number of options objects that control the
* items that are matched. See Options Objects in ReadingList.jsm.
* @param controlOpts A single options object. Use this to filter out items
* that don't match it -- in other words, to override the user options.
* See Options Objects in ReadingList.jsm.
* @return An array [sql, args]. sql is a string of SQL. args is an object
* that contains arguments for all the parameters in sql.
*/
function sqlFromOptions(optsList) {
// We modify the options objects, which were passed in by the store client, so
// clone them first.
optsList = Cu.cloneInto(optsList, {}, { cloneFunctions: false });
function sqlWhereFromOptions(userOptsList, controlOpts) {
// We modify the options objects in userOptsList, which were passed in by the
// store client, so clone them first.
userOptsList = Cu.cloneInto(userOptsList, {}, { cloneFunctions: false });
let sort;
let sortDir;
let limit;
let offset;
for (let opts of optsList) {
for (let opts of userOptsList) {
if ("sort" in opts) {
sort = opts.sort;
delete opts.sort;
@ -284,21 +343,44 @@ function sqlFromOptions(optsList) {
}
let args = {};
let mainExprs = [];
function uniqueParamName(name) {
if (name in args) {
for (let i = 1; ; i++) {
let newName = `${name}_${i}`;
if (!(newName in args)) {
return newName;
}
}
}
return name;
let controlSQLExpr = sqlExpressionFromOptions([controlOpts], args);
if (controlSQLExpr) {
mainExprs.push(`(${controlSQLExpr})`);
}
// Build a WHERE clause for the remaining properties. Assume they all refer
// to columns. (If they don't, the SQL query will fail.)
let userSQLExpr = sqlExpressionFromOptions(userOptsList, args);
if (userSQLExpr) {
mainExprs.push(`(${userSQLExpr})`);
}
if (mainExprs.length) {
let conjunction = mainExprs.join(" AND ");
fragments.unshift(`WHERE ${conjunction}`);
}
let sql = fragments.join(" ");
return [sql, args];
}
/**
* Returns a SQL expression generated from the given options list. Each options
* object in the list generates a subexpression, and all the subexpressions are
* OR'ed together to produce the final top-level expression. (e.g., an optsList
* with three options objects would generate an expression like "(guid = :guid
* OR (title = :title AND unread = :unread) OR resolvedURL = :resolvedURL)".)
*
* All the properties of the options objects are assumed to refer to columns in
* the database. If they don't, your SQL query will fail.
*
* @param optsList See Options Objects in ReadingList.jsm.
* @param args An object that will hold the SQL parameters. It will be
* modified.
* @return A string of SQL. Also, args will contain arguments for all the
* parameters in the SQL.
*/
function sqlExpressionFromOptions(optsList, args) {
let disjunctions = [];
for (let opts of optsList) {
let conjunctions = [];
@ -310,14 +392,14 @@ function sqlFromOptions(optsList) {
let array = opts[key];
let params = [];
for (let i = 0; i < array.length; i++) {
let paramName = uniqueParamName(key);
let paramName = uniqueParamName(args, key);
params.push(`:${paramName}`);
args[paramName] = array[i];
}
conjunctions.push(`${key} IN (${params})`);
}
else {
let paramName = uniqueParamName(key);
let paramName = uniqueParamName(args, key);
conjunctions.push(`${key} = :${paramName}`);
args[paramName] = opts[key];
}
@ -328,11 +410,26 @@ function sqlFromOptions(optsList) {
}
}
let disjunction = disjunctions.join(" OR ");
if (disjunction) {
let where = `WHERE ${disjunction}`;
fragments = [where].concat(fragments);
}
let sql = fragments.join(" ");
return [sql, args];
return disjunction;
}
/**
* Returns a version of the given name such that it doesn't conflict with the
* name of any property in args. e.g., if name is "foo" but args already has
* properties named "foo", "foo1", and "foo2", then "foo3" is returned.
*
* @param args An object.
* @param name The name you want to use.
* @return A unique version of the given name.
*/
function uniqueParamName(args, name) {
if (name in args) {
for (let i = 1; ; i++) {
let newName = `${name}_${i}`;
if (!(newName in args)) {
return newName;
}
}
}
return name;
}

View File

@ -74,7 +74,14 @@ let intervals = {
// This is the implementation, but it's not exposed directly.
function InternalScheduler() {
// oh, I don't know what logs yet - let's guess!
let logs = ["readinglist", "FirefoxAccounts", "browserwindow.syncui"];
let logs = [
"browserwindow.syncui",
"FirefoxAccounts",
"readinglist.api",
"readinglist.serverclient",
"readinglist.sync",
];
this._logManager = new LogManager("readinglist.", logs, "readinglist");
this.log = Log.repository.getLogger("readinglist.scheduler");
this.log.info("readinglist scheduler created.")

View File

@ -0,0 +1,556 @@
/* 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<null> 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<null> 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<response> 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;
},
});

View File

@ -9,6 +9,7 @@ EXTRA_JS_MODULES.readinglist += [
'Scheduler.jsm',
'ServerClient.jsm',
'SQLiteStore.jsm',
'Sync.jsm',
]
TESTING_JS_MODULES += [

View File

@ -38,7 +38,6 @@ add_task(function* prepare() {
title: `title ${i}`,
excerpt: `excerpt ${i}`,
unread: 0,
lastModified: Date.now(),
favorite: 0,
isArticle: 1,
storedOn: Date.now(),
@ -137,7 +136,26 @@ add_task(function* constraints() {
catch (e) {
err = e;
}
checkError(err);
Assert.ok(err);
Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
Assert.equal(err.message, "The item must have a url");
// update an item with no url
item = (yield gList.item({ guid: gItems[0].guid }));
Assert.ok(item);
let oldURL = item._record.url;
item._record.url = null;
err = null;
try {
yield gList.updateItem(item);
}
catch (e) {
err = e;
}
item._record.url = oldURL;
Assert.ok(err);
Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
Assert.equal(err.message, "The item must have a url");
// add an item with a bogus property
item = kindOfClone(gItems[0]);
@ -269,6 +287,19 @@ add_task(function* forEachItem() {
checkItems(items, [gItems[0], gItems[1]]);
});
add_task(function* forEachSyncedDeletedItem() {
let deletedItem = yield gList.addItem({
guid: "forEachSyncedDeletedItem",
url: "http://example.com/forEachSyncedDeletedItem",
});
deletedItem._record.syncStatus = gList.SyncStatus.SYNCED;
yield gList.deleteItem(deletedItem);
let items = [];
yield gList.forEachSyncedDeletedItem(item => items.push(item));
Assert.equal(items.length, 1);
Assert.equal(items[0].guid, deletedItem.guid);
});
add_task(function* forEachItem_promises() {
// promises resolved immediately
let items = [];
@ -540,36 +571,22 @@ add_task(function* item_setRecord() {
let item = (yield iter.items(1))[0];
Assert.ok(item);
// Set item._record without an updateItem. After fetching the item again, its
// title should be the old title.
let oldTitle = item.title;
let newTitle = "item_setRecord title 1";
Assert.notEqual(oldTitle, newTitle);
item._record.title = newTitle;
Assert.equal(item.title, newTitle);
iter = gList.iterator({
sort: "guid",
});
let sameItem = (yield iter.items(1))[0];
Assert.ok(item === sameItem);
Assert.equal(sameItem.title, oldTitle);
// Set item._record followed by an updateItem. After fetching the item again,
// its title should be the new title.
newTitle = "item_setRecord title 2";
let newTitle = "item_setRecord title 1";
item._record.title = newTitle;
yield gList.updateItem(item);
Assert.equal(item.title, newTitle);
iter = gList.iterator({
sort: "guid",
});
sameItem = (yield iter.items(1))[0];
let sameItem = (yield iter.items(1))[0];
Assert.ok(item === sameItem);
Assert.equal(sameItem.title, newTitle);
// Set item.title directly and call updateItem. After fetching the item
// again, its title should be the new title.
newTitle = "item_setRecord title 3";
newTitle = "item_setRecord title 2";
item.title = newTitle;
yield gList.updateItem(item);
Assert.equal(item.title, newTitle);
@ -678,11 +695,9 @@ add_task(function* deleteItem() {
function checkItems(actualItems, expectedItems) {
Assert.equal(actualItems.length, expectedItems.length);
for (let i = 0; i < expectedItems.length; i++) {
for (let prop in expectedItems[i]) {
if (prop != "list") {
Assert.ok(prop in actualItems[i]._record, prop);
Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
}
for (let prop in expectedItems[i]._record) {
Assert.ok(prop in actualItems[i]._record, prop);
Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
}
}
}

View File

@ -161,100 +161,88 @@ add_task(function* constraints() {
yield gStore.deleteItemByURL(url1);
yield gStore.deleteItemByURL(url2);
let items = [];
yield gStore.forEachItem(i => items.push(i), { url: [url1, url2] });
yield gStore.forEachItem(i => items.push(i), [{ url: [url1, url2] }]);
Assert.equal(items.length, 0);
// add a new item with no url, which is not allowed
item = kindOfClone(gItems[0]);
delete item.url;
err = null;
try {
yield gStore.addItem(item);
}
catch (e) {
err = e;
}
checkError(err, "NOT NULL constraint failed: items.url");
});
add_task(function* count() {
let count = yield gStore.count();
Assert.equal(count, gItems.length);
count = yield gStore.count({
count = yield gStore.count([{
guid: gItems[0].guid,
});
}]);
Assert.equal(count, 1);
});
add_task(function* forEachItem() {
// all items
let items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
sort: "guid",
});
}]);
checkItems(items, gItems);
// first item
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
limit: 1,
sort: "guid",
});
}]);
checkItems(items, gItems.slice(0, 1));
// last item
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
limit: 1,
sort: "guid",
descending: true,
});
}]);
checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
// match on a scalar property
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
guid: gItems[0].guid,
});
}]);
checkItems(items, gItems.slice(0, 1));
// match on an array
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
guid: gItems.map(i => i.guid),
sort: "guid",
});
}]);
checkItems(items, gItems);
// match on AND'ed properties
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
guid: gItems.map(i => i.guid),
title: gItems[0].title,
sort: "guid",
});
}]);
checkItems(items, [gItems[0]]);
// match on OR'ed properties
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
guid: gItems[1].guid,
sort: "guid",
}, {
guid: gItems[0].guid,
});
}]);
checkItems(items, [gItems[0], gItems[1]]);
// match on AND'ed and OR'ed properties
items = [];
yield gStore.forEachItem(item => items.push(item), {
yield gStore.forEachItem(item => items.push(item), [{
guid: gItems.map(i => i.guid),
title: gItems[1].title,
sort: "guid",
}, {
guid: gItems[0].guid,
});
}]);
checkItems(items, [gItems[0], gItems[1]]);
});
@ -263,9 +251,21 @@ add_task(function* updateItem() {
gItems[0].title = newTitle;
yield gStore.updateItem(gItems[0]);
let item;
yield gStore.forEachItem(i => item = i, {
yield gStore.forEachItem(i => item = i, [{
guid: gItems[0].guid,
});
}]);
Assert.ok(item);
Assert.equal(item.title, gItems[0].title);
});
add_task(function* updateItemByGUID() {
let newTitle = "updateItemByGUID";
gItems[0].title = newTitle;
yield gStore.updateItemByGUID(gItems[0]);
let item;
yield gStore.forEachItem(i => item = i, [{
guid: gItems[0].guid,
}]);
Assert.ok(item);
Assert.equal(item.title, gItems[0].title);
});
@ -276,27 +276,30 @@ add_task(function* deleteItemByURL() {
yield gStore.deleteItemByURL(gItems[0].url);
Assert.equal((yield gStore.count()), gItems.length - 1);
let items = [];
yield gStore.forEachItem(i => items.push(i), {
yield gStore.forEachItem(i => items.push(i), [{
sort: "guid",
});
}]);
checkItems(items, gItems.slice(1));
// delete second item
yield gStore.deleteItemByURL(gItems[1].url);
Assert.equal((yield gStore.count()), gItems.length - 2);
items = [];
yield gStore.forEachItem(i => items.push(i), {
yield gStore.forEachItem(i => items.push(i), [{
sort: "guid",
});
}]);
checkItems(items, gItems.slice(2));
});
// This test deletes items so it should probably run last.
add_task(function* deleteItemByGUID() {
// delete third item
yield gStore.deleteItemByURL(gItems[2].url);
yield gStore.deleteItemByGUID(gItems[2].guid);
Assert.equal((yield gStore.count()), gItems.length - 3);
items = [];
yield gStore.forEachItem(i => items.push(i), {
let items = [];
yield gStore.forEachItem(i => items.push(i), [{
sort: "guid",
});
}]);
checkItems(items, gItems.slice(3));
});

View File

@ -0,0 +1,330 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
let gProfildDirFile = do_get_profile();
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource:///modules/readinglist/Sync.jsm");
let { localRecordFromServerRecord } =
Cu.import("resource:///modules/readinglist/Sync.jsm", {});
let gList;
let gSync;
let gClient;
let gLocalItems = [];
function run_test() {
run_next_test();
}
add_task(function* prepare() {
gSync = Sync;
gList = Sync.list;
let dbFile = gProfildDirFile.clone();
dbFile.append(gSync.list._store.pathRelativeToProfileDir);
do_register_cleanup(function* () {
// Wait for the list's store to close its connection to the database.
yield gList.destroy();
if (dbFile.exists()) {
dbFile.remove(true);
}
});
gClient = new MockClient();
gSync._client = gClient;
let dumpAppender = new Log.DumpAppender();
dumpAppender.level = Log.Level.All;
let logNames = [
"readinglist.sync",
];
for (let name of logNames) {
let log = Log.repository.getLogger(name);
log.level = Log.Level.All;
log.addAppender(dumpAppender);
}
});
add_task(function* uploadNewItems() {
// Add some local items.
for (let i = 0; i < 3; i++) {
let record = {
url: `http://example.com/${i}`,
title: `title ${i}`,
addedBy: "device name",
};
gLocalItems.push(yield gList.addItem(record));
}
Assert.ok(!("resolvedURL" in gLocalItems[0]._record));
yield gSync.start();
// The syncer should update local items with the items in the server response.
// e.g., the item didn't have a resolvedURL before sync, but after sync it
// should.
Assert.ok("resolvedURL" in gLocalItems[0]._record);
checkItems(gClient.items, gLocalItems);
});
add_task(function* uploadStatusChanges() {
// Change an item's unread from true to false.
Assert.ok(gLocalItems[0].unread === true);
gLocalItems[0].unread = false;
yield gList.updateItem(gLocalItems[0]);
yield gSync.start();
Assert.ok(gLocalItems[0].unread === false);
checkItems(gClient.items, gLocalItems);
});
add_task(function* downloadChanges() {
// Change an item on the server.
let newTitle = "downloadChanges new title";
let response = yield gClient.request({
method: "PATCH",
path: "/articles/1",
body: {
title: newTitle,
},
});
Assert.equal(response.status, 200);
// Add a new item on the server.
let newRecord = {
url: "http://example.com/downloadChanges-new-item",
title: "downloadChanges 2",
added_by: "device name",
};
response = yield gClient.request({
method: "POST",
path: "/articles",
body: newRecord,
});
Assert.equal(response.status, 201);
// Delete an item on the server.
response = yield gClient.request({
method: "DELETE",
path: "/articles/2",
});
Assert.equal(response.status, 200);
yield gSync.start();
// Refresh the list of local items. The changed item should be changed
// locally, the deleted item should be deleted locally, and the new item
// should appear in the list.
gLocalItems = (yield gList.iterator({ sort: "guid" }).
items(gLocalItems.length));
Assert.equal(gLocalItems[1].title, newTitle);
Assert.equal(gLocalItems[2].url, newRecord.url);
checkItems(gClient.items, gLocalItems);
});
function MockClient() {
this._items = [];
this._nextItemID = 0;
this._nextLastModifiedToken = 0;
}
MockClient.prototype = {
request(req) {
let response = this._routeRequest(req);
return new Promise(resolve => {
// Resolve the promise asyncly, just as if this were a real server, so
// that we don't somehow end up depending on sync behavior.
setTimeout(() => {
resolve(response);
}, 0);
});
},
get items() {
return this._items.slice().sort((item1, item2) => {
return item2.id < item1.id;
});
},
itemByID(id) {
return this._items.find(item => item.id == id);
},
itemByURL(url) {
return this._items.find(item => item.url == url);
},
_items: null,
_nextItemID: null,
_nextLastModifiedToken: null,
_routeRequest(req) {
for (let prop in this) {
let match = (new RegExp("^" + prop + "$")).exec(req.path);
if (match) {
let handler = this[prop];
let method = req.method.toLowerCase();
if (!(method in handler)) {
throw new Error(`Handler ${prop} does not support method ${method}`);
}
let response = handler[method].call(this, req.body, match);
// Make sure the response really is JSON'able (1) as a kind of sanity
// check, (2) to convert any non-primitives (e.g., new String()) into
// primitives, and (3) because that's what the real server returns.
response = JSON.parse(JSON.stringify(response));
return response;
}
}
throw new Error(`Unrecognized path: ${req.path}`);
},
// route handlers
"/articles": {
get(body) {
return new MockResponse(200, {
// No URL params supported right now.
items: this.items,
});
},
post(body) {
let existingItem = this.itemByURL(body.url);
if (existingItem) {
// The real server seems to return a 200 if the items are identical.
if (areSameItems(existingItem, body)) {
return new MockResponse(200);
}
// 303 see other
return new MockResponse(303, {
id: existingItem.id,
});
}
body.id = new String(this._nextItemID++);
let defaultProps = {
last_modified: this._nextLastModifiedToken,
preview: "",
resolved_url: body.url,
resolved_title: body.title,
excerpt: "",
archived: 0,
deleted: 0,
favorite: false,
is_article: true,
word_count: null,
unread: true,
added_on: null,
stored_on: this._nextLastModifiedToken,
marked_read_by: null,
marked_read_on: null,
read_position: null,
};
for (let prop in defaultProps) {
if (!(prop in body) || body[prop] === null) {
body[prop] = defaultProps[prop];
}
}
this._nextLastModifiedToken++;
this._items.push(body);
// 201 created
return new MockResponse(201, body);
},
},
"/articles/([^/]+)": {
get(body, routeMatch) {
let id = routeMatch[1];
let item = this.itemByID(id);
if (!item) {
return new MockResponse(404);
}
return new MockResponse(200, item);
},
patch(body, routeMatch) {
let id = routeMatch[1];
let item = this.itemByID(id);
if (!item) {
return new MockResponse(404);
}
for (let prop in body) {
item[prop] = body[prop];
}
item.last_modified = this._nextLastModifiedToken++;
return new MockResponse(200, item);
},
delete(body, routeMatch) {
let id = routeMatch[1];
let item = this.itemByID(id);
if (!item) {
return new MockResponse(404);
}
item.deleted = true;
return new MockResponse(200);
},
},
"/batch": {
post(body) {
let responses = [];
let defaults = body.defaults || {};
for (let request of body.requests) {
for (let prop in defaults) {
if (!(prop in request)) {
request[prop] = defaults[prop];
}
}
responses.push(this._routeRequest(request));
}
return new MockResponse(200, {
defaults: defaults,
responses: responses,
});
},
},
};
function MockResponse(status, body, headers={}) {
this.status = status;
this.body = body;
this.headers = headers;
}
function areSameItems(item1, item2) {
for (let prop in item1) {
if (!(prop in item2) || item1[prop] != item2[prop]) {
return false;
}
}
for (let prop in item2) {
if (!(prop in item1) || item1[prop] != item2[prop]) {
return false;
}
}
return true;
}
function checkItems(serverRecords, localItems) {
serverRecords = serverRecords.map(r => localRecordFromServerRecord(r));
serverRecords = serverRecords.filter(r => !r.deleted);
Assert.equal(serverRecords.length, localItems.length);
for (let i = 0; i < serverRecords.length; i++) {
for (let prop in localItems[i]._record) {
Assert.ok(prop in serverRecords[i], prop);
Assert.equal(serverRecords[i][prop], localItems[i]._record[prop]);
}
}
}

View File

@ -6,3 +6,4 @@ firefox-appdir = browser
[test_ServerClient.js]
[test_scheduler.js]
[test_SQLiteStore.js]
[test_Sync.js]