/* 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 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 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 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 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 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 {} 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 . * @see PageMetadata.jsm * * @param {} 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 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 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 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; }, });