mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-29 21:25:35 +00:00
1121 lines
33 KiB
JavaScript
1121 lines
33 KiB
JavaScript
/* 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 = [
|
|
"ReadingList",
|
|
];
|
|
|
|
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
|
|
|
Cu.import("resource://gre/modules/Services.jsm");
|
|
Cu.import("resource://gre/modules/Task.jsm");
|
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|
Cu.import("resource://gre/modules/Preferences.jsm");
|
|
Cu.import("resource://gre/modules/Log.jsm");
|
|
|
|
XPCOMUtils.defineLazyModuleGetter(this, "SQLiteStore",
|
|
"resource:///modules/readinglist/SQLiteStore.jsm");
|
|
|
|
// We use Sync's "Utils" module for the device name, which is unfortunate,
|
|
// but let's give it a better name here.
|
|
XPCOMUtils.defineLazyGetter(this, "SyncUtils", function() {
|
|
const {Utils} = Cu.import("resource://services-sync/util.js", {});
|
|
return Utils;
|
|
});
|
|
|
|
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
|
|
serverLastModified
|
|
url
|
|
preview
|
|
title
|
|
resolvedURL
|
|
resolvedTitle
|
|
excerpt
|
|
archived
|
|
deleted
|
|
favorite
|
|
isArticle
|
|
wordCount
|
|
unread
|
|
addedBy
|
|
addedOn
|
|
storedOn
|
|
markedReadBy
|
|
markedReadOn
|
|
readPosition
|
|
syncStatus
|
|
`.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+/);
|
|
|
|
function ReadingListError(message) {
|
|
this.message = message;
|
|
this.name = this.constructor.name;
|
|
this.stack = (new Error()).stack;
|
|
|
|
// Consumers can set this to an Error that this ReadingListError wraps.
|
|
this.originalError = null;
|
|
}
|
|
ReadingListError.prototype = new Error();
|
|
ReadingListError.prototype.constructor = ReadingListError;
|
|
|
|
function ReadingListExistsError(message) {
|
|
message = message || "The item already exists";
|
|
ReadingListError.call(this, message);
|
|
}
|
|
ReadingListExistsError.prototype = new ReadingListError();
|
|
ReadingListExistsError.prototype.constructor = ReadingListExistsError;
|
|
|
|
function ReadingListDeletedError(message) {
|
|
message = message || "The item has been deleted";
|
|
ReadingListError.call(this, message);
|
|
}
|
|
ReadingListDeletedError.prototype = new ReadingListError();
|
|
ReadingListDeletedError.prototype.constructor = ReadingListDeletedError;
|
|
|
|
/**
|
|
* A reading list contains ReadingListItems.
|
|
*
|
|
* A list maintains only one copy of an item per URL. So if for example you use
|
|
* an iterator to get two references to items with the same URL, your references
|
|
* actually refer to the same JS object.
|
|
*
|
|
* Options Objects
|
|
* ---------------
|
|
*
|
|
* Some methods on ReadingList take an "optsList", a variable number of
|
|
* arguments, each of which is an "options object". Options objects let you
|
|
* control the items that the method acts on.
|
|
*
|
|
* Each options object is a simple object with properties whose names are drawn
|
|
* from ITEM_RECORD_PROPERTIES. For an item to match an options object, the
|
|
* properties of the item must match all the properties in the object. For
|
|
* example, an object { guid: "123" } matches any item whose GUID is 123. An
|
|
* object { guid: "123", title: "foo" } matches any item whose GUID is 123 *and*
|
|
* whose title is foo.
|
|
*
|
|
* You can pass multiple options objects as separate arguments. For an item to
|
|
* match multiple objects, its properties must match all the properties in at
|
|
* least one of the objects. For example, a list of objects { guid: "123" } and
|
|
* { title: "foo" } matches any item whose GUID is 123 *or* whose title is
|
|
* foo.
|
|
*
|
|
* The properties in an options object can be arrays, not only scalars. When a
|
|
* property is an array, then for an item to match, its corresponding property
|
|
* must have a value that matches any value in the array. For example, an
|
|
* options object { guid: ["123", "456"] } matches any item whose GUID is either
|
|
* 123 *or* 456.
|
|
*
|
|
* In addition to properties with names from ITEM_RECORD_PROPERTIES, options
|
|
* objects can also have the following special properties:
|
|
*
|
|
* * sort: The name of a property to sort on.
|
|
* * descending: A boolean, true to sort descending, false to sort ascending.
|
|
* If `sort` is given but `descending` isn't, the sort is ascending (since
|
|
* `descending` is falsey).
|
|
* * limit: Limits the number of matching items to this number.
|
|
* * offset: Starts matching items at this index in the results.
|
|
*
|
|
* Since you can pass multiple options objects in a list, you can include these
|
|
* special properties in any number of the objects in the list, but it doesn't
|
|
* really make sense to do so. The last property in the list is the one that's
|
|
* used.
|
|
*
|
|
* @param store Backing storage for the list. See SQLiteStore.jsm for what this
|
|
* object's interface should look like.
|
|
*/
|
|
function ReadingListImpl(store) {
|
|
this._store = store;
|
|
this._itemsByNormalizedURL = new Map();
|
|
this._iterators = new Set();
|
|
this._listeners = new Set();
|
|
}
|
|
|
|
ReadingListImpl.prototype = {
|
|
|
|
Error: {
|
|
Error: ReadingListError,
|
|
Exists: ReadingListExistsError,
|
|
Deleted: ReadingListDeletedError,
|
|
},
|
|
|
|
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.
|
|
*
|
|
* @param optsList A variable number of options objects that control the
|
|
* items that are matched. See Options Objects.
|
|
* @return Promise<number> The number of matching items in the list. Rejected
|
|
* with an Error on error.
|
|
*/
|
|
count: Task.async(function* (...optsList) {
|
|
return (yield this._store.count(optsList, STORE_OPTIONS_IGNORE_DELETED));
|
|
}),
|
|
|
|
/**
|
|
* Checks whether a given URL is in the ReadingList already.
|
|
*
|
|
* @param {String/nsIURI} url - URL to check.
|
|
* @returns {Promise} Promise that is fulfilled with a boolean indicating
|
|
* whether the URL is in the list or not.
|
|
*/
|
|
hasItemForURL: Task.async(function* (url) {
|
|
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.
|
|
|
|
// First check if any cached items have a direct match.
|
|
if (this._itemsByNormalizedURL.has(url)) {
|
|
return true;
|
|
}
|
|
|
|
// Then check if any cached items may have a different resolved URL
|
|
// that matches.
|
|
for (let itemWeakRef of this._itemsByNormalizedURL.values()) {
|
|
let item = itemWeakRef.get();
|
|
if (item && item.resolvedURL == url) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Finally, fall back to the DB.
|
|
let count = yield this.count({url: url}, {resolvedURL: url});
|
|
return (count > 0);
|
|
}),
|
|
|
|
/**
|
|
* Enumerates the items in the list that match the given options.
|
|
*
|
|
* @param callback Called for each item in the enumeration. It's passed a
|
|
* single object, a ReadingListItem. It may return a promise; if so,
|
|
* the callback will not be called for the next item until the promise
|
|
* is resolved.
|
|
* @param optsList A variable number of options objects that control the
|
|
* items that are matched. See Options Objects.
|
|
* @return Promise<null> Resolved when the enumeration completes *and* the
|
|
* last promise returned by the callback is resolved. Rejected with
|
|
* an Error on error.
|
|
*/
|
|
forEachItem: Task.async(function* (callback, ...optsList) {
|
|
let thisCallback = record => callback(this._itemFromRecord(record));
|
|
yield this._forEachRecord(thisCallback, optsList, STORE_OPTIONS_IGNORE_DELETED);
|
|
}),
|
|
|
|
/**
|
|
* Enumerates the GUIDs for previously synced items that are marked as being
|
|
* locally deleted.
|
|
*/
|
|
forEachSyncedDeletedGUID: Task.async(function* (callback, ...optsList) {
|
|
let thisCallback = record => callback(record.guid);
|
|
yield this._forEachRecord(thisCallback, optsList, {
|
|
syncStatus: SYNC_STATUS_DELETED,
|
|
});
|
|
}),
|
|
|
|
/**
|
|
* See forEachItem.
|
|
*
|
|
* @param storeOptions An options object passed to the store as the "control"
|
|
* options.
|
|
*/
|
|
_forEachRecord: Task.async(function* (callback, optsList, storeOptions) {
|
|
let promiseChain = Promise.resolve();
|
|
yield this._store.forEachItem(record => {
|
|
promiseChain = promiseChain.then(() => {
|
|
return new Promise((resolve, reject) => {
|
|
let promise = callback(record);
|
|
if (promise instanceof Promise) {
|
|
return promise.then(resolve, reject);
|
|
}
|
|
resolve();
|
|
return undefined;
|
|
});
|
|
});
|
|
}, optsList, storeOptions);
|
|
yield promiseChain;
|
|
}),
|
|
|
|
/**
|
|
* Returns a new ReadingListItemIterator that can be used to enumerate items
|
|
* in the list.
|
|
*
|
|
* @param optsList A variable number of options objects that control the
|
|
* items that are matched. See Options Objects.
|
|
* @return A new ReadingListItemIterator.
|
|
*/
|
|
iterator(...optsList) {
|
|
let iter = new ReadingListItemIterator(this, ...optsList);
|
|
this._iterators.add(Cu.getWeakReference(iter));
|
|
return iter;
|
|
},
|
|
|
|
/**
|
|
* Adds an item to the list that isn't already present.
|
|
*
|
|
* The given object represents a new item, and the properties of the object
|
|
* are those in ITEM_RECORD_PROPERTIES. It may have as few or as many
|
|
* properties that you want to set, but it must have a `url` property.
|
|
*
|
|
* It's an error to call this with an object whose `url` or `guid` properties
|
|
* are the same as those of items that are already present in the list. The
|
|
* returned promise is rejected in that case.
|
|
*
|
|
* @param record A simple object representing an item.
|
|
* @return Promise<ReadingListItem> Resolved with the new item when the list
|
|
* is updated. Rejected with an Error on error.
|
|
*/
|
|
addItem: Task.async(function* (record) {
|
|
record = normalizeRecord(record);
|
|
if (!record.url) {
|
|
throw new ReadingListError("The item to be added must have a url");
|
|
}
|
|
if (!("addedOn" in record)) {
|
|
record.addedOn = Date.now();
|
|
}
|
|
if (!("addedBy" in record)) {
|
|
try {
|
|
record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
|
|
} catch (ex) {
|
|
record.addedBy = SyncUtils.getDefaultDeviceName();
|
|
}
|
|
}
|
|
if (!("syncStatus" in record)) {
|
|
record.syncStatus = SYNC_STATUS_NEW;
|
|
}
|
|
|
|
log.debug("Adding item with guid: ${guid}, url: ${url}", record);
|
|
yield this._store.addItem(record);
|
|
log.trace("Added item with guid: ${guid}, url: ${url}", record);
|
|
this._invalidateIterators();
|
|
let item = this._itemFromRecord(record);
|
|
this._callListeners("onItemAdded", item);
|
|
let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
|
|
mm.broadcastAsyncMessage("Reader:Added", item.toJSON());
|
|
return item;
|
|
}),
|
|
|
|
/**
|
|
* Updates the properties of an item that belongs to the list.
|
|
*
|
|
* The passed-in item may have as few or as many properties that you want to
|
|
* set; only the properties that are present are updated. The item must have
|
|
* a `url`, however.
|
|
*
|
|
* It's an error to call this for an item that doesn't belong to the list.
|
|
* The returned promise is rejected in that case.
|
|
*
|
|
* @param item The ReadingListItem to update.
|
|
* @return Promise<null> Resolved when the list is updated. Rejected with an
|
|
* Error on error.
|
|
*/
|
|
updateItem: Task.async(function* (item) {
|
|
if (item._deleted) {
|
|
throw new ReadingListDeletedError("The item to be updated has been deleted");
|
|
}
|
|
if (!item._record.url) {
|
|
throw new ReadingListError("The item to be updated must have a url");
|
|
}
|
|
this._ensureItemBelongsToList(item);
|
|
log.debug("Updating item with guid: ${guid}, url: ${url}", item._record);
|
|
yield this._store.updateItem(item._record);
|
|
log.trace("Finished updating item with guid: ${guid}, url: ${url}", item._record);
|
|
this._invalidateIterators();
|
|
this._callListeners("onItemUpdated", item);
|
|
}),
|
|
|
|
/**
|
|
* Deletes an item from the list. The item must have a `url`.
|
|
*
|
|
* It's an error to call this for an item that doesn't belong to the list.
|
|
* The returned promise is rejected in that case.
|
|
*
|
|
* @param item The ReadingListItem to delete.
|
|
* @return Promise<null> Resolved when the list is updated. Rejected with an
|
|
* Error on error.
|
|
*/
|
|
deleteItem: Task.async(function* (item) {
|
|
if (item._deleted) {
|
|
throw new ReadingListDeletedError("The item has already been deleted");
|
|
}
|
|
this._ensureItemBelongsToList(item);
|
|
|
|
log.debug("Deleting item with guid: ${guid}, url: ${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) {
|
|
log.debug("Item is new, truly deleting it", item._record);
|
|
yield this._store.deleteItemByURL(item.url);
|
|
}
|
|
else {
|
|
log.debug("Item has been synced, only marking it as deleted",
|
|
item._record);
|
|
// 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;
|
|
yield this._store.updateItemByGUID(newRecord);
|
|
}
|
|
|
|
log.trace("Finished deleting item with guid: ${guid}, url: ${url}", item._record);
|
|
item.list = null;
|
|
item._deleted = true;
|
|
// failing to remove the item from the map points at something bad!
|
|
if (!this._itemsByNormalizedURL.delete(item.url)) {
|
|
log.error("Failed to remove item from the map", item);
|
|
}
|
|
this._invalidateIterators();
|
|
let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
|
|
mm.broadcastAsyncMessage("Reader:Removed", item.toJSON());
|
|
this._callListeners("onItemDeleted", item);
|
|
}),
|
|
|
|
/**
|
|
* Finds the first item that matches the given options.
|
|
*
|
|
* @param optsList See Options Objects.
|
|
* @return The first matching item, or null if there are no matching items.
|
|
*/
|
|
item: Task.async(function* (...optsList) {
|
|
return (yield this.iterator(...optsList).items(1))[0] || null;
|
|
}),
|
|
|
|
/**
|
|
* Find any item that matches a given URL - either the item's URL, or its
|
|
* resolved URL.
|
|
*
|
|
* @param {String/nsIURI} uri - URI to match against. This will be normalized.
|
|
* @return The first matching item, or null if there are no matching items.
|
|
*/
|
|
itemForURL: Task.async(function* (uri) {
|
|
let url = normalizeURI(uri);
|
|
return (yield this.item({ url: url }, { resolvedURL: url }));
|
|
}),
|
|
|
|
/**
|
|
* Add to the ReadingList the page that is loaded in a given browser.
|
|
*
|
|
* @param {<xul:browser>} browser - Browser element for the document,
|
|
* used to get metadata about the article.
|
|
* @param {nsIURI/string} url - url to add to the reading list.
|
|
* @return {Promise} Promise that is fullfilled with the added item.
|
|
*/
|
|
addItemFromBrowser: Task.async(function* (browser, url) {
|
|
let metadata = yield this.getMetadataFromBrowser(browser);
|
|
let record = {
|
|
url: url,
|
|
title: metadata.title,
|
|
resolvedURL: metadata.url,
|
|
excerpt: metadata.description,
|
|
};
|
|
|
|
if (metadata.previews.length > 0) {
|
|
record.preview = metadata.previews[0];
|
|
}
|
|
|
|
return (yield this.addItem(record));
|
|
}),
|
|
|
|
/**
|
|
* Get page metadata from the content document in a given <xul:browser>.
|
|
* @see PageMetadata.jsm
|
|
*
|
|
* @param {<xul:browser>} browser - Browser element for the document.
|
|
* @returns {Promise} Promise that is fulfilled with an object describing the metadata.
|
|
*/
|
|
getMetadataFromBrowser(browser) {
|
|
let mm = browser.messageManager;
|
|
return new Promise(resolve => {
|
|
function handleResult(msg) {
|
|
mm.removeMessageListener("PageMetadata:PageDataResult", handleResult);
|
|
resolve(msg.json);
|
|
}
|
|
mm.addMessageListener("PageMetadata:PageDataResult", handleResult);
|
|
mm.sendAsyncMessage("PageMetadata:GetPageData");
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Adds a listener that will be notified when the list changes. Listeners
|
|
* are objects with the following optional methods:
|
|
*
|
|
* onItemAdded(item)
|
|
* onItemUpdated(item)
|
|
* onItemDeleted(item)
|
|
*
|
|
* @param listener A listener object.
|
|
*/
|
|
addListener(listener) {
|
|
this._listeners.add(listener);
|
|
},
|
|
|
|
/**
|
|
* Removes a listener from the list.
|
|
*
|
|
* @param listener A listener object.
|
|
*/
|
|
removeListener(listener) {
|
|
this._listeners.delete(listener);
|
|
},
|
|
|
|
/**
|
|
* Call this when you're done with the list. Don't use it afterward.
|
|
*/
|
|
destroy: Task.async(function* () {
|
|
yield this._store.destroy();
|
|
for (let itemWeakRef of this._itemsByNormalizedURL.values()) {
|
|
let item = itemWeakRef.get();
|
|
if (item) {
|
|
item.list = null;
|
|
}
|
|
}
|
|
this._itemsByNormalizedURL.clear();
|
|
}),
|
|
|
|
// The list's backing store.
|
|
_store: null,
|
|
|
|
// A Map mapping *normalized* URL strings to nsIWeakReferences that refer to
|
|
// ReadingListItems.
|
|
_itemsByNormalizedURL: null,
|
|
|
|
// A Set containing nsIWeakReferences that refer to valid iterators produced
|
|
// by the list.
|
|
_iterators: null,
|
|
|
|
// A Set containing listener objects.
|
|
_listeners: null,
|
|
|
|
/**
|
|
* Returns the ReadingListItem represented by the given record object. If
|
|
* the item doesn't exist yet, it's created first.
|
|
*
|
|
* @param record A simple object with *normalized* item record properties.
|
|
* @return The ReadingListItem.
|
|
*/
|
|
_itemFromRecord(record) {
|
|
if (!record.url) {
|
|
throw new Error("record must have a URL");
|
|
}
|
|
let itemWeakRef = this._itemsByNormalizedURL.get(record.url);
|
|
let item = itemWeakRef ? itemWeakRef.get() : null;
|
|
if (item) {
|
|
item._record = record;
|
|
}
|
|
else {
|
|
item = new ReadingListItem(record);
|
|
item.list = this;
|
|
this._itemsByNormalizedURL.set(record.url, Cu.getWeakReference(item));
|
|
}
|
|
return item;
|
|
},
|
|
|
|
/**
|
|
* Marks all the list's iterators as invalid, meaning it's not safe to use
|
|
* them anymore.
|
|
*/
|
|
_invalidateIterators() {
|
|
for (let iterWeakRef of this._iterators) {
|
|
let iter = iterWeakRef.get();
|
|
if (iter) {
|
|
iter.invalidate();
|
|
}
|
|
}
|
|
this._iterators.clear();
|
|
},
|
|
|
|
/**
|
|
* Calls a method on all listeners.
|
|
*
|
|
* @param methodName The name of the method to call.
|
|
* @param item This item will be passed to the listeners.
|
|
*/
|
|
_callListeners(methodName, item) {
|
|
for (let listener of this._listeners) {
|
|
if (methodName in listener) {
|
|
try {
|
|
listener[methodName](item);
|
|
}
|
|
catch (err) {
|
|
Cu.reportError(err);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_ensureItemBelongsToList(item) {
|
|
if (!item || !item._ensureBelongsToList) {
|
|
throw new ReadingListError("The item is not a ReadingListItem");
|
|
}
|
|
item._ensureBelongsToList();
|
|
},
|
|
};
|
|
|
|
|
|
let _unserializable = () => {}; // See comments in the ReadingListItem ctor.
|
|
|
|
/**
|
|
* An item in a reading list.
|
|
*
|
|
* Each item belongs to a list, and it's an error to use an item with a
|
|
* ReadingList that the item doesn't belong to.
|
|
*
|
|
* @param record A simple object with the properties of the item, as few or many
|
|
* as you want. This will be normalized.
|
|
*/
|
|
function ReadingListItem(record={}) {
|
|
this._record = record;
|
|
this._deleted = false;
|
|
|
|
// |this._unserializable| works around a problem when sending one of these
|
|
// items via a message manager. If |this.list| is set, the item can't be
|
|
// transferred directly, so .toJSON is implicitly called and the object
|
|
// returned via that is sent. However, once the item is deleted and |this.list|
|
|
// is null, the item *can* be directly serialized - so the message handler
|
|
// sees the "raw" object - ie, it sees "_record" etc.
|
|
// We work around this problem by *always* having an unserializable property
|
|
// on the object - this way the implicit .toJSON call is always made, even
|
|
// when |this.list| is null.
|
|
this._unserializable = _unserializable;
|
|
}
|
|
|
|
ReadingListItem.prototype = {
|
|
|
|
// Be careful when caching properties. If you cache a property that depends
|
|
// on a mutable _record property, then you need to recache your property after
|
|
// _record is set.
|
|
|
|
/**
|
|
* Item's unique ID.
|
|
* @type string
|
|
*/
|
|
get id() {
|
|
if (!this._id) {
|
|
this._id = hash(this.url);
|
|
}
|
|
return this._id;
|
|
},
|
|
|
|
/**
|
|
* The item's server-side GUID. This is set by the remote server and therefore is not
|
|
* guaranteed to be set for local items.
|
|
* @type string
|
|
*/
|
|
get guid() {
|
|
return this._record.guid || undefined;
|
|
},
|
|
|
|
/**
|
|
* The item's URL.
|
|
* @type string
|
|
*/
|
|
get url() {
|
|
return this._record.url || undefined;
|
|
},
|
|
|
|
/**
|
|
* The item's URL as an nsIURI.
|
|
* @type nsIURI
|
|
*/
|
|
get uri() {
|
|
if (!this._uri) {
|
|
this._uri = this._record.url ?
|
|
Services.io.newURI(this._record.url, "", null) :
|
|
undefined;
|
|
}
|
|
return this._uri;
|
|
},
|
|
|
|
/**
|
|
* The item's resolved URL.
|
|
* @type string
|
|
*/
|
|
get resolvedURL() {
|
|
return this._record.resolvedURL || undefined;
|
|
},
|
|
set resolvedURL(val) {
|
|
this._updateRecord({ resolvedURL: val });
|
|
},
|
|
|
|
/**
|
|
* The item's resolved URL as an nsIURI. The setter takes an nsIURI or a
|
|
* string spec.
|
|
* @type nsIURI
|
|
*/
|
|
get resolvedURI() {
|
|
return this._record.resolvedURL ?
|
|
Services.io.newURI(this._record.resolvedURL, "", null) :
|
|
undefined;
|
|
},
|
|
set resolvedURI(val) {
|
|
this._updateRecord({ resolvedURL: val });
|
|
},
|
|
|
|
/**
|
|
* The item's title.
|
|
* @type string
|
|
*/
|
|
get title() {
|
|
return this._record.title || undefined;
|
|
},
|
|
set title(val) {
|
|
this._updateRecord({ title: val });
|
|
},
|
|
|
|
/**
|
|
* The item's resolved title.
|
|
* @type string
|
|
*/
|
|
get resolvedTitle() {
|
|
return this._record.resolvedTitle || undefined;
|
|
},
|
|
set resolvedTitle(val) {
|
|
this._updateRecord({ resolvedTitle: val });
|
|
},
|
|
|
|
/**
|
|
* The item's excerpt.
|
|
* @type string
|
|
*/
|
|
get excerpt() {
|
|
return this._record.excerpt || undefined;
|
|
},
|
|
set excerpt(val) {
|
|
this._updateRecord({ excerpt: val });
|
|
},
|
|
|
|
/**
|
|
* The item's archived status.
|
|
* @type boolean
|
|
*/
|
|
get archived() {
|
|
return !!this._record.archived;
|
|
},
|
|
set archived(val) {
|
|
this._updateRecord({ archived: !!val });
|
|
},
|
|
|
|
/**
|
|
* Whether the item is a favorite.
|
|
* @type boolean
|
|
*/
|
|
get favorite() {
|
|
return !!this._record.favorite;
|
|
},
|
|
set favorite(val) {
|
|
this._updateRecord({ favorite: !!val });
|
|
},
|
|
|
|
/**
|
|
* Whether the item is an article.
|
|
* @type boolean
|
|
*/
|
|
get isArticle() {
|
|
return !!this._record.isArticle;
|
|
},
|
|
set isArticle(val) {
|
|
this._updateRecord({ isArticle: !!val });
|
|
},
|
|
|
|
/**
|
|
* The item's word count.
|
|
* @type integer
|
|
*/
|
|
get wordCount() {
|
|
return this._record.wordCount || undefined;
|
|
},
|
|
set wordCount(val) {
|
|
this._updateRecord({ wordCount: val });
|
|
},
|
|
|
|
/**
|
|
* Whether the item is unread.
|
|
* @type boolean
|
|
*/
|
|
get unread() {
|
|
return !!this._record.unread;
|
|
},
|
|
set unread(val) {
|
|
this._updateRecord({ unread: !!val });
|
|
},
|
|
|
|
/**
|
|
* The date the item was added.
|
|
* @type Date
|
|
*/
|
|
get addedOn() {
|
|
return this._record.addedOn ?
|
|
new Date(this._record.addedOn) :
|
|
undefined;
|
|
},
|
|
set addedOn(val) {
|
|
this._updateRecord({ addedOn: val.valueOf() });
|
|
},
|
|
|
|
/**
|
|
* The date the item was stored.
|
|
* @type Date
|
|
*/
|
|
get storedOn() {
|
|
return this._record.storedOn ?
|
|
new Date(this._record.storedOn) :
|
|
undefined;
|
|
},
|
|
set storedOn(val) {
|
|
this._updateRecord({ storedOn: val.valueOf() });
|
|
},
|
|
|
|
/**
|
|
* The GUID of the device that marked the item read.
|
|
* @type string
|
|
*/
|
|
get markedReadBy() {
|
|
return this._record.markedReadBy || undefined;
|
|
},
|
|
set markedReadBy(val) {
|
|
this._updateRecord({ markedReadBy: val });
|
|
},
|
|
|
|
/**
|
|
* The date the item marked read.
|
|
* @type Date
|
|
*/
|
|
get markedReadOn() {
|
|
return this._record.markedReadOn ?
|
|
new Date(this._record.markedReadOn) :
|
|
undefined;
|
|
},
|
|
set markedReadOn(val) {
|
|
this._updateRecord({ markedReadOn: val.valueOf() });
|
|
},
|
|
|
|
/**
|
|
* The item's read position.
|
|
* @param integer
|
|
*/
|
|
get readPosition() {
|
|
return this._record.readPosition || undefined;
|
|
},
|
|
set readPosition(val) {
|
|
this._updateRecord({ readPosition: val });
|
|
},
|
|
|
|
/**
|
|
* The URL to a preview image.
|
|
* @type string
|
|
*/
|
|
get preview() {
|
|
return this._record.preview || undefined;
|
|
},
|
|
|
|
/**
|
|
* Deletes the item from its list.
|
|
*
|
|
* @return Promise<null> Resolved when the list has been updated.
|
|
*/
|
|
delete: Task.async(function* () {
|
|
if (this._deleted) {
|
|
throw new ReadingListDeletedError("The item has already been deleted");
|
|
}
|
|
this._ensureBelongsToList();
|
|
yield this.list.deleteItem(this);
|
|
}),
|
|
|
|
toJSON() {
|
|
return this._record;
|
|
},
|
|
|
|
/**
|
|
* Do not use this at all unless you know what you're doing. Use the public
|
|
* getters and setters, above, instead.
|
|
*
|
|
* A simple object that contains the item's normalized data in the same format
|
|
* that the local store and server use. Records passed in by the consumer are
|
|
* 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;
|
|
},
|
|
set _record(val) {
|
|
this.__record = normalizeRecord(val);
|
|
},
|
|
|
|
/**
|
|
* Updates the item's record. This calls the _record setter, so it will throw
|
|
* an error if the partial record is not valid.
|
|
*
|
|
* @param partialRecord An object containing any of the record properties.
|
|
*/
|
|
_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];
|
|
}
|
|
this._record = record;
|
|
},
|
|
|
|
_ensureBelongsToList() {
|
|
if (!this.list) {
|
|
throw new ReadingListError("The item must belong to a list");
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* An object that enumerates over items in a list.
|
|
*
|
|
* You can enumerate items a chunk at a time by passing counts to forEach() and
|
|
* items(). An iterator remembers where it left off, so for example calling
|
|
* forEach() with a count of 10 will enumerate the first 10 items, and then
|
|
* calling it again with 10 will enumerate the next 10 items.
|
|
*
|
|
* It's possible for an iterator's list to be modified between calls to
|
|
* forEach() and items(). If that happens, the iterator is no longer safe to
|
|
* use, so it's invalidated. You can check whether an iterator is invalid by
|
|
* getting its `invalid` property. Attempting to use an invalid iterator will
|
|
* throw an error.
|
|
*
|
|
* @param list The ReadingList to enumerate.
|
|
* @param optsList A variable number of options objects that control the items
|
|
* that are matched. See Options Objects.
|
|
*/
|
|
function ReadingListItemIterator(list, ...optsList) {
|
|
this.list = list;
|
|
this.index = 0;
|
|
this.optsList = optsList;
|
|
}
|
|
|
|
ReadingListItemIterator.prototype = {
|
|
|
|
/**
|
|
* True if it's not safe to use the iterator. Attempting to use an invalid
|
|
* iterator will throw an error.
|
|
*/
|
|
invalid: false,
|
|
|
|
/**
|
|
* Enumerates the items in the iterator starting at its current index. The
|
|
* iterator is advanced by the number of items enumerated.
|
|
*
|
|
* @param callback Called for each item in the enumeration. It's passed a
|
|
* single object, a ReadingListItem. It may return a promise; if so,
|
|
* the callback will not be called for the next item until the promise
|
|
* is resolved.
|
|
* @param count The maximum number of items to enumerate. Pass -1 to
|
|
* enumerate them all.
|
|
* @return Promise<null> Resolved when the enumeration completes *and* the
|
|
* last promise returned by the callback is resolved.
|
|
*/
|
|
forEach: Task.async(function* (callback, count=-1) {
|
|
this._ensureValid();
|
|
let optsList = clone(this.optsList);
|
|
optsList.push({
|
|
offset: this.index,
|
|
limit: count,
|
|
});
|
|
yield this.list.forEachItem(item => {
|
|
this.index++;
|
|
return callback(item);
|
|
}, ...optsList);
|
|
}),
|
|
|
|
/**
|
|
* Gets an array of items in the iterator starting at its current index. The
|
|
* iterator is advanced by the number of items fetched.
|
|
*
|
|
* @param count The maximum number of items to get.
|
|
* @return Promise<array> The fetched items.
|
|
*/
|
|
items: Task.async(function* (count) {
|
|
this._ensureValid();
|
|
let optsList = clone(this.optsList);
|
|
optsList.push({
|
|
offset: this.index,
|
|
limit: count,
|
|
});
|
|
let items = [];
|
|
yield this.list.forEachItem(item => items.push(item), ...optsList);
|
|
this.index += items.length;
|
|
return items;
|
|
}),
|
|
|
|
/**
|
|
* Invalidates the iterator. You probably don't want to call this unless
|
|
* you're a ReadingList.
|
|
*/
|
|
invalidate() {
|
|
this.invalid = true;
|
|
},
|
|
|
|
_ensureValid() {
|
|
if (this.invalid) {
|
|
throw new ReadingListError("The iterator has been invalidated");
|
|
}
|
|
},
|
|
};
|
|
|
|
|
|
/**
|
|
* Normalizes the properties of a record object, which represents a
|
|
* ReadingListItem. Throws an error if the record contains properties that
|
|
* aren't in ITEM_RECORD_PROPERTIES.
|
|
*
|
|
* @param record A non-normalized record object.
|
|
* @return The new normalized record.
|
|
*/
|
|
function normalizeRecord(nonNormalizedRecord) {
|
|
let record = {};
|
|
for (let prop in nonNormalizedRecord) {
|
|
if (ITEM_RECORD_PROPERTIES.indexOf(prop) < 0) {
|
|
throw new ReadingListError("Unrecognized item property: " + prop);
|
|
}
|
|
switch (prop) {
|
|
case "url":
|
|
case "resolvedURL":
|
|
if (nonNormalizedRecord[prop]) {
|
|
record[prop] = normalizeURI(nonNormalizedRecord[prop]);
|
|
}
|
|
else {
|
|
record[prop] = nonNormalizedRecord[prop];
|
|
}
|
|
break;
|
|
default:
|
|
record[prop] = nonNormalizedRecord[prop];
|
|
break;
|
|
}
|
|
}
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* Normalize a URI, stripping away extraneous parts we don't want to store
|
|
* or compare against.
|
|
*
|
|
* @param {nsIURI/String} uri - URI to normalize.
|
|
* @returns {String} String spec of a cloned and normalized version of the
|
|
* input URI.
|
|
*/
|
|
function normalizeURI(uri) {
|
|
if (typeof uri == "string") {
|
|
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.spec;
|
|
};
|
|
|
|
function hash(str) {
|
|
let hasher = Cc["@mozilla.org/security/hash;1"].
|
|
createInstance(Ci.nsICryptoHash);
|
|
hasher.init(Ci.nsICryptoHash.MD5);
|
|
let stream = Cc["@mozilla.org/io/string-input-stream;1"].
|
|
createInstance(Ci.nsIStringInputStream);
|
|
stream.data = str;
|
|
hasher.updateFromStream(stream, -1);
|
|
let binaryStr = hasher.finish(false);
|
|
let hexStr =
|
|
[("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in binaryStr)].
|
|
join("");
|
|
return hexStr;
|
|
}
|
|
|
|
function clone(obj) {
|
|
return Cu.cloneInto(obj, {}, { cloneFunctions: false });
|
|
}
|
|
|
|
Object.defineProperty(this, "ReadingList", {
|
|
get() {
|
|
if (!this._singleton) {
|
|
let store = new SQLiteStore("reading-list.sqlite");
|
|
this._singleton = new ReadingListImpl(store);
|
|
}
|
|
return this._singleton;
|
|
},
|
|
});
|