mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-11 20:35:50 +00:00
Bug 1131416 - Desktop syncing module for reading list service (sync module). r=markh
This commit is contained in:
parent
407619ed84
commit
2559961e02
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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.")
|
||||
|
556
browser/components/readinglist/Sync.jsm
Normal file
556
browser/components/readinglist/Sync.jsm
Normal 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;
|
||||
},
|
||||
});
|
@ -9,6 +9,7 @@ EXTRA_JS_MODULES.readinglist += [
|
||||
'Scheduler.jsm',
|
||||
'ServerClient.jsm',
|
||||
'SQLiteStore.jsm',
|
||||
'Sync.jsm',
|
||||
]
|
||||
|
||||
TESTING_JS_MODULES += [
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
|
||||
|
330
browser/components/readinglist/test/xpcshell/test_Sync.js
Normal file
330
browser/components/readinglist/test/xpcshell/test_Sync.js
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,3 +6,4 @@ firefox-appdir = browser
|
||||
[test_ServerClient.js]
|
||||
[test_scheduler.js]
|
||||
[test_SQLiteStore.js]
|
||||
[test_Sync.js]
|
||||
|
Loading…
Reference in New Issue
Block a user