mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-24 13:21:05 +00:00
Bug 1184005 - Remove readinglist. r=MattN,jaws,adw
This commit is contained in:
parent
36fcd19a2d
commit
4cc97da765
@ -11,7 +11,6 @@ const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [
|
||||
'menu_socialSidebar',
|
||||
'menu_historySidebar',
|
||||
'menu_bookmarksSidebar',
|
||||
'menu_readingListSidebar'
|
||||
];
|
||||
|
||||
function isSidebarShowing(window) {
|
||||
|
@ -17,7 +17,6 @@ const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [
|
||||
'menu_socialSidebar',
|
||||
'menu_historySidebar',
|
||||
'menu_bookmarksSidebar',
|
||||
'menu_readingListSidebar'
|
||||
];
|
||||
|
||||
function isSidebarShowing(window) {
|
||||
|
@ -1927,11 +1927,6 @@ pref("dom.ipc.reportProcessHangs", false);
|
||||
pref("dom.ipc.reportProcessHangs", true);
|
||||
#endif
|
||||
|
||||
pref("browser.readinglist.enabled", false);
|
||||
pref("browser.readinglist.sidebarEverOpened", false);
|
||||
pref("readinglist.scheduler.enabled", false);
|
||||
pref("readinglist.server", "https://readinglist.services.mozilla.com/v1");
|
||||
|
||||
pref("browser.reader.detectedFirstArticle", false);
|
||||
// Don't limit how many nodes we care about on desktop:
|
||||
pref("reader.parse-node-limit", 0);
|
||||
|
@ -210,10 +210,6 @@
|
||||
key="key_gotoHistory"
|
||||
observes="viewHistorySidebar"
|
||||
label="&historyButton.label;"/>
|
||||
<menuitem id="menu_readingListSidebar"
|
||||
key="key_readingListSidebar"
|
||||
observes="readingListSidebar"
|
||||
label="&readingList.label;"/>
|
||||
|
||||
<!-- Service providers with sidebars are inserted between these two menuseperators -->
|
||||
<menuseparator hidden="true"/>
|
||||
@ -443,30 +439,6 @@
|
||||
onpopupshowing="if (!this.parentNode._placesView)
|
||||
new PlacesMenu(event, 'place:folder=TOOLBAR');"/>
|
||||
</menu>
|
||||
#ifndef XP_MACOSX
|
||||
# Disabled on Mac because we can't fill native menupopups asynchronously
|
||||
<menuseparator id="menu_readingListSeparator">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</menuseparator>
|
||||
<menu id="menu_readingList"
|
||||
class="menu-iconic bookmark-item"
|
||||
label="&readingList.label;"
|
||||
container="true">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
<menupopup id="readingListPopup"
|
||||
#ifndef XP_MACOSX
|
||||
placespopup="true"
|
||||
#endif
|
||||
onpopupshowing="ReadingListUI.onReadingListPopupShowing(this);">
|
||||
<menuseparator id="viewReadingListSidebarSeparator"/>
|
||||
<menuitem id="viewReadingListSidebar" class="subviewbutton"
|
||||
oncommand="SidebarUI.toggle('readingListSidebar');"
|
||||
label="&readingList.showSidebar.label;">
|
||||
<observes element="readingListSidebar" attribute="checked"/>
|
||||
</menuitem>
|
||||
</menupopup>
|
||||
</menu>
|
||||
#endif
|
||||
<menuseparator id="bookmarksMenuItemsSeparator"/>
|
||||
<!-- Bookmarks menu items -->
|
||||
<menuseparator builder="end"
|
||||
|
@ -1,376 +0,0 @@
|
||||
/*
|
||||
# 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/.
|
||||
*/
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
|
||||
const READINGLIST_COMMAND_ID = "readingListSidebar";
|
||||
|
||||
let ReadingListUI = {
|
||||
/**
|
||||
* Frame-script messages we want to listen to.
|
||||
* @type {[string]}
|
||||
*/
|
||||
MESSAGES: [
|
||||
"ReadingList:GetVisibility",
|
||||
"ReadingList:ToggleVisibility",
|
||||
"ReadingList:ShowIntro",
|
||||
],
|
||||
|
||||
/**
|
||||
* Add-to-ReadingList toolbar button in the URLbar.
|
||||
* @type {Element}
|
||||
*/
|
||||
toolbarButton: null,
|
||||
|
||||
/**
|
||||
* Whether this object is currently registered as a listener with ReadingList.
|
||||
* Used to avoid inadvertantly loading the ReadLingList.jsm module on startup.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
listenerRegistered: false,
|
||||
|
||||
/**
|
||||
* Initialize the ReadingList UI.
|
||||
*/
|
||||
init() {
|
||||
this.toolbarButton = document.getElementById("readinglist-addremove-button");
|
||||
|
||||
Preferences.observe("browser.readinglist.enabled", this.updateUI, this);
|
||||
|
||||
const mm = window.messageManager;
|
||||
for (let msg of this.MESSAGES) {
|
||||
mm.addMessageListener(msg, this);
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
* Un-initialize the ReadingList UI.
|
||||
*/
|
||||
uninit() {
|
||||
Preferences.ignore("browser.readinglist.enabled", this.updateUI, this);
|
||||
|
||||
const mm = window.messageManager;
|
||||
for (let msg of this.MESSAGES) {
|
||||
mm.removeMessageListener(msg, this);
|
||||
}
|
||||
|
||||
if (this.listenerRegistered) {
|
||||
ReadingList.removeListener(this);
|
||||
this.listenerRegistered = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the ReadingList feature is enabled or not.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get enabled() {
|
||||
return Preferences.get("browser.readinglist.enabled", false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the ReadingList sidebar is currently open or not.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isSidebarOpen() {
|
||||
return SidebarUI.isOpen && SidebarUI.currentID == READINGLIST_COMMAND_ID;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the UI status, ensuring the UI is shown or hidden depending on
|
||||
* whether the feature is enabled or not.
|
||||
*/
|
||||
updateUI() {
|
||||
let enabled = this.enabled;
|
||||
if (enabled) {
|
||||
// This is a no-op if we're already registered.
|
||||
ReadingList.addListener(this);
|
||||
this.listenerRegistered = true;
|
||||
} else {
|
||||
if (this.listenerRegistered) {
|
||||
// This is safe to call if we're not currently registered, but we don't
|
||||
// want to forcibly load the normally lazy-loaded module on startup.
|
||||
ReadingList.removeListener(this);
|
||||
this.listenerRegistered = false;
|
||||
}
|
||||
|
||||
this.hideSidebar();
|
||||
}
|
||||
|
||||
document.getElementById(READINGLIST_COMMAND_ID).setAttribute("hidden", !enabled);
|
||||
document.getElementById(READINGLIST_COMMAND_ID).setAttribute("disabled", !enabled);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the ReadingList sidebar.
|
||||
* @return {Promise}
|
||||
*/
|
||||
showSidebar() {
|
||||
if (this.enabled) {
|
||||
return SidebarUI.show(READINGLIST_COMMAND_ID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the ReadingList sidebar, if it is currently shown.
|
||||
*/
|
||||
hideSidebar() {
|
||||
if (this.isSidebarOpen) {
|
||||
SidebarUI.hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-refresh the ReadingList bookmarks submenu when it opens.
|
||||
*
|
||||
* @param {Element} target - Menu element opening.
|
||||
*/
|
||||
onReadingListPopupShowing: Task.async(function* (target) {
|
||||
if (target.id == "BMB_readingListPopup") {
|
||||
// Setting this class in the .xul file messes with the way
|
||||
// browser-places.js inserts bookmarks in the menu.
|
||||
document.getElementById("BMB_viewReadingListSidebar")
|
||||
.classList.add("panel-subview-footer");
|
||||
}
|
||||
|
||||
while (!target.firstChild.id)
|
||||
target.firstChild.remove();
|
||||
|
||||
let classList = "menuitem-iconic bookmark-item menuitem-with-favicon";
|
||||
let insertPoint = target.firstChild;
|
||||
if (insertPoint.classList.contains("subviewbutton"))
|
||||
classList += " subviewbutton";
|
||||
|
||||
let hasItems = false;
|
||||
yield ReadingList.forEachItem(item => {
|
||||
hasItems = true;
|
||||
|
||||
let menuitem = document.createElement("menuitem");
|
||||
menuitem.setAttribute("label", item.title || item.url);
|
||||
menuitem.setAttribute("class", classList);
|
||||
|
||||
let node = menuitem._placesNode = {
|
||||
// Passing the PlacesUtils.nodeIsURI check is required for the
|
||||
// onCommand handler to load our URI.
|
||||
type: Ci.nsINavHistoryResultNode.RESULT_TYPE_URI,
|
||||
|
||||
// makes PlacesUIUtils.canUserRemove return false.
|
||||
// The context menu is broken without this.
|
||||
parent: {type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER},
|
||||
|
||||
// A -1 id makes this item a non-bookmark, which avoids calling
|
||||
// PlacesUtils.annotations.itemHasAnnotation to check if the
|
||||
// bookmark should be opened in the sidebar (this call fails for
|
||||
// readinglist item, and breaks loading our URI).
|
||||
itemId: -1,
|
||||
|
||||
// Used by the tooltip and onCommand handlers.
|
||||
uri: item.url,
|
||||
|
||||
// Used by the tooltip.
|
||||
title: item.title
|
||||
};
|
||||
|
||||
Favicons.getFaviconURLForPage(item.uri, uri => {
|
||||
if (uri) {
|
||||
menuitem.setAttribute("image",
|
||||
Favicons.getFaviconLinkForIcon(uri).spec);
|
||||
}
|
||||
});
|
||||
|
||||
target.insertBefore(menuitem, insertPoint);
|
||||
}, {sort: "addedOn", descending: true});
|
||||
|
||||
if (!hasItems) {
|
||||
let menuitem = document.createElement("menuitem");
|
||||
let bundle =
|
||||
Services.strings.createBundle("chrome://browser/locale/places/places.properties");
|
||||
menuitem.setAttribute("label", bundle.GetStringFromName("bookmarksMenuEmptyFolder"));
|
||||
menuitem.setAttribute("class", "bookmark-item");
|
||||
menuitem.setAttribute("disabled", true);
|
||||
target.insertBefore(menuitem, insertPoint);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Hide the ReadingList sidebar, if it is currently shown.
|
||||
*/
|
||||
toggleSidebar() {
|
||||
if (this.enabled) {
|
||||
SidebarUI.toggle(READINGLIST_COMMAND_ID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Respond to messages.
|
||||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "ReadingList:GetVisibility": {
|
||||
if (message.target.messageManager) {
|
||||
message.target.messageManager.sendAsyncMessage("ReadingList:VisibilityStatus",
|
||||
{ isOpen: this.isSidebarOpen });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ReadingList:ToggleVisibility": {
|
||||
this.toggleSidebar();
|
||||
break;
|
||||
}
|
||||
|
||||
case "ReadingList:ShowIntro": {
|
||||
if (this.enabled && !Preferences.get("browser.readinglist.introShown", false)) {
|
||||
Preferences.set("browser.readinglist.introShown", true);
|
||||
this.showSidebar();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles toolbar button styling based on page proxy state changes.
|
||||
*
|
||||
* @see SetPageProxyState()
|
||||
*
|
||||
* @param {string} state - New state. Either "valid" or "invalid".
|
||||
*/
|
||||
onPageProxyStateChanged: Task.async(function* (state) {
|
||||
if (!this.toolbarButton) {
|
||||
// nothing to do if we have no button.
|
||||
return;
|
||||
}
|
||||
|
||||
let uri;
|
||||
if (this.enabled && state == "valid") {
|
||||
uri = gBrowser.currentURI;
|
||||
if (uri.schemeIs("about"))
|
||||
uri = ReaderMode.getOriginalUrl(uri.spec);
|
||||
else if (!uri.schemeIs("http") && !uri.schemeIs("https"))
|
||||
uri = null;
|
||||
}
|
||||
|
||||
let msg = {topic: "UpdateActiveItem", url: null};
|
||||
if (!uri) {
|
||||
this.toolbarButton.setAttribute("hidden", true);
|
||||
if (this.isSidebarOpen)
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
let isInList = yield ReadingList.hasItemForURL(uri);
|
||||
|
||||
if (window.closed) {
|
||||
// Skip updating the UI if the window was closed since our hasItemForURL call.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSidebarOpen) {
|
||||
if (isInList)
|
||||
msg.url = typeof uri == "string" ? uri : uri.spec;
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
}
|
||||
this.setToolbarButtonState(isInList);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set the state of the ReadingList toolbar button in the urlbar.
|
||||
* If the current tab's page is in the ReadingList (active), sets the button
|
||||
* to allow removing the page. Otherwise, sets the button to allow adding the
|
||||
* page (not active).
|
||||
*
|
||||
* @param {boolean} active - True if the button should be active (page is
|
||||
* already in the list).
|
||||
*/
|
||||
setToolbarButtonState(active) {
|
||||
this.toolbarButton.setAttribute("already-added", active);
|
||||
|
||||
let type = (active ? "remove" : "add");
|
||||
let tooltip = gNavigatorBundle.getString(`readingList.urlbar.${type}`);
|
||||
this.toolbarButton.setAttribute("tooltiptext", tooltip);
|
||||
|
||||
this.toolbarButton.removeAttribute("hidden");
|
||||
},
|
||||
|
||||
buttonClick(event) {
|
||||
if (event.button != 0) {
|
||||
return;
|
||||
}
|
||||
this.togglePageByBrowser(gBrowser.selectedBrowser);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle a page (from a browser) in the ReadingList, adding if it's not already added, or
|
||||
* removing otherwise.
|
||||
*
|
||||
* @param {<xul:browser>} browser - Browser with page to toggle.
|
||||
* @returns {Promise} Promise resolved when operation has completed.
|
||||
*/
|
||||
togglePageByBrowser: Task.async(function* (browser) {
|
||||
let uri = browser.currentURI;
|
||||
if (uri.spec.startsWith("about:reader?"))
|
||||
uri = ReaderMode.getOriginalUrl(uri.spec);
|
||||
if (!uri)
|
||||
return;
|
||||
|
||||
let item = yield ReadingList.itemForURL(uri);
|
||||
if (item) {
|
||||
yield item.delete();
|
||||
} else {
|
||||
yield ReadingList.addItemFromBrowser(browser, uri);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Checks if a given item matches the current tab in this window.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item to check
|
||||
* @returns True if match, false otherwise.
|
||||
*/
|
||||
isItemForCurrentBrowser(item) {
|
||||
let currentURL = gBrowser.currentURI.spec;
|
||||
if (currentURL.startsWith("about:reader?"))
|
||||
currentURL = ReaderMode.getOriginalUrl(currentURL);
|
||||
|
||||
if (item.url == currentURL || item.resolvedURL == currentURL) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* ReadingList event handler for when an item is added.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item added.
|
||||
*/
|
||||
onItemAdded(item) {
|
||||
if (!Services.prefs.getBoolPref("browser.readinglist.sidebarEverOpened")) {
|
||||
SidebarUI.show("readingListSidebar");
|
||||
}
|
||||
if (this.isItemForCurrentBrowser(item)) {
|
||||
this.setToolbarButtonState(true);
|
||||
if (this.isSidebarOpen) {
|
||||
let msg = {topic: "UpdateActiveItem", url: item.url};
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ReadingList event handler for when an item is deleted.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item deleted.
|
||||
*/
|
||||
onItemDeleted(item) {
|
||||
if (this.isItemForCurrentBrowser(item)) {
|
||||
this.setToolbarButtonState(false);
|
||||
}
|
||||
},
|
||||
};
|
@ -146,11 +146,6 @@
|
||||
sidebarurl="chrome://browser/content/history/history-panel.xul"
|
||||
oncommand="SidebarUI.toggle('viewHistorySidebar');"/>
|
||||
|
||||
<broadcaster id="readingListSidebar" hidden="true" autoCheck="false" disabled="true"
|
||||
sidebartitle="&readingList.label;" type="checkbox" group="sidebar"
|
||||
sidebarurl="chrome://browser/content/readinglist/sidebar.xhtml"
|
||||
oncommand="SidebarUI.toggle('readingListSidebar');"/>
|
||||
|
||||
<broadcaster id="viewWebPanelsSidebar" autoCheck="false"
|
||||
type="checkbox" group="sidebar" sidebarurl="chrome://browser/content/web-panels.xul"
|
||||
oncommand="SidebarUI.toggle('viewWebPanelsSidebar');"/>
|
||||
@ -421,11 +416,6 @@
|
||||
#endif
|
||||
command="viewHistorySidebar"/>
|
||||
|
||||
<key id="key_readingListSidebar"
|
||||
key="&readingList.sidebar.commandKey;"
|
||||
modifiers="accel,alt"
|
||||
command="readingListSidebar"/>
|
||||
|
||||
<key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;" command="cmd_fullZoomReduce" modifiers="accel"/>
|
||||
<key key="&fullZoomReduceCmd.commandkey2;" command="cmd_fullZoomReduce" modifiers="accel"/>
|
||||
<key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;" command="cmd_fullZoomEnlarge" modifiers="accel"/>
|
||||
|
@ -11,9 +11,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "CloudSync",
|
||||
let CloudSync = null;
|
||||
#endif
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingListScheduler",
|
||||
"resource:///modules/readinglist/Scheduler.jsm");
|
||||
|
||||
// gSyncUI handles updating the tools menu and displaying notifications.
|
||||
let gSyncUI = {
|
||||
_obs: ["weave:service:sync:start",
|
||||
@ -31,10 +28,6 @@ let gSyncUI = {
|
||||
"weave:ui:sync:error",
|
||||
"weave:ui:sync:finish",
|
||||
"weave:ui:clear-error",
|
||||
|
||||
"readinglist:sync:start",
|
||||
"readinglist:sync:finish",
|
||||
"readinglist:sync:error",
|
||||
],
|
||||
|
||||
_unloaded: false,
|
||||
@ -115,17 +108,13 @@ let gSyncUI = {
|
||||
// authManager, so this should always return a value directly.
|
||||
// This only applies to fxAccounts-based Sync.
|
||||
if (Weave.Status._authManager._signedInUser !== undefined) {
|
||||
// So we are using Firefox accounts - in this world, checking Sync isn't
|
||||
// enough as reading list may be configured but not Sync.
|
||||
// We consider ourselves setup if we have a verified user.
|
||||
// XXX - later we should consider checking preferences to ensure at least
|
||||
// one engine is enabled?
|
||||
// If we have a signed in user already, and that user is not verified,
|
||||
// revert to the "needs setup" state.
|
||||
return !Weave.Status._authManager._signedInUser ||
|
||||
!Weave.Status._authManager._signedInUser.verified;
|
||||
}
|
||||
|
||||
// So we are using legacy sync, and reading-list isn't supported for such
|
||||
// users, so check sync itself.
|
||||
// We are using legacy sync - check that.
|
||||
let firstSync = "";
|
||||
try {
|
||||
firstSync = Services.prefs.getCharPref("services.sync.firstSync");
|
||||
@ -136,10 +125,9 @@ let gSyncUI = {
|
||||
},
|
||||
|
||||
_loginFailed: function () {
|
||||
this.log.debug("_loginFailed has sync state=${sync}, readinglist state=${rl}",
|
||||
{ sync: Weave.Status.login, rl: ReadingListScheduler.state});
|
||||
return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED ||
|
||||
ReadingListScheduler.state == ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
this.log.debug("_loginFailed has sync state=${sync}",
|
||||
{ sync: Weave.Status.login});
|
||||
return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED;
|
||||
},
|
||||
|
||||
updateUI: function SUI_updateUI() {
|
||||
@ -235,8 +223,6 @@ let gSyncUI = {
|
||||
|
||||
onLoginError: function SUI_onLoginError() {
|
||||
this.log.debug("onLoginError: login=${login}, sync=${sync}", Weave.Status);
|
||||
// Note: This is used for *both* Sync and ReadingList login errors.
|
||||
// if login fails, any other notifications are essentially moot
|
||||
Weave.Notifications.removeAll();
|
||||
|
||||
// if we haven't set up the client, don't show errors
|
||||
@ -260,12 +246,10 @@ let gSyncUI = {
|
||||
},
|
||||
|
||||
showLoginError() {
|
||||
// Note: This is used for *both* Sync and ReadingList login errors.
|
||||
let title = this._stringBundle.GetStringFromName("error.login.title");
|
||||
|
||||
let description;
|
||||
if (Weave.Status.sync == Weave.PROLONGED_SYNC_FAILURE ||
|
||||
this.isProlongedReadingListError()) {
|
||||
if (Weave.Status.sync == Weave.PROLONGED_SYNC_FAILURE) {
|
||||
this.log.debug("showLoginError has a prolonged login error");
|
||||
// Convert to days
|
||||
let lastSync =
|
||||
@ -333,7 +317,6 @@ let gSyncUI = {
|
||||
}
|
||||
|
||||
Services.obs.notifyObservers(null, "cloudsync:user-sync", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:user-sync", null);
|
||||
},
|
||||
|
||||
handleToolbarButton: function SUI_handleStatusbarButton() {
|
||||
@ -432,14 +415,6 @@ let gSyncUI = {
|
||||
lastSync = new Date(Services.prefs.getCharPref("services.sync.lastSync"));
|
||||
}
|
||||
catch (e) { };
|
||||
// and reading-list time - we want whatever one is the most recent.
|
||||
try {
|
||||
let lastRLSync = new Date(Services.prefs.getCharPref("readinglist.scheduler.lastSync"));
|
||||
if (!lastSync || lastRLSync > lastSync) {
|
||||
lastSync = lastRLSync;
|
||||
}
|
||||
}
|
||||
catch (e) { };
|
||||
if (!lastSync || this._needsSetup()) {
|
||||
if (syncButton) {
|
||||
syncButton.removeAttribute("tooltiptext");
|
||||
@ -475,75 +450,6 @@ let gSyncUI = {
|
||||
this.clearError(title);
|
||||
},
|
||||
|
||||
// Return true if the reading-list is in a "prolonged" error state. That
|
||||
// engine doesn't impose what that means, so calculate it here. For
|
||||
// consistency, we just use the sync prefs.
|
||||
isProlongedReadingListError() {
|
||||
// If the readinglist scheduler is disabled we don't treat it as prolonged.
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = Services.prefs.getBoolPref("readinglist.scheduler.enabled");
|
||||
} catch (_) {}
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
let lastSync, threshold, prolonged;
|
||||
try {
|
||||
lastSync = new Date(Services.prefs.getCharPref("readinglist.scheduler.lastSync"));
|
||||
threshold = new Date(Date.now() - Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout") * 1000);
|
||||
prolonged = lastSync <= threshold;
|
||||
} catch (ex) {
|
||||
// no pref, assume not prolonged.
|
||||
prolonged = false;
|
||||
}
|
||||
this.log.debug("isProlongedReadingListError has last successful sync at ${lastSync}, threshold is ${threshold}, prolonged=${prolonged}",
|
||||
{lastSync, threshold, prolonged});
|
||||
return prolonged;
|
||||
},
|
||||
|
||||
onRLSyncError() {
|
||||
// Like onSyncError, but from the reading-list engine.
|
||||
// However, the current UX around Sync is that error notifications should
|
||||
// generally *not* be seen as they typically aren't actionable - so only
|
||||
// authentication errors (which require user action) and "prolonged" errors
|
||||
// (which technically aren't actionable, but user really should know anyway)
|
||||
// are shown.
|
||||
this.log.debug("onRLSyncError with readingList state", ReadingListScheduler.state);
|
||||
if (ReadingListScheduler.state == ReadingListScheduler.STATE_ERROR_AUTHENTICATION) {
|
||||
this.onLoginError();
|
||||
return;
|
||||
}
|
||||
// If it's not prolonged there's nothing to do.
|
||||
if (!this.isProlongedReadingListError()) {
|
||||
this.log.debug("onRLSyncError has a non-authentication, non-prolonged error, so not showing any error UI");
|
||||
return;
|
||||
}
|
||||
// So it's a prolonged error.
|
||||
// Unfortunate duplication from below...
|
||||
this.log.debug("onRLSyncError has a prolonged error");
|
||||
let title = this._stringBundle.GetStringFromName("error.sync.title");
|
||||
// XXX - this is somewhat wrong - we are reporting the threshold we consider
|
||||
// to be prolonged, not how long it actually has been. (ie, lastSync below
|
||||
// is effectively constant) - bit it too is copied from below.
|
||||
let lastSync =
|
||||
Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout") / 86400;
|
||||
let description =
|
||||
this._stringBundle.formatStringFromName("error.sync.prolonged_failure", [lastSync], 1);
|
||||
let priority = Weave.Notifications.PRIORITY_INFO;
|
||||
let buttons = [
|
||||
new Weave.NotificationButton(
|
||||
this._stringBundle.GetStringFromName("error.sync.tryAgainButton.label"),
|
||||
this._stringBundle.GetStringFromName("error.sync.tryAgainButton.accesskey"),
|
||||
function() { gSyncUI.doSync(); return true; }
|
||||
),
|
||||
];
|
||||
let notification =
|
||||
new Weave.Notification(title, description, null, priority, buttons);
|
||||
Weave.Notifications.replaceTitle(notification);
|
||||
|
||||
this.updateUI();
|
||||
},
|
||||
|
||||
onSyncError: function SUI_onSyncError() {
|
||||
this.log.debug("onSyncError: login=${login}, sync=${sync}", Weave.Status);
|
||||
let title = this._stringBundle.GetStringFromName("error.sync.title");
|
||||
@ -637,21 +543,17 @@ let gSyncUI = {
|
||||
switch (topic) {
|
||||
case "weave:service:sync:start":
|
||||
case "weave:service:login:start":
|
||||
case "readinglist:sync:start":
|
||||
this.onActivityStart();
|
||||
break;
|
||||
case "weave:service:sync:finish":
|
||||
case "weave:service:sync:error":
|
||||
case "weave:service:login:finish":
|
||||
case "weave:service:login:error":
|
||||
case "readinglist:sync:finish":
|
||||
case "readinglist:sync:error":
|
||||
this.onActivityStop();
|
||||
break;
|
||||
}
|
||||
// Now non-activity state (eg, enabled, errors, etc)
|
||||
// Note that sync uses the ":ui:" notifications for errors because sync.
|
||||
// ReadingList has no such concept (yet?; hopefully the :error is enough!)
|
||||
switch (topic) {
|
||||
case "weave:ui:sync:finish":
|
||||
this.onSyncFinish();
|
||||
@ -689,13 +591,6 @@ let gSyncUI = {
|
||||
case "weave:ui:clear-error":
|
||||
this.clearError();
|
||||
break;
|
||||
|
||||
case "readinglist:sync:error":
|
||||
this.onRLSyncError();
|
||||
break;
|
||||
case "readinglist:sync:finish":
|
||||
this.clearError();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -274,7 +274,6 @@ let gInitialPages = [
|
||||
#include browser-loop.js
|
||||
#include browser-places.js
|
||||
#include browser-plugins.js
|
||||
#include browser-readinglist.js
|
||||
#include browser-safebrowsing.js
|
||||
#include browser-sidebar.js
|
||||
#include browser-social.js
|
||||
@ -1267,8 +1266,6 @@ var gBrowserInit = {
|
||||
#ifdef E10S_TESTING_ONLY
|
||||
gRemoteTabsUI.init();
|
||||
#endif
|
||||
ReadingListUI.init();
|
||||
|
||||
// Initialize the full zoom setting.
|
||||
// We do this before the session restore service gets initialized so we can
|
||||
// apply full zoom settings to tabs restored by the session restore service.
|
||||
@ -1549,8 +1546,6 @@ var gBrowserInit = {
|
||||
|
||||
gMenuButtonUpdateBadge.uninit();
|
||||
|
||||
ReadingListUI.uninit();
|
||||
|
||||
SidebarUI.uninit();
|
||||
|
||||
// Now either cancel delayedStartup, or clean up the services initialized from
|
||||
@ -2549,8 +2544,6 @@ function UpdatePageProxyState()
|
||||
function SetPageProxyState(aState)
|
||||
{
|
||||
BookmarkingUI.onPageProxyStateChanged(aState);
|
||||
ReadingListUI.onPageProxyStateChanged(aState);
|
||||
|
||||
if (!gURLBar)
|
||||
return;
|
||||
|
||||
|
@ -784,10 +784,6 @@
|
||||
hidden="true"
|
||||
tooltiptext="&pageReportIcon.tooltip;"
|
||||
onclick="gPopupBlockerObserver.onReportButtonClick(event);"/>
|
||||
<image id="readinglist-addremove-button"
|
||||
class="urlbar-icon"
|
||||
hidden="true"
|
||||
onclick="ReadingListUI.buttonClick(event);"/>
|
||||
<image id="reader-mode-button"
|
||||
class="urlbar-icon"
|
||||
hidden="true"
|
||||
@ -918,22 +914,6 @@
|
||||
new PlacesMenu(event, 'place:folder=UNFILED_BOOKMARKS',
|
||||
PlacesUIUtils.getViewForNode(this.parentNode.parentNode).options);"/>
|
||||
</menu>
|
||||
<menuseparator>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</menuseparator>
|
||||
<menu id="BMB_readingList"
|
||||
class="menu-iconic bookmark-item subviewbutton"
|
||||
label="&readingList.label;"
|
||||
container="true">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
<menupopup id="BMB_readingListPopup"
|
||||
placespopup="true"
|
||||
onpopupshowing="ReadingListUI.onReadingListPopupShowing(this);">
|
||||
<menuitem id="BMB_viewReadingListSidebar" class="subviewbutton"
|
||||
oncommand="SidebarUI.show('readingListSidebar');"
|
||||
label="&readingList.showSidebar.label;"/>
|
||||
</menupopup>
|
||||
</menu>
|
||||
<menuseparator/>
|
||||
<!-- Bookmarks menu items will go here -->
|
||||
<menuitem id="BMB_bookmarksShowAll"
|
||||
|
@ -6,15 +6,6 @@
|
||||
|
||||
Components.utils.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
addEventListener("load", function () {
|
||||
// unhide the reading-list engine if readinglist is enabled (note this
|
||||
// dialog is only used with FxA sync, so no special action is needed
|
||||
// for legacy sync.)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("dialogaccept", function () {
|
||||
let pane = document.getElementById("sync-customize-pane");
|
||||
// First determine what the preference for the "global" sync enabled pref
|
||||
|
@ -27,8 +27,6 @@
|
||||
<preference id="engine.passwords" name="services.sync.engine.passwords" type="bool"/>
|
||||
<preference id="engine.addons" name="services.sync.engine.addons" type="bool"/>
|
||||
<preference id="engine.prefs" name="services.sync.engine.prefs" type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist" name="readinglist.scheduler.enabled" type="bool"/>
|
||||
</preferences>
|
||||
|
||||
<label id="sync-customize-title" value="&syncCustomize.title;"/>
|
||||
@ -53,11 +51,6 @@
|
||||
<checkbox label="&engine.history.label;"
|
||||
accesskey="&engine.history.accesskey;"
|
||||
preference="engine.history"/>
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
preference="engine.addons"/>
|
||||
|
@ -4,13 +4,10 @@
|
||||
|
||||
/**
|
||||
* Test that the reader mode button appears and works properly on
|
||||
* reader-able content, and that ReadingList button can open and close
|
||||
* its Sidebar UI.
|
||||
* reader-able content.
|
||||
*/
|
||||
const TEST_PREFS = [
|
||||
["reader.parse-on-load.enabled", true],
|
||||
["browser.readinglist.enabled", true],
|
||||
["browser.readinglist.introShown", false],
|
||||
];
|
||||
|
||||
const TEST_PATH = "http://example.com/browser/browser/base/content/test/general/";
|
||||
@ -63,26 +60,6 @@ add_task(function* test_reader_button() {
|
||||
is(gURLBar.value, readerUrl, "gURLBar value is about:reader URL");
|
||||
is(gURLBar.textValue, url.substring("http://".length), "gURLBar is displaying original article URL");
|
||||
|
||||
// Readinglist button should be present, and status should be "openned", as the
|
||||
// first time in readerMode opens the Sidebar ReadingList as a feature introduction.
|
||||
let listButton;
|
||||
yield promiseWaitForCondition(() =>
|
||||
listButton = gBrowser.contentDocument.getElementById("list-button"));
|
||||
is_element_visible(listButton, "List button is present on a reader-able page");
|
||||
yield promiseWaitForCondition(() => listButton.classList.contains("on"));
|
||||
ok(listButton.classList.contains("on"),
|
||||
"List button should indicate SideBar-ReadingList open.");
|
||||
ok(ReadingListUI.isSidebarOpen,
|
||||
"The ReadingListUI should indicate SideBar-ReadingList open.");
|
||||
|
||||
// Now close the Sidebar ReadingList.
|
||||
listButton.click();
|
||||
yield promiseWaitForCondition(() => !listButton.classList.contains("on"));
|
||||
ok(!listButton.classList.contains("on"),
|
||||
"List button should now indicate SideBar-ReadingList closed.");
|
||||
ok(!ReadingListUI.isSidebarOpen,
|
||||
"The ReadingListUI should now indicate SideBar-ReadingList closed.");
|
||||
|
||||
// Switch page back out of reader mode.
|
||||
readerButton.click();
|
||||
yield promiseTabLoadEvent(tab);
|
||||
|
@ -4,8 +4,7 @@
|
||||
|
||||
/**
|
||||
* Test that the reader mode button appears and works properly on
|
||||
* reader-able content, and that ReadingList button can open and close
|
||||
* its Sidebar UI.
|
||||
* reader-able content.
|
||||
*/
|
||||
const TEST_PREFS = [
|
||||
["reader.parse-on-load.enabled", true],
|
||||
|
@ -4,8 +4,6 @@
|
||||
let {Log} = Cu.import("resource://gre/modules/Log.jsm", {});
|
||||
let {Weave} = Cu.import("resource://services-sync/main.js", {});
|
||||
let {Notifications} = Cu.import("resource://services-sync/notifications.js", {});
|
||||
// The BackStagePass allows us to get this test-only non-exported function.
|
||||
let {getInternalScheduler} = Cu.import("resource:///modules/readinglist/Scheduler.jsm", {});
|
||||
|
||||
let stringBundle = Cc["@mozilla.org/intl/stringbundle;1"]
|
||||
.getService(Ci.nsIStringBundleService)
|
||||
@ -37,23 +35,6 @@ add_task(function* prepare() {
|
||||
});
|
||||
});
|
||||
|
||||
add_task(function* testNotProlongedRLErrorWhenDisabled() {
|
||||
// Here we arrange for the (dead?) readinglist scheduler to have a last-synced
|
||||
// date of long ago and the RL scheduler is disabled.
|
||||
// gSyncUI.isProlongedReadingListError() should return false.
|
||||
// Pretend the reading-list is in the "prolonged error" state.
|
||||
let longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000); // 100 days ago.
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", longAgo.toString());
|
||||
|
||||
// It's prolonged while it's enabled.
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", true);
|
||||
Assert.equal(gSyncUI.isProlongedReadingListError(), true);
|
||||
|
||||
// But false when disabled.
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", false);
|
||||
Assert.equal(gSyncUI.isProlongedReadingListError(), false);
|
||||
});
|
||||
|
||||
add_task(function* testProlongedSyncError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
@ -76,32 +57,6 @@ add_task(function* testProlongedSyncError() {
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
add_task(function* testProlongedRLError() {
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", true);
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend the reading-list is in the "prolonged error" state.
|
||||
let longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000); // 100 days ago.
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", longAgo.toString());
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_OTHER;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.sync.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now pretend we just had a successful sync - the error notification should go away.
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", Date.now().toString());
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
add_task(function* testSyncLoginError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
@ -155,13 +110,7 @@ add_task(function* testSyncLoginNetworkError() {
|
||||
Services.obs.notifyObservers(null, "weave:ui:login:error", null);
|
||||
Assert.ok(sawNotificationAdded);
|
||||
|
||||
// clear the notification.
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
|
||||
// cool - so reset the flag and test what should *not* show an error.
|
||||
// reset the flag and test what should *not* show an error.
|
||||
sawNotificationAdded = false;
|
||||
Weave.Status.sync = Weave.LOGIN_FAILED;
|
||||
Weave.Status.login = Weave.LOGIN_FAILED_NETWORK_ERROR;
|
||||
@ -179,80 +128,6 @@ add_task(function* testSyncLoginNetworkError() {
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* testRLLoginError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend RL is in an auth error state
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now pretend we just had a successful sync - the error notification should go away.
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_OK;
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
// Here we put readinglist into an "authentication error" state (should see
|
||||
// the error bar reflecting this), then report a prolonged error from Sync (an
|
||||
// infobar to reflect the sync error should replace it), then resolve the sync
|
||||
// error - the authentication error from readinglist should remain.
|
||||
add_task(function* testRLLoginErrorRemains() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend RL is in an auth error state
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now Sync into a prolonged auth error state.
|
||||
promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Weave.Status.sync = Weave.PROLONGED_SYNC_FAILURE;
|
||||
Weave.Status.login = Weave.LOGIN_FAILED_LOGIN_REJECTED;
|
||||
Services.obs.notifyObservers(null, "weave:ui:sync:error", null);
|
||||
subject = yield promiseNotificationAdded;
|
||||
// still exactly 1 notification with the "login" title.
|
||||
notification = subject.wrappedJSObject.object;
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Resolve the sync problem.
|
||||
promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Weave.Status.sync = Weave.STATUS_OK;
|
||||
Weave.Status.login = Weave.LOGIN_SUCCEEDED;
|
||||
Services.obs.notifyObservers(null, "weave:ui:sync:finish", null);
|
||||
|
||||
// Expect one notification - the RL login problem.
|
||||
subject = yield promiseNotificationAdded;
|
||||
// still exactly 1 notification with the "login" title.
|
||||
notification = subject.wrappedJSObject.object;
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// and cleanup - resolve the readinglist error.
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_OK;
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
function checkButtonsStatus(shouldBeActive) {
|
||||
let button = document.getElementById("sync-button");
|
||||
let fxaContainer = document.getElementById("PanelUI-footer-fxa");
|
||||
@ -287,26 +162,12 @@ add_task(function* testButtonActivities() {
|
||||
testButtonActions("weave:service:sync:start", "weave:service:sync:finish");
|
||||
testButtonActions("weave:service:sync:start", "weave:service:sync:error");
|
||||
|
||||
testButtonActions("readinglist:sync:start", "readinglist:sync:finish");
|
||||
testButtonActions("readinglist:sync:start", "readinglist:sync:error");
|
||||
|
||||
// and ensure the counters correctly handle multiple in-flight syncs
|
||||
Services.obs.notifyObservers(null, "weave:service:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
// sync is still going...
|
||||
checkButtonsStatus(true);
|
||||
// another reading list starts
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
// The initial sync stops.
|
||||
// sync stops.
|
||||
Services.obs.notifyObservers(null, "weave:service:sync:finish", null);
|
||||
// RL is still going...
|
||||
checkButtonsStatus(true);
|
||||
// RL finishes with an error, so no longer active.
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
// Button should not be active.
|
||||
checkButtonsStatus(false);
|
||||
} finally {
|
||||
PanelUI.hide();
|
||||
|
@ -139,17 +139,6 @@
|
||||
label="&unsortedBookmarksCmd.label;"
|
||||
class="subviewbutton cui-withicon"
|
||||
oncommand="PlacesCommandHook.showPlacesOrganizer('UnfiledBookmarks'); PanelUI.hide();"/>
|
||||
<toolbarseparator>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</toolbarseparator>
|
||||
<toolbarbutton id="panelMenu_viewReadingListSidebar"
|
||||
label="&readingList.showSidebar.label;"
|
||||
class="subviewbutton"
|
||||
key="key_readingListSidebar"
|
||||
oncommand="SidebarUI.toggle('readingListSidebar'); PanelUI.hide();">
|
||||
<observes element="readingListSidebar" attribute="checked"/>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</toolbarbutton>
|
||||
<toolbarseparator class="small-separator"/>
|
||||
<toolbaritem id="panelMenu_bookmarksMenu"
|
||||
orient="vertical"
|
||||
|
@ -16,7 +16,6 @@ DIRS += [
|
||||
'pocket',
|
||||
'preferences',
|
||||
'privatebrowsing',
|
||||
'readinglist',
|
||||
'search',
|
||||
'sessionstore',
|
||||
'shell',
|
||||
|
@ -318,12 +318,6 @@ let gSyncPane = {
|
||||
fxaEmailAddress1Label.hidden = false;
|
||||
displayNameLabel.hidden = true;
|
||||
|
||||
// unhide the reading-list engine if readinglist is enabled (note we do
|
||||
// it here as it must remain disabled for legacy sync users)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
|
||||
let profileInfoEnabled;
|
||||
try {
|
||||
profileInfoEnabled = Services.prefs.getBoolPref("identity.fxaccounts.profile_image.enabled");
|
||||
|
@ -24,10 +24,6 @@
|
||||
<preference id="engine.passwords"
|
||||
name="services.sync.engine.passwords"
|
||||
type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist"
|
||||
name="readinglist.scheduler.enabled"
|
||||
type="bool"/>
|
||||
</preferences>
|
||||
|
||||
<script type="application/javascript"
|
||||
@ -300,11 +296,6 @@
|
||||
<checkbox label="&engine.history.label;"
|
||||
accesskey="&engine.history.accesskey;"
|
||||
preference="engine.history"/>
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
preference="engine.addons"/>
|
||||
|
@ -156,11 +156,6 @@ let gSyncPane = {
|
||||
// service.fxAccountsEnabled is false iff sync is already configured for
|
||||
// the legacy provider.
|
||||
if (service.fxAccountsEnabled) {
|
||||
// unhide the reading-list engine if readinglist is enabled (note we do
|
||||
// it here as it must remain disabled for legacy sync users)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
// determine the fxa status...
|
||||
this.page = PAGE_PLEASE_WAIT;
|
||||
fxAccounts.getSignedInUser().then(data => {
|
||||
|
@ -28,8 +28,6 @@
|
||||
<preference id="engine.tabs" name="services.sync.engine.tabs" type="bool"/>
|
||||
<preference id="engine.prefs" name="services.sync.engine.prefs" type="bool"/>
|
||||
<preference id="engine.passwords" name="services.sync.engine.passwords" type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist" name="readinglist.scheduler.enabled" type="bool"/>
|
||||
</preferences>
|
||||
|
||||
|
||||
@ -301,12 +299,6 @@
|
||||
accesskey="&engine.history.accesskey;"
|
||||
onsynctopreference="gSyncPane.onPreferenceChanged(this);"
|
||||
preference="engine.history"/>
|
||||
<!-- onpreferencechanged not needed for the readinglist engine -->
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
onsynctopreference="gSyncPane.onPreferenceChanged();"
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,466 +0,0 @@
|
||||
/* 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 = [
|
||||
"SQLiteStore",
|
||||
];
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
|
||||
"resource://gre/modules/Sqlite.jsm");
|
||||
|
||||
/**
|
||||
* A SQLite Reading List store backed by a database on disk. The database is
|
||||
* created if it doesn't exist.
|
||||
*
|
||||
* @param pathRelativeToProfileDir The path of the database file relative to
|
||||
* the profile directory.
|
||||
*/
|
||||
this.SQLiteStore = function SQLiteStore(pathRelativeToProfileDir) {
|
||||
this.pathRelativeToProfileDir = pathRelativeToProfileDir;
|
||||
};
|
||||
|
||||
this.SQLiteStore.prototype = {
|
||||
|
||||
/**
|
||||
* Yields the number of items in the store that match the given options.
|
||||
*
|
||||
* @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* (userOptsList=[], controlOpts={}) {
|
||||
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
|
||||
let count = 0;
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
SELECT COUNT(*) AS count FROM items ${sql};
|
||||
`, args, row => count = row.getResultByName("count"));
|
||||
return count;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Enumerates the items in the store that match the given options.
|
||||
*
|
||||
* @param callback Called for each item in the enumeration. It's passed a
|
||||
* single object, an item.
|
||||
* @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, userOptsList=[], controlOpts={}) {
|
||||
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
|
||||
let colNames = ReadingList.ItemRecordProperties;
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
SELECT ${colNames} FROM items ${sql};
|
||||
`, args, row => callback(itemFromRow(row)));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Adds an item to the store that isn't already present. See
|
||||
* ReadingList.prototype.addItem.
|
||||
*
|
||||
* @param items A simple object representing an item.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
addItem: Task.async(function* (item) {
|
||||
let colNames = [];
|
||||
let paramNames = [];
|
||||
for (let propName in item) {
|
||||
colNames.push(propName);
|
||||
paramNames.push(`:${propName}`);
|
||||
}
|
||||
let conn = yield this._connectionPromise;
|
||||
try {
|
||||
yield conn.executeCached(`
|
||||
INSERT INTO items (${colNames}) VALUES (${paramNames});
|
||||
`, item);
|
||||
}
|
||||
catch (err) {
|
||||
throwExistsError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 a `url`.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
updateItem: Task.async(function* (item) {
|
||||
yield this._updateItem(item, "url");
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Error on error.
|
||||
*/
|
||||
deleteItemByURL: Task.async(function* (url) {
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
DELETE FROM items WHERE url = :url;
|
||||
`, { 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.
|
||||
*/
|
||||
destroy() {
|
||||
if (!this._destroyPromise) {
|
||||
this._destroyPromise = Task.spawn(function* () {
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.close();
|
||||
this.__connectionPromise = Promise.reject("Store destroyed");
|
||||
}.bind(this));
|
||||
}
|
||||
return this._destroyPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Promise<Sqlite.OpenedConnection>
|
||||
*/
|
||||
get _connectionPromise() {
|
||||
if (!this.__connectionPromise) {
|
||||
this.__connectionPromise = this._createConnection();
|
||||
}
|
||||
return this.__connectionPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the database connection.
|
||||
*
|
||||
* @return Promise<Sqlite.OpenedConnection>
|
||||
*/
|
||||
_createConnection: Task.async(function* () {
|
||||
let conn = yield Sqlite.openConnection({
|
||||
path: this.pathRelativeToProfileDir,
|
||||
sharedMemoryCache: false,
|
||||
});
|
||||
Sqlite.shutdown.addBlocker("readinglist/SQLiteStore: Destroy",
|
||||
this.destroy.bind(this));
|
||||
yield conn.execute(`
|
||||
PRAGMA locking_mode = EXCLUSIVE;
|
||||
`);
|
||||
yield this._checkSchema(conn);
|
||||
return conn;
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 ReadingList.Error.Error("Item must have " + keyProp);
|
||||
}
|
||||
try {
|
||||
yield conn.executeCached(`
|
||||
UPDATE items SET ${assignments} WHERE ${keyProp} = :${keyProp};
|
||||
`, item);
|
||||
}
|
||||
catch (err) {
|
||||
throwExistsError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
// The current schema version.
|
||||
_schemaVersion: 1,
|
||||
|
||||
_checkSchema: Task.async(function* (conn) {
|
||||
let version = parseInt(yield conn.getSchemaVersion());
|
||||
for (; version < this._schemaVersion; version++) {
|
||||
let meth = `_migrateSchema${version}To${version + 1}`;
|
||||
yield this[meth](conn);
|
||||
}
|
||||
yield conn.setSchemaVersion(this._schemaVersion);
|
||||
}),
|
||||
|
||||
_migrateSchema0To1: Task.async(function* (conn) {
|
||||
yield conn.execute(`
|
||||
PRAGMA journal_mode = wal;
|
||||
`);
|
||||
// 524288 bytes = 512 KiB
|
||||
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,
|
||||
serverLastModified INTEGER,
|
||||
url TEXT UNIQUE,
|
||||
preview TEXT,
|
||||
title TEXT,
|
||||
resolvedURL TEXT UNIQUE,
|
||||
resolvedTitle TEXT,
|
||||
excerpt TEXT,
|
||||
archived BOOLEAN,
|
||||
deleted BOOLEAN,
|
||||
favorite BOOLEAN,
|
||||
isArticle BOOLEAN,
|
||||
wordCount INTEGER,
|
||||
unread BOOLEAN,
|
||||
addedBy TEXT,
|
||||
addedOn INTEGER,
|
||||
storedOn INTEGER,
|
||||
markedReadBy TEXT,
|
||||
markedReadOn INTEGER,
|
||||
readPosition INTEGER,
|
||||
syncStatus INTEGER
|
||||
);
|
||||
`);
|
||||
yield conn.execute(`
|
||||
CREATE INDEX items_addedOn ON items (addedOn);
|
||||
`);
|
||||
yield conn.execute(`
|
||||
CREATE INDEX items_unread ON items (unread);
|
||||
`);
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a simple object whose properties are the
|
||||
* ReadingList.ItemRecordProperties lifted from the given row.
|
||||
*
|
||||
* @param row A mozIStorageRow.
|
||||
* @return The item.
|
||||
*/
|
||||
function itemFromRow(row) {
|
||||
let item = {};
|
||||
for (let name of ReadingList.ItemRecordProperties) {
|
||||
item[name] = row.getResultByName(name);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the given Error indicates that a unique constraint failed, then wraps that
|
||||
* error in a ReadingList.Error.Exists and throws it. Otherwise throws the
|
||||
* given error.
|
||||
*
|
||||
* @param err An Error object.
|
||||
*/
|
||||
function throwExistsError(err) {
|
||||
let match =
|
||||
/UNIQUE constraint failed: items\.([a-zA-Z0-9_]+)/.exec(err.message);
|
||||
if (match) {
|
||||
let newErr = new ReadingList.Error.Exists(
|
||||
"An item with the following property already exists: " + match[1]
|
||||
);
|
||||
newErr.originalError = err;
|
||||
err = newErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the back part of a SELECT statement generated from the given list of
|
||||
* options.
|
||||
*
|
||||
* @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 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 userOptsList) {
|
||||
if ("sort" in opts) {
|
||||
sort = opts.sort;
|
||||
delete opts.sort;
|
||||
}
|
||||
if ("descending" in opts) {
|
||||
if (opts.descending) {
|
||||
sortDir = "DESC";
|
||||
}
|
||||
delete opts.descending;
|
||||
}
|
||||
if ("limit" in opts) {
|
||||
limit = opts.limit;
|
||||
delete opts.limit;
|
||||
}
|
||||
if ("offset" in opts) {
|
||||
offset = opts.offset;
|
||||
delete opts.offset;
|
||||
}
|
||||
}
|
||||
|
||||
let fragments = [];
|
||||
|
||||
if (sort) {
|
||||
sortDir = sortDir || "ASC";
|
||||
fragments.push(`ORDER BY ${sort} ${sortDir}`);
|
||||
}
|
||||
if (limit) {
|
||||
fragments.push(`LIMIT ${limit}`);
|
||||
if (offset) {
|
||||
fragments.push(`OFFSET ${offset}`);
|
||||
}
|
||||
}
|
||||
|
||||
let args = {};
|
||||
let mainExprs = [];
|
||||
|
||||
let controlSQLExpr = sqlExpressionFromOptions([controlOpts], args);
|
||||
if (controlSQLExpr) {
|
||||
mainExprs.push(`(${controlSQLExpr})`);
|
||||
}
|
||||
|
||||
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 = [];
|
||||
for (let key in opts) {
|
||||
if (Array.isArray(opts[key])) {
|
||||
// Convert arrays to IN expressions. e.g., { guid: ['a', 'b', 'c'] }
|
||||
// becomes "guid IN (:guid, :guid_1, :guid_2)". The guid_i arguments
|
||||
// are added to opts.
|
||||
let array = opts[key];
|
||||
let params = [];
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let paramName = uniqueParamName(args, key);
|
||||
params.push(`:${paramName}`);
|
||||
args[paramName] = array[i];
|
||||
}
|
||||
conjunctions.push(`${key} IN (${params})`);
|
||||
}
|
||||
else {
|
||||
let paramName = uniqueParamName(args, key);
|
||||
conjunctions.push(`${key} = :${paramName}`);
|
||||
args[paramName] = opts[key];
|
||||
}
|
||||
}
|
||||
let conjunction = conjunctions.join(" AND ");
|
||||
if (conjunction) {
|
||||
disjunctions.push(`(${conjunction})`);
|
||||
}
|
||||
}
|
||||
let disjunction = disjunctions.join(" OR ");
|
||||
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;
|
||||
}
|
@ -1,409 +0,0 @@
|
||||
/* 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;"
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import('resource://gre/modules/Task.jsm');
|
||||
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'LogManager',
|
||||
'resource://services-common/logmanager.js');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Log',
|
||||
'resource://gre/modules/Log.jsm');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Preferences',
|
||||
'resource://gre/modules/Preferences.jsm');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
|
||||
// The main readinglist module.
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'ReadingList',
|
||||
'resource:///modules/readinglist/ReadingList.jsm');
|
||||
|
||||
// The "engine"
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Sync',
|
||||
'resource:///modules/readinglist/Sync.jsm');
|
||||
|
||||
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
|
||||
XPCOMUtils.defineLazyGetter(this, "fxAccountsCommon", function() {
|
||||
let namespace = {};
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js", namespace);
|
||||
return namespace;
|
||||
});
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ReadingListScheduler"];
|
||||
|
||||
// A list of "external" observer topics that may cause us to change when we
|
||||
// sync.
|
||||
const OBSERVERS = [
|
||||
// We don't sync when offline and restart when online.
|
||||
"network:offline-status-changed",
|
||||
// FxA notifications also cause us to check if we should sync.
|
||||
"fxaccounts:onverified",
|
||||
// some notifications the engine might send if we have been requested to backoff.
|
||||
"readinglist:backoff-requested",
|
||||
// request to sync now
|
||||
"readinglist:user-sync",
|
||||
|
||||
];
|
||||
|
||||
let prefs = new Preferences("readinglist.scheduler.");
|
||||
|
||||
// A helper to manage our interval values.
|
||||
let intervals = {
|
||||
// Getters for our intervals.
|
||||
_fixupIntervalPref(prefName, def) {
|
||||
// All pref values are seconds, but we return ms.
|
||||
return prefs.get(prefName, def) * 1000;
|
||||
},
|
||||
|
||||
// How long after startup do we do an initial sync?
|
||||
get initial() this._fixupIntervalPref("initial", 10), // 10 seconds.
|
||||
// Every interval after the first.
|
||||
get schedule() this._fixupIntervalPref("schedule", 2 * 60 * 60), // 2 hours
|
||||
// Initial retry after an error (exponentially backed-off to .schedule)
|
||||
get retry() this._fixupIntervalPref("retry", 2 * 60), // 2 mins
|
||||
};
|
||||
|
||||
// This is the implementation, but it's not exposed directly.
|
||||
function InternalScheduler(readingList = null) {
|
||||
// oh, I don't know what logs yet - let's guess!
|
||||
let logs = [
|
||||
"browserwindow.syncui",
|
||||
"FirefoxAccounts",
|
||||
"readinglist.api",
|
||||
"readinglist.scheduler",
|
||||
"readinglist.serverclient",
|
||||
"readinglist.sync",
|
||||
];
|
||||
|
||||
this._logManager = new LogManager("readinglist.", logs, "readinglist");
|
||||
this.log = Log.repository.getLogger("readinglist.scheduler");
|
||||
this.log.info("readinglist scheduler created.")
|
||||
this.state = this.STATE_OK;
|
||||
this.readingList = readingList || ReadingList; // hook point for tests.
|
||||
|
||||
// don't this.init() here, but instead at the module level - tests want to
|
||||
// add hooks before it is called.
|
||||
}
|
||||
|
||||
InternalScheduler.prototype = {
|
||||
// When the next scheduled sync should happen. If we can sync, there will
|
||||
// be a timer set to fire then. If we can't sync there will not be a timer,
|
||||
// but it will be set to fire then as soon as we can.
|
||||
_nextScheduledSync: null,
|
||||
// The time when the most-recent "backoff request" expires - we will never
|
||||
// schedule a new timer before this.
|
||||
_backoffUntil: 0,
|
||||
// Our current timer.
|
||||
_timer: null,
|
||||
// Our timer fires a promise - _timerRunning is true until it resolves or
|
||||
// rejects.
|
||||
_timerRunning: false,
|
||||
// Our sync engine - XXX - maybe just a callback?
|
||||
_engine: Sync,
|
||||
// Our current "error backoff" timeout. zero if no error backoff is in
|
||||
// progress and incremented after successive errors until a max is reached.
|
||||
_currentErrorBackoff: 0,
|
||||
|
||||
// Our state variable and constants.
|
||||
state: null,
|
||||
STATE_OK: "ok",
|
||||
STATE_ERROR_AUTHENTICATION: "authentication error",
|
||||
STATE_ERROR_OTHER: "other error",
|
||||
|
||||
init() {
|
||||
this.log.info("scheduler initialzing");
|
||||
this._setupRLListener();
|
||||
this._observe = this.observe.bind(this);
|
||||
for (let notification of OBSERVERS) {
|
||||
Services.obs.addObserver(this._observe, notification, false);
|
||||
}
|
||||
this._nextScheduledSync = Date.now() + intervals.initial;
|
||||
this._setupTimer();
|
||||
},
|
||||
|
||||
_setupRLListener() {
|
||||
let maybeSync = () => {
|
||||
if (this._timerRunning) {
|
||||
// If a sync is currently running it is possible it will miss the change
|
||||
// just made, so tell the timer the next sync should be 1 ms after
|
||||
// it completes (we don't use zero as that has special meaning re backoffs)
|
||||
this._maybeReschedule(1);
|
||||
} else {
|
||||
// Do the sync now.
|
||||
this._syncNow();
|
||||
}
|
||||
};
|
||||
let listener = {
|
||||
onItemAdded: maybeSync,
|
||||
onItemUpdated: maybeSync,
|
||||
onItemDeleted: maybeSync,
|
||||
}
|
||||
this.readingList.addListener(listener);
|
||||
},
|
||||
|
||||
// Note: only called by tests.
|
||||
finalize() {
|
||||
this.log.info("scheduler finalizing");
|
||||
this._clearTimer();
|
||||
for (let notification of OBSERVERS) {
|
||||
Services.obs.removeObserver(this._observe, notification);
|
||||
}
|
||||
this._observe = null;
|
||||
},
|
||||
|
||||
observe(subject, topic, data) {
|
||||
this.log.debug("observed ${}", topic);
|
||||
switch (topic) {
|
||||
case "readinglist:backoff-requested": {
|
||||
// The subject comes in as a string, a number of seconds.
|
||||
let interval = parseInt(data, 10);
|
||||
if (isNaN(interval)) {
|
||||
this.log.warn("Backoff request had non-numeric value", data);
|
||||
return;
|
||||
}
|
||||
this.log.info("Received a request to backoff for ${} seconds", interval);
|
||||
this._backoffUntil = Date.now() + interval * 1000;
|
||||
this._maybeReschedule(0);
|
||||
break;
|
||||
}
|
||||
case "readinglist:user-sync":
|
||||
this._syncNow();
|
||||
break;
|
||||
case "fxaccounts:onverified":
|
||||
// If we were in an authentication error state, reset that now.
|
||||
if (this.state == this.STATE_ERROR_AUTHENTICATION) {
|
||||
this.state = this.STATE_OK;
|
||||
}
|
||||
// and sync now.
|
||||
this._syncNow();
|
||||
break;
|
||||
|
||||
// The rest just indicate that now is probably a good time to check if
|
||||
// we can sync as normal using whatever schedule was previously set.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// When observers fire we ignore the current sync error state as the
|
||||
// notification may indicate it's been resolved.
|
||||
this._setupTimer(true);
|
||||
},
|
||||
|
||||
// Is the current error state such that we shouldn't schedule a new sync.
|
||||
_isBlockedOnError() {
|
||||
// this needs more thought...
|
||||
return this.state == this.STATE_ERROR_AUTHENTICATION;
|
||||
},
|
||||
|
||||
// canSync indicates if we can currently sync.
|
||||
_canSync(ignoreBlockingErrors = false) {
|
||||
if (!prefs.get("enabled")) {
|
||||
this.log.info("canSync=false - syncing is disabled");
|
||||
return false;
|
||||
}
|
||||
if (Services.io.offline) {
|
||||
this.log.info("canSync=false - we are offline");
|
||||
return false;
|
||||
}
|
||||
if (!ignoreBlockingErrors && this._isBlockedOnError()) {
|
||||
this.log.info("canSync=false - we are in a blocked error state", this.state);
|
||||
return false;
|
||||
}
|
||||
this.log.info("canSync=true");
|
||||
return true;
|
||||
},
|
||||
|
||||
// _setupTimer checks the current state and the environment to see when
|
||||
// we should next sync and creates the timer with the appropriate delay.
|
||||
_setupTimer(ignoreBlockingErrors = false) {
|
||||
if (!this._canSync(ignoreBlockingErrors)) {
|
||||
this._clearTimer();
|
||||
return;
|
||||
}
|
||||
if (this._timer) {
|
||||
let when = new Date(this._nextScheduledSync);
|
||||
let delay = this._nextScheduledSync - Date.now();
|
||||
this.log.info("checkStatus - already have a timer - will fire in ${delay}ms at ${when}",
|
||||
{delay, when});
|
||||
return;
|
||||
}
|
||||
if (this._timerRunning) {
|
||||
this.log.info("checkStatus - currently syncing");
|
||||
return;
|
||||
}
|
||||
// no timer and we can sync, so start a new one.
|
||||
let now = Date.now();
|
||||
let delay = Math.max(0, this._nextScheduledSync - now);
|
||||
let when = new Date(now + delay);
|
||||
this.log.info("next scheduled sync is in ${delay}ms (at ${when})", {delay, when})
|
||||
this._timer = this._setTimeout(delay);
|
||||
},
|
||||
|
||||
// Something (possibly naively) thinks the next sync should happen in
|
||||
// delay-ms. If there's a backoff in progress, ignore the requested delay
|
||||
// and use the back-off. If there's already a timer scheduled for earlier
|
||||
// than delay, let the earlier timer remain. Otherwise, use the requested
|
||||
// delay.
|
||||
_maybeReschedule(delay) {
|
||||
// If there's no delay specified and there's nothing currently scheduled,
|
||||
// it means a backoff request while the sync is actually running - there's
|
||||
// no need to do anything here - the next reschedule after the sync
|
||||
// completes will take the backoff into account.
|
||||
if (!delay && !this._nextScheduledSync) {
|
||||
this.log.debug("_maybeReschedule ignoring a backoff request while running");
|
||||
return;
|
||||
}
|
||||
let now = Date.now();
|
||||
if (!this._nextScheduledSync) {
|
||||
this._nextScheduledSync = now + delay;
|
||||
}
|
||||
// If there is something currently scheduled before the requested delay,
|
||||
// keep the existing value (eg, if we have a timer firing in 1 second, and
|
||||
// get a notification that says we should sync in 2 seconds, we keep the 1
|
||||
// second value)
|
||||
this._nextScheduledSync = Math.min(this._nextScheduledSync, now + delay);
|
||||
// But we still need to honor a backoff.
|
||||
this._nextScheduledSync = Math.max(this._nextScheduledSync, this._backoffUntil);
|
||||
// And always create a new timer next time _setupTimer is called.
|
||||
this._clearTimer();
|
||||
},
|
||||
|
||||
// callback for when the timer fires.
|
||||
_doSync() {
|
||||
this.log.debug("starting sync");
|
||||
this._timer = null;
|
||||
this._timerRunning = true;
|
||||
// flag that there's no new schedule yet, so a request coming in while
|
||||
// we are running does the right thing.
|
||||
this._nextScheduledSync = 0;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
this._engine.start().then(() => {
|
||||
this.log.info("Sync completed successfully");
|
||||
// Write a pref in the same format used to services/sync to indicate
|
||||
// the last success.
|
||||
prefs.set("lastSync", new Date().toString());
|
||||
this.state = this.STATE_OK;
|
||||
this._logManager.resetFileLog().then(result => {
|
||||
if (result == this._logManager.ERROR_LOG_WRITTEN) {
|
||||
Cu.reportError("Reading List sync encountered an error - see about:sync-log for the log file.");
|
||||
}
|
||||
});
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
this._currentErrorBackoff = 0; // error retry interval is reset on success.
|
||||
return intervals.schedule;
|
||||
}).catch(err => {
|
||||
// This isn't ideal - we really should have _canSync() check this - but
|
||||
// that requires a refactor to turn _canSync() into a promise-based
|
||||
// function.
|
||||
if (err.message == fxAccountsCommon.ERROR_NO_ACCOUNT ||
|
||||
err.message == fxAccountsCommon.ERROR_UNVERIFIED_ACCOUNT) {
|
||||
// make everything look like success.
|
||||
this._currentErrorBackoff = 0; // error retry interval is reset on success.
|
||||
this.log.info("Can't sync due to FxA account state " + err.message);
|
||||
this.state = this.STATE_OK;
|
||||
this._logManager.resetFileLog().then(result => {
|
||||
if (result == this._logManager.ERROR_LOG_WRITTEN) {
|
||||
Cu.reportError("Reading List sync encountered an error - see about:sync-log for the log file.");
|
||||
}
|
||||
});
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
// it's unfortunate that we are probably going to hit this every
|
||||
// 2 hours, but it should be invisible to the user.
|
||||
return intervals.schedule;
|
||||
}
|
||||
this.state = err.message == fxAccountsCommon.ERROR_AUTH_ERROR ?
|
||||
this.STATE_ERROR_AUTHENTICATION : this.STATE_ERROR_OTHER;
|
||||
this.log.error("Sync failed, now in state '${state}': ${err}",
|
||||
{state: this.state, err});
|
||||
this._logManager.resetFileLog();
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
// We back-off on error retries until it hits our normally scheduled interval.
|
||||
this._currentErrorBackoff = this._currentErrorBackoff == 0 ? intervals.retry :
|
||||
Math.min(intervals.schedule, this._currentErrorBackoff * 2);
|
||||
return this._currentErrorBackoff;
|
||||
}).then(nextDelay => {
|
||||
this._timerRunning = false;
|
||||
// ensure a new timer is setup for the appropriate next time.
|
||||
this._maybeReschedule(nextDelay);
|
||||
this._setupTimer();
|
||||
this._onAutoReschedule(); // just for tests...
|
||||
}).catch(err => {
|
||||
// We should never get here, but better safe than sorry...
|
||||
this.log.error("Failed to reschedule after sync completed", err);
|
||||
});
|
||||
},
|
||||
|
||||
_clearTimer() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// A function to "sync now", but not allowing it to start if one is
|
||||
// already running, and rescheduling the timer.
|
||||
// To call this, just send a "readinglist:user-sync" notification.
|
||||
_syncNow() {
|
||||
if (!prefs.get("enabled")) {
|
||||
this.log.info("syncNow() but syncing is disabled - ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._timerRunning) {
|
||||
this.log.info("syncNow() but a sync is already in progress - ignoring");
|
||||
return;
|
||||
}
|
||||
this._clearTimer();
|
||||
this._doSync();
|
||||
},
|
||||
|
||||
// A couple of hook-points for testing.
|
||||
// xpcshell tests hook this so (a) it can check the expected delay is set
|
||||
// and (b) to ignore the delay and set a timeout of 0 so the test is fast.
|
||||
_setTimeout(delay) {
|
||||
return setTimeout(() => this._doSync(), delay);
|
||||
},
|
||||
// xpcshell tests hook this to make sure that the correct state etc exist
|
||||
// after a sync has been completed and a new timer created (or not).
|
||||
_onAutoReschedule() {},
|
||||
};
|
||||
|
||||
let internalScheduler = new InternalScheduler();
|
||||
internalScheduler.init();
|
||||
|
||||
// The public interface into this module is tiny, so a simple object that
|
||||
// delegates to the implementation.
|
||||
let ReadingListScheduler = {
|
||||
get STATE_OK() internalScheduler.STATE_OK,
|
||||
get STATE_ERROR_AUTHENTICATION() internalScheduler.STATE_ERROR_AUTHENTICATION,
|
||||
get STATE_ERROR_OTHER() internalScheduler.STATE_ERROR_OTHER,
|
||||
|
||||
get state() internalScheduler.state,
|
||||
};
|
||||
|
||||
// These functions are exposed purely for tests, which manage to grab them
|
||||
// via a BackstagePass.
|
||||
function createTestableScheduler(readingList) {
|
||||
// kill the "real" scheduler as we don't want it listening to notifications etc.
|
||||
if (internalScheduler) {
|
||||
internalScheduler.finalize();
|
||||
internalScheduler = null;
|
||||
}
|
||||
// No .init() call - that's up to the tests after hooking.
|
||||
return new InternalScheduler(readingList);
|
||||
}
|
||||
|
||||
// mochitests want the internal state of the real scheduler for various things.
|
||||
function getInternalScheduler() {
|
||||
return internalScheduler;
|
||||
}
|
@ -1,178 +0,0 @@
|
||||
/* 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/. */
|
||||
|
||||
// The client used to access the ReadingList server.
|
||||
|
||||
"use strict";
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "RESTRequest", "resource://services-common/rest.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", "resource://services-common/utils.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", "resource://gre/modules/FxAccounts.jsm");
|
||||
|
||||
let log = Log.repository.getLogger("readinglist.serverclient");
|
||||
|
||||
const OAUTH_SCOPE = "readinglist"; // The "scope" on the oauth token we request.
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"ServerClient",
|
||||
];
|
||||
|
||||
// utf-8 joy. rest.js, which we use for the underlying requests, does *not*
|
||||
// encode the request as utf-8 even though it wants to know the encoding.
|
||||
// It does, however, explicitly decode the response. This seems insane, but is
|
||||
// what it is.
|
||||
// The end result being we need to utf-8 the request and let the response take
|
||||
// care of itself.
|
||||
function objectToUTF8Json(obj) {
|
||||
// FTR, unescape(encodeURIComponent(JSON.stringify(obj))) also works ;)
|
||||
return CommonUtils.encodeUTF8(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function ServerClient(fxa = fxAccounts) {
|
||||
this.fxa = fxa;
|
||||
}
|
||||
|
||||
ServerClient.prototype = {
|
||||
|
||||
request(options) {
|
||||
return this._request(options.path, options.method, options.body, options.headers);
|
||||
},
|
||||
|
||||
get serverURL() {
|
||||
return Services.prefs.getCharPref("readinglist.server");
|
||||
},
|
||||
|
||||
_getURL(path) {
|
||||
let result = this.serverURL;
|
||||
// we expect the path to have a leading slash, so remove any trailing
|
||||
// slashes on the pref.
|
||||
if (result.endsWith("/")) {
|
||||
result = result.slice(0, -1);
|
||||
}
|
||||
return result + path;
|
||||
},
|
||||
|
||||
// Hook points for testing.
|
||||
_getToken() {
|
||||
// Assume token-caching is in place - if it's not we should avoid doing
|
||||
// this each request.
|
||||
return this.fxa.getOAuthToken({scope: OAUTH_SCOPE});
|
||||
},
|
||||
|
||||
_removeToken(token) {
|
||||
return this.fxa.removeCachedOAuthToken({token});
|
||||
},
|
||||
|
||||
// Converts an error from the RESTRequest object to an error we export.
|
||||
_convertRestError(error) {
|
||||
return error; // XXX - errors?
|
||||
},
|
||||
|
||||
// Converts an error from a try/catch handler to an error we export.
|
||||
_convertJSError(error) {
|
||||
return error; // XXX - errors?
|
||||
},
|
||||
|
||||
/*
|
||||
* Perform a request - handles authentication
|
||||
*/
|
||||
_request: Task.async(function* (path, method, body, headers) {
|
||||
let token = yield this._getToken();
|
||||
let response = yield this._rawRequest(path, method, body, headers, token);
|
||||
log.debug("initial request got status ${status}", response);
|
||||
if (response.status == 401) {
|
||||
// an auth error - assume our token has expired or similar.
|
||||
this._removeToken(token);
|
||||
token = yield this._getToken();
|
||||
response = yield this._rawRequest(path, method, body, headers, token);
|
||||
log.debug("retry of request got status ${status}", response);
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
|
||||
/*
|
||||
* Perform a request *without* abstractions such as auth etc
|
||||
*
|
||||
* On success (which *includes* non-200 responses) returns an object like:
|
||||
* {
|
||||
* status: 200, # http status code
|
||||
* headers: {}, # header values keyed by header name.
|
||||
* body: {}, # parsed json
|
||||
}
|
||||
*/
|
||||
|
||||
_rawRequest(path, method, body, headers, oauthToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = this._getURL(path);
|
||||
log.debug("dispatching request to", url);
|
||||
let request = new RESTRequest(url);
|
||||
method = method.toUpperCase();
|
||||
|
||||
request.setHeader("Accept", "application/json");
|
||||
request.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
request.setHeader("Authorization", "Bearer " + oauthToken);
|
||||
// and additional header specified for this request.
|
||||
if (headers) {
|
||||
for (let [headerName, headerValue] in Iterator(headers)) {
|
||||
log.trace("Caller specified header: ${headerName}=${headerValue}", {headerName, headerValue});
|
||||
request.setHeader(headerName, headerValue);
|
||||
}
|
||||
}
|
||||
|
||||
request.onComplete = error => {
|
||||
// Although the server API docs say the "Backoff" header is on
|
||||
// successful responses while "Retry-After" is on error responses, we
|
||||
// just look for them both in both cases (as the scheduler makes no
|
||||
// distinction)
|
||||
let response = request.response;
|
||||
if (response && response.headers) {
|
||||
let backoff = response.headers["backoff"] || response.headers["retry-after"];
|
||||
if (backoff) {
|
||||
let numeric = backoff.toLowerCase() == "none" ? 0 :
|
||||
parseInt(backoff, 10);
|
||||
if (isNaN(numeric)) {
|
||||
log.info("Server requested unrecognized backoff", backoff);
|
||||
} else if (numeric > 0) {
|
||||
log.info("Server requested backoff", numeric);
|
||||
Services.obs.notifyObservers(null, "readinglist:backoff-requested", String(numeric));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
return reject(this._convertRestError(error));
|
||||
}
|
||||
|
||||
log.debug("received response status: ${status} ${statusText}", response);
|
||||
// Handle response status codes we know about
|
||||
let result = {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
};
|
||||
try {
|
||||
if (response.body) {
|
||||
result.body = JSON.parse(response.body);
|
||||
}
|
||||
} catch (e) {
|
||||
log.debug("Response is not JSON. First 1024 chars: |${body}|",
|
||||
{ body: response.body.substr(0, 1024) });
|
||||
// We don't reject due to this (and don't even make a huge amount of
|
||||
// log noise - eg, a 50X error from a load balancer etc may not write
|
||||
// JSON.
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
}
|
||||
// We are assuming the body has already been decoded and thus contains
|
||||
// unicode, but the server expects utf-8. encodeURIComponent does that.
|
||||
request.dispatch(method, objectToUTF8Json(body));
|
||||
});
|
||||
},
|
||||
};
|
@ -1,664 +0,0 @@
|
||||
/* 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 maximum number of sub-requests per POST /batch supported by the server.
|
||||
// See http://readinglist.readthedocs.org/en/latest/api/batch.html.
|
||||
const BATCH_REQUEST_LIMIT = 25;
|
||||
|
||||
// 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* () {
|
||||
try {
|
||||
yield this._start();
|
||||
} finally {
|
||||
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._logDiagnostics();
|
||||
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 0 - for debugging we log some stuff about the local store before
|
||||
* we start syncing.
|
||||
* We only do this when the log level is "Trace" or lower as the info (a)
|
||||
* may be expensive to generate, (b) generate alot of output and (c) may
|
||||
* contain private information.
|
||||
*/
|
||||
_logDiagnostics: Task.async(function* () {
|
||||
// Sadly our log is likely to have Log.Level.All, so loop over our
|
||||
// appenders looking for the effective level.
|
||||
let smallestLevel = log.appenders.reduce(
|
||||
(prev, appender) => Math.min(prev, appender.level),
|
||||
Log.Level.Error);
|
||||
|
||||
if (smallestLevel > Log.Level.Trace) {
|
||||
return;
|
||||
}
|
||||
|
||||
let localItems = [];
|
||||
yield this.list.forEachItem(localItem => localItems.push(localItem));
|
||||
log.trace("Have " + localItems.length + " local item(s)");
|
||||
for (let localItem of localItems) {
|
||||
// We need to use .record so we get access to a couple of the "internal" fields.
|
||||
let record = localItem._record;
|
||||
let redacted = {};
|
||||
for (let attr of ["guid", "url", "resolvedURL", "serverLastModified", "syncStatus"]) {
|
||||
redacted[attr] = record[attr];
|
||||
}
|
||||
log.trace(JSON.stringify(redacted));
|
||||
}
|
||||
// and the GUIDs of deleted items.
|
||||
let deletedGuids = []
|
||||
yield this.list.forEachSyncedDeletedGUID(guid => deletedGuids.push(guid));
|
||||
// This might be a huge line, but that's OK.
|
||||
log.trace("Have ${num} deleted item(s): ${deletedGuids}", {num: deletedGuids.length, deletedGuids});
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "PATCH",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "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 == 409) {
|
||||
// "Conflict": A change violated a uniqueness constraint. 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(false, "uploading a change", response);
|
||||
continue;
|
||||
}
|
||||
// Don't assume the local record and the server record aren't materially
|
||||
// different. Reconcile the differences.
|
||||
// TODO
|
||||
|
||||
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 = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "POST",
|
||||
path: "/articles",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "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. But if something goes wrong locally (eg, we upload but
|
||||
// get some error even though the upload worked) we will see this.
|
||||
// So allow 200 but log a warning.
|
||||
if (response.status == 200) {
|
||||
log.debug("Attempting to upload a new item found the server already had it", response);
|
||||
// but we still process it.
|
||||
} else if (response.status != 201) {
|
||||
this._handleUnexpectedResponse(false, "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.forEachSyncedDeletedGUID(guid => {
|
||||
requests.push({
|
||||
path: "/articles/" + guid,
|
||||
});
|
||||
});
|
||||
if (!requests.length) {
|
||||
log.debug("No local deleted synced items to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the request.
|
||||
let request = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "DELETE",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "uploading deleted items", batchResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete local items based on the response.
|
||||
for (let response of batchResponse.body.responses) {
|
||||
// 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(false, "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,
|
||||
};
|
||||
let response = yield this._sendRequest(request);
|
||||
if (response.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "downloading modified items", response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local items based on the response.
|
||||
for (let serverRecord of response.body.items) {
|
||||
if (serverRecord.deleted) {
|
||||
// _deleteItemForGUID is a no-op if no item exists with the GUID.
|
||||
yield this._deleteItemForGUID(serverRecord.id);
|
||||
continue;
|
||||
}
|
||||
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
|
||||
|
||||
yield this._updateItemWithServerRecord(localItem, serverRecord);
|
||||
continue;
|
||||
}
|
||||
// A potentially new item. addItem() will fail here when an item was
|
||||
// added to the local list between the time we uploaded new items and
|
||||
// now.
|
||||
let localRecord = localRecordFromServerRecord(serverRecord);
|
||||
try {
|
||||
yield this.list.addItem(localRecord);
|
||||
} catch (ex) {
|
||||
if (ex instanceof ReadingList.Error.Exists) {
|
||||
log.debug("Tried to add an item that already exists.");
|
||||
} else {
|
||||
log.error("Error adding an item from server record ${serverRecord} ${ex}",
|
||||
{ serverRecord, ex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now that changes have been successfully applied, advance the server
|
||||
// last-modified timestamp so that next time we fetch items starting from
|
||||
// the current point. Response header names are lowercase.
|
||||
if (response.headers && "last-modified" in response.headers) {
|
||||
this._serverLastModifiedHeader = response.headers["last-modified"];
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// The item may have been deleted from the local list between the time we
|
||||
// saw that it needed updating and now.
|
||||
log.debug("Tried to update a null local item from server record",
|
||||
serverRecord);
|
||||
return;
|
||||
}
|
||||
localItem._record = localRecordFromServerRecord(serverRecord);
|
||||
try {
|
||||
yield this.list.updateItem(localItem);
|
||||
} catch (ex) {
|
||||
// The item may have been deleted from the local list after we fetched it.
|
||||
if (ex instanceof ReadingList.Error.Deleted) {
|
||||
log.debug("Tried to update an item that was deleted from server record",
|
||||
serverRecord);
|
||||
} else {
|
||||
log.error("Error updating an item from server record ${serverRecord} ${ex}",
|
||||
{ serverRecord, ex });
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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;
|
||||
try {
|
||||
yield this.list.deleteItem(item);
|
||||
} catch (ex) {
|
||||
log.error("Failed delete local item with id ${guid} ${ex}",
|
||||
{ guid, ex });
|
||||
}
|
||||
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");
|
||||
try {
|
||||
this.list._store.deleteItemByGUID(guid);
|
||||
} catch (ex) {
|
||||
log.error("Failed to delete local item with id ${guid} ${ex}",
|
||||
{ guid, ex });
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return response;
|
||||
}),
|
||||
|
||||
/**
|
||||
* The server limits the number of sub-requests in POST /batch'es to
|
||||
* BATCH_REQUEST_LIMIT. This method takes an arbitrarily big batch request
|
||||
* and breaks it apart into many individual batch requests in order to stay
|
||||
* within the limit.
|
||||
*
|
||||
* @param bigRequest The same type of request object that _sendRequest takes.
|
||||
* Since it's a POST /batch request, its `body` should have a
|
||||
* `requests` property whose value is an array of sub-requests.
|
||||
* `method` and `path` are automatically filled.
|
||||
* @return Promise<response> Resolved when all requests complete with 200s, or
|
||||
* when the first response that is not a 200 is received. In the
|
||||
* first case, the resolved response is a combination of all the
|
||||
* server responses, and response.body.responses contains the sub-
|
||||
* responses for all the sub-requests in bigRequest. In the second
|
||||
* case, the resolved response is the non-200 response straight from
|
||||
* the server.
|
||||
*/
|
||||
_postBatch: Task.async(function* (bigRequest) {
|
||||
log.debug("Sending batch requests");
|
||||
let allSubResponses = [];
|
||||
let remainingSubRequests = bigRequest.body.requests;
|
||||
while (remainingSubRequests.length) {
|
||||
let request = Object.assign({}, bigRequest);
|
||||
request.method = "POST";
|
||||
request.path = "/batch";
|
||||
request.body.requests =
|
||||
remainingSubRequests.splice(0, BATCH_REQUEST_LIMIT);
|
||||
let response = yield this._sendRequest(request);
|
||||
if (response.status != 200) {
|
||||
return response;
|
||||
}
|
||||
allSubResponses = allSubResponses.concat(response.body.responses);
|
||||
}
|
||||
let bigResponse = {
|
||||
status: 200,
|
||||
body: {
|
||||
responses: allSubResponses,
|
||||
},
|
||||
};
|
||||
log.debug("All batch requests successfully sent");
|
||||
return bigResponse;
|
||||
}),
|
||||
|
||||
_handleUnexpectedResponse(isTopLevel, contextMsgFragment, response) {
|
||||
log.error(`Unexpected response ${contextMsgFragment}`, response);
|
||||
// We want to throw in some cases so the sync engine knows there was an
|
||||
// error and retries using the error schedule. 401 implies an auth issue
|
||||
// (possibly transient, possibly not) - but things like 404 might just
|
||||
// relate to a single item and need not throw. Any 5XX implies a
|
||||
// (hopefully transient) server error.
|
||||
if (isTopLevel && (response.status == 401 || response.status >= 500)) {
|
||||
throw new Error("Sync aborted due to " + response.status + " server 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;
|
||||
},
|
||||
});
|
@ -1,7 +0,0 @@
|
||||
# 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/.
|
||||
|
||||
browser.jar:
|
||||
content/browser/readinglist/sidebar.xhtml
|
||||
content/browser/readinglist/sidebar.js
|
@ -1,24 +0,0 @@
|
||||
# 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/.
|
||||
|
||||
JAR_MANIFESTS += ['jar.mn']
|
||||
|
||||
EXTRA_JS_MODULES.readinglist += [
|
||||
'ReadingList.jsm',
|
||||
'Scheduler.jsm',
|
||||
'ServerClient.jsm',
|
||||
'SQLiteStore.jsm',
|
||||
'Sync.jsm',
|
||||
]
|
||||
|
||||
TESTING_JS_MODULES += [
|
||||
'test/ReadingListTestUtils.jsm',
|
||||
]
|
||||
|
||||
BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
|
||||
|
||||
XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
|
||||
|
||||
with Files('**'):
|
||||
BUG_COMPONENT = ('Firefox', 'Reading List')
|
@ -1,484 +0,0 @@
|
||||
/* 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";
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = 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:///modules/readinglist/ReadingList.jsm");
|
||||
|
||||
let log = Cu.import("resource://gre/modules/Log.jsm", {})
|
||||
.Log.repository.getLogger("readinglist.sidebar");
|
||||
|
||||
|
||||
let RLSidebar = {
|
||||
/**
|
||||
* Container element for all list item elements.
|
||||
* @type {Element}
|
||||
*/
|
||||
list: null,
|
||||
|
||||
/**
|
||||
* A promise that's resolved when building the initial list completes.
|
||||
* @type {Promise}
|
||||
*/
|
||||
listPromise: null,
|
||||
|
||||
/**
|
||||
* <template> element used for constructing list item elements.
|
||||
* @type {Element}
|
||||
*/
|
||||
itemTemplate: null,
|
||||
|
||||
/**
|
||||
* Map of ReadingList Item objects, keyed by their ID.
|
||||
* @type {Map}
|
||||
*/
|
||||
itemsById: new Map(),
|
||||
/**
|
||||
* Map of list item elements, keyed by their corresponding Item's ID.
|
||||
* @type {Map}
|
||||
*/
|
||||
itemNodesById: new Map(),
|
||||
|
||||
/**
|
||||
* Initialize the sidebar UI.
|
||||
*/
|
||||
init() {
|
||||
log.debug("Initializing");
|
||||
|
||||
addEventListener("unload", () => this.uninit());
|
||||
|
||||
this.list = document.getElementById("list");
|
||||
this.emptyListInfo = document.getElementById("emptyListInfo");
|
||||
this.itemTemplate = document.getElementById("item-template");
|
||||
|
||||
// click events for middle-clicks are not sent to DOM nodes, only to the document.
|
||||
document.addEventListener("click", event => this.onClick(event));
|
||||
|
||||
this.list.addEventListener("mousemove", event => this.onListMouseMove(event));
|
||||
this.list.addEventListener("keydown", event => this.onListKeyDown(event), true);
|
||||
|
||||
window.addEventListener("message", event => this.onMessage(event));
|
||||
|
||||
this.listPromise = this.ensureListItems();
|
||||
ReadingList.addListener(this);
|
||||
|
||||
Services.prefs.setBoolPref("browser.readinglist.sidebarEverOpened", true);
|
||||
|
||||
let initEvent = new CustomEvent("Initialized", {bubbles: true});
|
||||
document.documentElement.dispatchEvent(initEvent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Un-initialize the sidebar UI.
|
||||
*/
|
||||
uninit() {
|
||||
log.debug("Shutting down");
|
||||
|
||||
ReadingList.removeListener(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an item being added to the ReadingList.
|
||||
* TODO: We may not want to show this new item right now.
|
||||
* TODO: We should guard against the list growing here.
|
||||
*
|
||||
* @param {ReadinglistItem} item - Item that was added.
|
||||
*/
|
||||
onItemAdded(item, append = false) {
|
||||
log.trace(`onItemAdded: ${item}`);
|
||||
|
||||
let itemNode = document.importNode(this.itemTemplate.content, true).firstElementChild;
|
||||
this.updateItem(item, itemNode);
|
||||
// XXX Inserting at the top by default is a temp hack that will stop
|
||||
// working once we start including items received from sync.
|
||||
if (append)
|
||||
this.list.appendChild(itemNode);
|
||||
else
|
||||
this.list.insertBefore(itemNode, this.list.firstChild);
|
||||
this.itemNodesById.set(item.id, itemNode);
|
||||
this.itemsById.set(item.id, item);
|
||||
|
||||
this.emptyListInfo.hidden = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
itemNode.classList.add('visible');
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an item being deleted from the ReadingList.
|
||||
* @param {ReadingListItem} item - Item that was deleted.
|
||||
*/
|
||||
onItemDeleted(item) {
|
||||
log.trace(`onItemDeleted: ${item}`);
|
||||
|
||||
let itemNode = this.itemNodesById.get(item.id);
|
||||
|
||||
this.itemNodesById.delete(item.id);
|
||||
this.itemsById.delete(item.id);
|
||||
|
||||
itemNode.addEventListener('transitionend', (event) => {
|
||||
if (event.propertyName == "max-height") {
|
||||
itemNode.remove();
|
||||
|
||||
// TODO: ensureListItems doesn't yet cope with needing to add one item.
|
||||
//this.ensureListItems();
|
||||
|
||||
this.emptyListInfo.hidden = (this.numItems > 0);
|
||||
}
|
||||
}, false);
|
||||
|
||||
itemNode.classList.remove('visible');
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an item in the ReadingList having any of its properties changed.
|
||||
* @param {ReadingListItem} item - Item that was updated.
|
||||
*/
|
||||
onItemUpdated(item) {
|
||||
log.trace(`onItemUpdated: ${item}`);
|
||||
|
||||
let itemNode = this.itemNodesById.get(item.id);
|
||||
if (!itemNode)
|
||||
return;
|
||||
|
||||
this.updateItem(item, itemNode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the element representing an item, ensuring it's in sync with the
|
||||
* underlying data.
|
||||
* @param {ReadingListItem} item - Item to use as a source.
|
||||
* @param {Element} itemNode - Element to update.
|
||||
*/
|
||||
updateItem(item, itemNode) {
|
||||
itemNode.setAttribute("id", "item-" + item.id);
|
||||
itemNode.setAttribute("title", `${item.title}\n${item.url}`);
|
||||
|
||||
itemNode.querySelector(".item-title").textContent = item.title;
|
||||
|
||||
let domain = item.uri.spec;
|
||||
try {
|
||||
domain = item.uri.host;
|
||||
}
|
||||
catch (err) {}
|
||||
itemNode.querySelector(".item-domain").textContent = domain;
|
||||
|
||||
let thumb = itemNode.querySelector(".item-thumb-container");
|
||||
if (item.preview) {
|
||||
thumb.style.backgroundImage = "url(" + item.preview + ")";
|
||||
} else {
|
||||
thumb.style.removeProperty("background-image");
|
||||
}
|
||||
thumb.classList.toggle("preview-available", !!item.preview);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure that the list is populated with the correct items.
|
||||
*/
|
||||
ensureListItems: Task.async(function* () {
|
||||
yield ReadingList.forEachItem(item => {
|
||||
// TODO: Should be batch inserting via DocumentFragment
|
||||
try {
|
||||
this.onItemAdded(item, true);
|
||||
} catch (e) {
|
||||
log.warn("Error adding item", e);
|
||||
}
|
||||
}, {sort: "addedOn", descending: true});
|
||||
this.emptyListInfo.hidden = (this.numItems > 0);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the number of items currently displayed in the list.
|
||||
* @type {number}
|
||||
*/
|
||||
get numItems() {
|
||||
return this.list.childElementCount;
|
||||
},
|
||||
|
||||
/**
|
||||
* The list item displayed in the current tab.
|
||||
* @type {Element}
|
||||
*/
|
||||
get activeItem() {
|
||||
return document.querySelector("#list > .item.active");
|
||||
},
|
||||
|
||||
set activeItem(node) {
|
||||
if (node && node.parentNode != this.list) {
|
||||
log.error(`Unable to set activeItem to invalid node ${node}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.trace(`Setting activeItem: ${node ? node.id : null}`);
|
||||
|
||||
if (node && node.classList.contains("active")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevItem = document.querySelector("#list > .item.active");
|
||||
if (prevItem) {
|
||||
prevItem.classList.remove("active");
|
||||
}
|
||||
|
||||
if (node) {
|
||||
node.classList.add("active");
|
||||
}
|
||||
|
||||
let event = new CustomEvent("ActiveItemChanged", {bubbles: true});
|
||||
this.list.dispatchEvent(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* The list item selected with the keyboard.
|
||||
* @type {Element}
|
||||
*/
|
||||
get selectedItem() {
|
||||
return document.querySelector("#list > .item.selected");
|
||||
},
|
||||
|
||||
set selectedItem(node) {
|
||||
if (node && node.parentNode != this.list) {
|
||||
log.error(`Unable to set selectedItem to invalid node ${node}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.trace(`Setting selectedItem: ${node ? node.id : null}`);
|
||||
|
||||
let prevItem = document.querySelector("#list > .item.selected");
|
||||
if (prevItem) {
|
||||
prevItem.classList.remove("selected");
|
||||
}
|
||||
|
||||
if (node) {
|
||||
node.classList.add("selected");
|
||||
let itemId = this.getItemIdFromNode(node);
|
||||
this.list.setAttribute("aria-activedescendant", "item-" + itemId);
|
||||
} else {
|
||||
this.list.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
let event = new CustomEvent("SelectedItemChanged", {bubbles: true});
|
||||
this.list.dispatchEvent(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* The index of the currently selected item in the list.
|
||||
* @type {number}
|
||||
*/
|
||||
get selectedIndex() {
|
||||
for (let i = 0; i < this.numItems; i++) {
|
||||
let item = this.list.children.item(i);
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
if (item.classList.contains("selected")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
set selectedIndex(index) {
|
||||
log.trace(`Setting selectedIndex: ${index}`);
|
||||
|
||||
if (index == -1) {
|
||||
this.selectedItem = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let item = this.list.children.item(index);
|
||||
if (!item) {
|
||||
log.warn(`Unable to set selectedIndex to invalid index ${index}`);
|
||||
return;
|
||||
}
|
||||
this.selectedItem = item;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open a given URL. The event is used to determine where it should be opened
|
||||
* (current tab, new tab, new window).
|
||||
* @param {string} url - URL to open.
|
||||
* @param {Event} event - KeyEvent or MouseEvent that triggered this action.
|
||||
*/
|
||||
openURL(url, event) {
|
||||
log.debug(`Opening page ${url}`);
|
||||
|
||||
let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Ci.nsIWebNavigation)
|
||||
.QueryInterface(Ci.nsIDocShellTreeItem)
|
||||
.rootTreeItem
|
||||
.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Ci.nsIDOMWindow);
|
||||
|
||||
let currentUrl = mainWindow.gBrowser.currentURI.spec;
|
||||
if (currentUrl.startsWith("about:reader"))
|
||||
url = "about:reader?url=" + encodeURIComponent(url);
|
||||
|
||||
mainWindow.openUILink(url, event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the ID of the Item associated with a given list item element.
|
||||
* @param {element} node - List item element to get an ID for.
|
||||
* @return {string} Assocated Item ID.
|
||||
*/
|
||||
getItemIdFromNode(node) {
|
||||
let id = node.getAttribute("id");
|
||||
if (id && id.startsWith("item-")) {
|
||||
return id.slice(5);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the Item associated with a given list item element.
|
||||
* @param {element} node - List item element to get an Item for.
|
||||
* @return {string} Associated Item.
|
||||
*/
|
||||
getItemFromNode(node) {
|
||||
let itemId = this.getItemIdFromNode(node);
|
||||
if (!itemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.itemsById.get(itemId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the active item in the list.
|
||||
* @param {Event} event - Event triggering this.
|
||||
*/
|
||||
openActiveItem(event) {
|
||||
let itemNode = this.activeItem;
|
||||
if (!itemNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let item = this.getItemFromNode(itemNode);
|
||||
this.openURL(item.url, event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find the parent item element, from a given child element.
|
||||
* @param {Element} node - Child element.
|
||||
* @return {Element} Element for the item, or null if not found.
|
||||
*/
|
||||
findParentItemNode(node) {
|
||||
while (node && node != this.list && node != document.documentElement &&
|
||||
!node.classList.contains("item")) {
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
if (node != this.list && node != document.documentElement) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a click event on the sidebar.
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onClick(event) {
|
||||
let itemNode = this.findParentItemNode(event.target);
|
||||
if (!itemNode)
|
||||
return;
|
||||
|
||||
if (event.target.classList.contains("remove-button")) {
|
||||
ReadingList.deleteItem(this.getItemFromNode(itemNode));
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeItem = itemNode;
|
||||
this.openActiveItem(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a mousemove event over the list box:
|
||||
* If the hovered item isn't the selected one, clear the selection.
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onListMouseMove(event) {
|
||||
let itemNode = this.findParentItemNode(event.target);
|
||||
if (itemNode != this.selectedItem)
|
||||
this.selectedItem = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a keydown event on the list box.
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onListKeyDown(event) {
|
||||
if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
|
||||
// TODO: Refactor this so we pass a direction to a generic method.
|
||||
// See autocomplete.xml's getNextIndex
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.numItems) {
|
||||
return;
|
||||
}
|
||||
let index = this.selectedIndex + 1;
|
||||
if (index >= this.numItems) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.selectedIndex = index;
|
||||
this.selectedItem.focus();
|
||||
} else if (event.keyCode == KeyEvent.DOM_VK_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.numItems) {
|
||||
return;
|
||||
}
|
||||
let index = this.selectedIndex - 1;
|
||||
if (index < 0) {
|
||||
index = this.numItems - 1;
|
||||
}
|
||||
|
||||
this.selectedIndex = index;
|
||||
this.selectedItem.focus();
|
||||
} else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
|
||||
let selectedItem = this.selectedItem;
|
||||
if (selectedItem) {
|
||||
this.activeItem = selectedItem;
|
||||
this.openActiveItem(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a message, typically sent from browser-readinglist.js
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onMessage(event) {
|
||||
let msg = event.data;
|
||||
|
||||
if (msg.topic != "UpdateActiveItem") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.url) {
|
||||
this.activeItem = null;
|
||||
} else {
|
||||
ReadingList.itemForURL(msg.url).then(item => {
|
||||
let node;
|
||||
if (item && (node = this.itemNodesById.get(item.id))) {
|
||||
this.activeItem = node;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
addEventListener("DOMContentLoaded", () => RLSidebar.init());
|
@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- 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/. -->
|
||||
|
||||
<!DOCTYPE html [
|
||||
<!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd">
|
||||
%browserDTD;
|
||||
]>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<script src="chrome://browser/content/readinglist/sidebar.js" type="application/javascript;version=1.8"></script>
|
||||
<link rel="stylesheet" type="text/css" media="all" href="chrome://browser/skin/readinglist/sidebar.css"/>
|
||||
<title>&readingList.label;</title>
|
||||
</head>
|
||||
|
||||
<body role="application">
|
||||
<template id="item-template">
|
||||
<div class="item" role="option" tabindex="-1">
|
||||
<div class="item-thumb-container"></div>
|
||||
<div class="item-summary-container">
|
||||
<div class="item-title-lines">
|
||||
<p class="item-title"/>
|
||||
<button class="remove-button" title="&readingList.sidebar.delete.tooltip;"/>
|
||||
</div>
|
||||
<div class="item-domain"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="emptyListInfo" hidden="true">&readingList.sidebar.emptyText;</div>
|
||||
<div id="list" role="listbox" tabindex="1"></div>
|
||||
</body>
|
||||
</html>
|
@ -1,169 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"ReadingListTestUtils",
|
||||
];
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
|
||||
|
||||
|
||||
/** Preference name controlling whether the ReadingList feature is enabled/disabled. */
|
||||
const PREF_RL_ENABLED = "browser.readinglist.enabled";
|
||||
|
||||
|
||||
/**
|
||||
* Utilities for testing the ReadingList sidebar.
|
||||
*/
|
||||
function SidebarUtils(window, assert) {
|
||||
this.window = window;
|
||||
this.Assert = assert;
|
||||
}
|
||||
|
||||
SidebarUtils.prototype = {
|
||||
/**
|
||||
* Reference to the RLSidebar object controlling the ReadingList sidebar UI.
|
||||
* @type {object}
|
||||
*/
|
||||
get RLSidebar() {
|
||||
return this.window.SidebarUI.browser.contentWindow.RLSidebar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reference to the list container element in the sidebar.
|
||||
* @type {Element}
|
||||
*/
|
||||
get list() {
|
||||
return this.RLSidebar.list;
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the sidebar and waits until it finishes building its list.
|
||||
* @return {Promise} Resolved when the sidebar's list is ready.
|
||||
*/
|
||||
showSidebar: Task.async(function* () {
|
||||
yield this.window.ReadingListUI.showSidebar();
|
||||
yield this.RLSidebar.listPromise;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Check that the number of elements in the list matches the expected count.
|
||||
* @param {number} count - Expected number of items.
|
||||
*/
|
||||
expectNumItems(count) {
|
||||
this.Assert.equal(this.list.childElementCount, count,
|
||||
"Should have expected number of items in the sidebar list");
|
||||
},
|
||||
|
||||
/**
|
||||
* Check all items in the sidebar list, ensuring the DOM matches the data.
|
||||
*/
|
||||
checkAllItems() {
|
||||
for (let itemNode of this.list.children) {
|
||||
this.checkSidebarItem(itemNode);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Run a series of sanity checks for an element in the list associated with
|
||||
* an Item, ensuring the DOM matches the data.
|
||||
*/
|
||||
checkItem(node) {
|
||||
let item = this.RLSidebar.getItemFromNode(node);
|
||||
|
||||
this.Assert.ok(node.classList.contains("item"),
|
||||
"Node should have .item class");
|
||||
this.Assert.equal(node.id, "item-" + item.id,
|
||||
"Node should have correct ID");
|
||||
this.Assert.equal(node.getAttribute("title"), item.title + "\n" + item.url.spec,
|
||||
"Node should have correct title attribute");
|
||||
this.Assert.equal(node.querySelector(".item-title").textContent, item.title,
|
||||
"Node's title element's text should match item title");
|
||||
|
||||
let domain = item.uri.spec;
|
||||
try {
|
||||
domain = item.uri.host;
|
||||
}
|
||||
catch (err) {}
|
||||
this.Assert.equal(node.querySelector(".item-domain").textContent, domain,
|
||||
"Node's domain element's text should match item title");
|
||||
},
|
||||
|
||||
expectSelectedId(itemId) {
|
||||
let selectedItem = this.RLSidebar.selectedItem;
|
||||
if (itemId == null) {
|
||||
this.Assert.equal(selectedItem, null, "Should have no selected item");
|
||||
} else {
|
||||
this.Assert.notEqual(selectedItem, null, "selectedItem should not be null");
|
||||
let selectedId = this.RLSidebar.getItemIdFromNode(selectedItem);
|
||||
this.Assert.equal(itemId, selectedId, "Should have currect item selected");
|
||||
}
|
||||
},
|
||||
|
||||
expectActiveId(itemId) {
|
||||
let activeItem = this.RLSidebar.activeItem;
|
||||
if (itemId == null) {
|
||||
this.Assert.equal(activeItem, null, "Should have no active item");
|
||||
} else {
|
||||
this.Assert.notEqual(activeItem, null, "activeItem should not be null");
|
||||
let activeId = this.RLSidebar.getItemIdFromNode(activeItem);
|
||||
this.Assert.equal(itemId, activeId, "Should have correct item active");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Utilities for testing the ReadingList.
|
||||
*/
|
||||
this.ReadingListTestUtils = {
|
||||
/**
|
||||
* Whether the ReadingList feature is enabled or not.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get enabled() {
|
||||
return Preferences.get(PREF_RL_ENABLED, false);
|
||||
},
|
||||
set enabled(value) {
|
||||
Preferences.set(PREF_RL_ENABLED, !!value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Utilities for testing the ReadingList sidebar.
|
||||
*/
|
||||
SidebarUtils: SidebarUtils,
|
||||
|
||||
/**
|
||||
* Synthetically add an item to the ReadingList.
|
||||
* @param {object|[object]} data - Object or array of objects to pass to the
|
||||
* Item constructor.
|
||||
* @return {Promise} Promise that gets fulfilled with the item or items added.
|
||||
*/
|
||||
addItem(data) {
|
||||
if (Array.isArray(data)) {
|
||||
let promises = [];
|
||||
for (let itemData of data) {
|
||||
promises.push(this.addItem(itemData));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
return ReadingList.addItem(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleanup all data, resetting to a blank state.
|
||||
*/
|
||||
cleanup: Task.async(function *() {
|
||||
Preferences.reset(PREF_RL_ENABLED);
|
||||
let items = [];
|
||||
yield ReadingList.forEachItem(i => items.push(i));
|
||||
for (let item of items) {
|
||||
yield ReadingList.deleteItem(item);
|
||||
}
|
||||
}),
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
[DEFAULT]
|
||||
support-files =
|
||||
head.js
|
||||
|
||||
[browser_ui_enable_disable.js]
|
||||
[browser_sidebar_list.js]
|
||||
;[browser_sidebar_mouse_nav.js]
|
@ -1,49 +0,0 @@
|
||||
/**
|
||||
* This tests the basic functionality of the sidebar to list items.
|
||||
*/
|
||||
|
||||
add_task(function*() {
|
||||
registerCleanupFunction(function*() {
|
||||
ReadingListUI.hideSidebar();
|
||||
yield RLUtils.cleanup();
|
||||
});
|
||||
|
||||
RLUtils.enabled = true;
|
||||
|
||||
yield RLSidebarUtils.showSidebar();
|
||||
let RLSidebar = RLSidebarUtils.RLSidebar;
|
||||
let sidebarDoc = SidebarUI.browser.contentDocument;
|
||||
Assert.equal(RLSidebar.numItems, 0, "Should start with no items");
|
||||
Assert.equal(RLSidebar.activeItem, null, "Should start with no active item");
|
||||
Assert.equal(RLSidebar.activeItem, null, "Should start with no selected item");
|
||||
|
||||
info("Adding first item");
|
||||
yield RLUtils.addItem({
|
||||
url: "http://example.com/article1",
|
||||
title: "Article 1",
|
||||
});
|
||||
RLSidebarUtils.expectNumItems(1);
|
||||
|
||||
info("Adding more items");
|
||||
yield RLUtils.addItem([{
|
||||
url: "http://example.com/article2",
|
||||
title: "Article 2",
|
||||
}, {
|
||||
url: "http://example.com/article3",
|
||||
title: "Article 3",
|
||||
}]);
|
||||
RLSidebarUtils.expectNumItems(3);
|
||||
|
||||
info("Closing sidebar");
|
||||
ReadingListUI.hideSidebar();
|
||||
|
||||
info("Adding another item");
|
||||
yield RLUtils.addItem({
|
||||
url: "http://example.com/article4",
|
||||
title: "Article 4",
|
||||
});
|
||||
|
||||
info("Re-opening sidebar");
|
||||
yield RLSidebarUtils.showSidebar();
|
||||
RLSidebarUtils.expectNumItems(4);
|
||||
});
|
@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Test mouse navigation for selecting items in the sidebar.
|
||||
*/
|
||||
|
||||
|
||||
function mouseInteraction(mouseEvent, responseEvent, itemNode) {
|
||||
let eventPromise = BrowserTestUtils.waitForEvent(RLSidebarUtils.list, responseEvent);
|
||||
let details = {};
|
||||
if (mouseEvent != "click") {
|
||||
details.type = mouseEvent;
|
||||
}
|
||||
|
||||
EventUtils.synthesizeMouseAtCenter(itemNode, details, itemNode.ownerDocument.defaultView);
|
||||
return eventPromise;
|
||||
}
|
||||
|
||||
add_task(function*() {
|
||||
registerCleanupFunction(function*() {
|
||||
ReadingListUI.hideSidebar();
|
||||
yield RLUtils.cleanup();
|
||||
});
|
||||
|
||||
RLUtils.enabled = true;
|
||||
|
||||
let itemData = [{
|
||||
url: "http://example.com/article1",
|
||||
title: "Article 1",
|
||||
}, {
|
||||
url: "http://example.com/article2",
|
||||
title: "Article 2",
|
||||
}, {
|
||||
url: "http://example.com/article3",
|
||||
title: "Article 3",
|
||||
}, {
|
||||
url: "http://example.com/article4",
|
||||
title: "Article 4",
|
||||
}, {
|
||||
url: "http://example.com/article5",
|
||||
title: "Article 5",
|
||||
}];
|
||||
info("Adding initial mock data");
|
||||
yield RLUtils.addItem(itemData);
|
||||
|
||||
info("Fetching items");
|
||||
let items = yield ReadingList.iterator({ sort: "url" }).items(itemData.length);
|
||||
|
||||
info("Opening sidebar");
|
||||
yield RLSidebarUtils.showSidebar();
|
||||
RLSidebarUtils.expectNumItems(5);
|
||||
RLSidebarUtils.expectSelectedId(null);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 1");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[0]);
|
||||
RLSidebarUtils.expectSelectedId(items[0].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 2");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[1]);
|
||||
RLSidebarUtils.expectSelectedId(items[1].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 5");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[4]);
|
||||
RLSidebarUtils.expectSelectedId(items[4].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 1 again");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[0]);
|
||||
RLSidebarUtils.expectSelectedId(items[0].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse click on item 1");
|
||||
yield mouseInteraction("click", "ActiveItemChanged", RLSidebarUtils.list.children[0]);
|
||||
RLSidebarUtils.expectSelectedId(items[0].id);
|
||||
RLSidebarUtils.expectActiveId(items[0].id);
|
||||
|
||||
info("Mouse click on item 3");
|
||||
yield mouseInteraction("click", "ActiveItemChanged", RLSidebarUtils.list.children[2]);
|
||||
RLSidebarUtils.expectSelectedId(items[2].id);
|
||||
RLSidebarUtils.expectActiveId(items[2].id);
|
||||
});
|
@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Test enabling/disabling the entire ReadingList feature via the
|
||||
* browser.readinglist.enabled preference.
|
||||
*/
|
||||
|
||||
function checkRLState() {
|
||||
let enabled = RLUtils.enabled;
|
||||
info("Checking ReadingList UI is " + (enabled ? "enabled" : "disabled"));
|
||||
|
||||
let sidebarBroadcaster = document.getElementById("readingListSidebar");
|
||||
let sidebarMenuitem = document.getElementById("menu_readingListSidebar");
|
||||
|
||||
let bookmarksMenubarItem = document.getElementById("menu_readingList");
|
||||
let bookmarksMenubarSeparator = document.getElementById("menu_readingListSeparator");
|
||||
|
||||
if (enabled) {
|
||||
Assert.notEqual(sidebarBroadcaster.getAttribute("hidden"), "true",
|
||||
"Sidebar broadcaster should not be hidden");
|
||||
Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"Sidebar menuitem should be visible");
|
||||
|
||||
// Currently disabled on OSX.
|
||||
if (bookmarksMenubarItem) {
|
||||
Assert.notEqual(bookmarksMenubarItem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks submenu in menubar should not be hidden");
|
||||
Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks separator in menubar should be visible");
|
||||
}
|
||||
} else {
|
||||
Assert.equal(sidebarBroadcaster.getAttribute("hidden"), "true",
|
||||
"Sidebar broadcaster should be hidden");
|
||||
Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"Sidebar menuitem should be hidden");
|
||||
Assert.equal(ReadingListUI.isSidebarOpen, false,
|
||||
"ReadingListUI should not think sidebar is open");
|
||||
|
||||
// Currently disabled on OSX.
|
||||
if (bookmarksMenubarItem) {
|
||||
Assert.equal(bookmarksMenubarItem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks submenu in menubar should not be hidden");
|
||||
Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks separator in menubar should be visible");
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
Assert.equal(SidebarUI.isOpen, false, "Sidebar should not be open");
|
||||
}
|
||||
}
|
||||
|
||||
add_task(function*() {
|
||||
info("Start with ReadingList disabled");
|
||||
RLUtils.enabled = false;
|
||||
checkRLState();
|
||||
info("Enabling ReadingList");
|
||||
RLUtils.enabled = true;
|
||||
checkRLState();
|
||||
|
||||
info("Opening ReadingList sidebar");
|
||||
yield ReadingListUI.showSidebar();
|
||||
Assert.ok(SidebarUI.isOpen, "Sidebar should be open");
|
||||
Assert.equal(SidebarUI.currentID, "readingListSidebar", "Sidebar should have ReadingList loaded");
|
||||
|
||||
info("Disabling ReadingList");
|
||||
RLUtils.enabled = false;
|
||||
Assert.ok(!SidebarUI.isOpen, "Sidebar should be closed");
|
||||
checkRLState();
|
||||
});
|
@ -1,13 +0,0 @@
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingListTestUtils",
|
||||
"resource://testing-common/ReadingListTestUtils.jsm");
|
||||
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "RLUtils", () => {
|
||||
return ReadingListTestUtils;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "RLSidebarUtils", () => {
|
||||
return new RLUtils.SidebarUtils(window, Assert);
|
||||
});
|
@ -1,56 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
do_get_profile(); // fxa needs a profile directory for storage.
|
||||
|
||||
Cu.import("resource://gre/modules/FxAccounts.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
|
||||
|
||||
// Create a mocked FxAccounts object with a signed-in, verified user.
|
||||
function* createMockFxA() {
|
||||
|
||||
function MockFxAccountsClient() {
|
||||
this._email = "nobody@example.com";
|
||||
this._verified = false;
|
||||
|
||||
this.accountStatus = function(uid) {
|
||||
let deferred = Promise.defer();
|
||||
deferred.resolve(!!uid && (!this._deletedOnServer));
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
this.signOut = function() { return Promise.resolve(); };
|
||||
|
||||
FxAccountsClient.apply(this);
|
||||
}
|
||||
|
||||
MockFxAccountsClient.prototype = {
|
||||
__proto__: FxAccountsClient.prototype
|
||||
}
|
||||
|
||||
function MockFxAccounts() {
|
||||
return new FxAccounts({
|
||||
fxAccountsClient: new MockFxAccountsClient(),
|
||||
getAssertion: () => Promise.resolve("assertion"),
|
||||
});
|
||||
}
|
||||
|
||||
let fxa = new MockFxAccounts();
|
||||
let credentials = {
|
||||
email: "foo@example.com",
|
||||
uid: "1234@lcip.org",
|
||||
assertion: "foobar",
|
||||
sessionToken: "dead",
|
||||
kA: "beef",
|
||||
kB: "cafe",
|
||||
verified: true
|
||||
};
|
||||
|
||||
yield fxa.setSignedInUser(credentials);
|
||||
return fxa;
|
||||
}
|
@ -1,782 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let gDBFile = do_get_profile();
|
||||
|
||||
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
|
||||
Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
|
||||
Cu.import("resource://gre/modules/Sqlite.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
|
||||
Log.repository.getLogger("readinglist.api").level = Log.Level.All;
|
||||
Log.repository.getLogger("readinglist.api").addAppender(new Log.DumpAppender());
|
||||
|
||||
var gList;
|
||||
var gItems;
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* prepare() {
|
||||
gList = ReadingList;
|
||||
Assert.ok(gList);
|
||||
gDBFile.append(gList._store.pathRelativeToProfileDir);
|
||||
do_register_cleanup(function* () {
|
||||
// Wait for the list's store to close its connection to the database.
|
||||
yield gList.destroy();
|
||||
if (gDBFile.exists()) {
|
||||
gDBFile.remove(true);
|
||||
}
|
||||
});
|
||||
|
||||
gItems = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
gItems.push({
|
||||
guid: `guid${i}`,
|
||||
url: `http://example.com/${i}`,
|
||||
resolvedURL: `http://example.com/resolved/${i}`,
|
||||
title: `title ${i}`,
|
||||
excerpt: `excerpt ${i}`,
|
||||
unread: 0,
|
||||
favorite: 0,
|
||||
isArticle: 1,
|
||||
storedOn: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
for (let item of gItems) {
|
||||
let addedItem = yield gList.addItem(item);
|
||||
checkItems(addedItem, item);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* item_properties() {
|
||||
// get an item
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
|
||||
Assert.ok(item.uri);
|
||||
Assert.ok(item.uri instanceof Ci.nsIURI);
|
||||
Assert.equal(item.uri.spec, item._record.url);
|
||||
|
||||
Assert.ok(item.resolvedURI);
|
||||
Assert.ok(item.resolvedURI instanceof Ci.nsIURI);
|
||||
Assert.equal(item.resolvedURI.spec, item._record.resolvedURL);
|
||||
|
||||
Assert.ok(item.addedOn);
|
||||
Assert.ok(item.addedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
|
||||
|
||||
Assert.ok(item.storedOn);
|
||||
Assert.ok(item.storedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
|
||||
|
||||
Assert.ok(typeof(item.favorite) == "boolean");
|
||||
Assert.ok(typeof(item.isArticle) == "boolean");
|
||||
Assert.ok(typeof(item.unread) == "boolean");
|
||||
|
||||
Assert.equal(item.id, hash(item._record.url));
|
||||
});
|
||||
|
||||
add_task(function* constraints() {
|
||||
// add an item again
|
||||
let err = null;
|
||||
try {
|
||||
yield gList.addItem(gItems[0]);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing guid
|
||||
let item = kindOfClone(gItems[0]);
|
||||
item.guid = gItems[0].guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing url
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.url = gItems[0].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing resolvedURL
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.resolvedURL = gItems[0].resolvedURL;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with no url
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.url;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// 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 ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// add an item with a bogus property
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.bogus = "gnarly";
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// add a new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
let rlitem1;
|
||||
try {
|
||||
rlitem1 = yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
|
||||
// add a second item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[1]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
let rlitem2;
|
||||
try {
|
||||
rlitem2 = yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
|
||||
// Delete the two previous items since other tests assume the store contains
|
||||
// only gItems.
|
||||
yield gList.deleteItem(rlitem1);
|
||||
yield gList.deleteItem(rlitem2);
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), { url: [rlitem1.uri.spec, rlitem2.uri.spec] });
|
||||
Assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
add_task(function* count() {
|
||||
let count = yield gList.count();
|
||||
Assert.equal(count, gItems.length);
|
||||
|
||||
count = yield gList.count({
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(count, 1);
|
||||
});
|
||||
|
||||
add_task(function* forEachItem() {
|
||||
// all items
|
||||
let items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// first item
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
limit: 1,
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// last item
|
||||
items = [];
|
||||
yield gList.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 gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems.map(i => i.guid),
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
items = [];
|
||||
yield gList.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 gList.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 gList.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]]);
|
||||
});
|
||||
|
||||
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 guids = [];
|
||||
yield gList.forEachSyncedDeletedGUID(guid => guids.push(guid));
|
||||
Assert.equal(guids.length, 1);
|
||||
Assert.equal(guids[0], deletedItem.guid);
|
||||
});
|
||||
|
||||
add_task(function* forEachItem_promises() {
|
||||
// promises resolved immediately
|
||||
let items = [];
|
||||
yield gList.forEachItem(item => {
|
||||
items.push(item);
|
||||
return Promise.resolve();
|
||||
}, {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// promises resolved after a delay
|
||||
items = [];
|
||||
let i = 0;
|
||||
let promises = [];
|
||||
yield gList.forEachItem(item => {
|
||||
items.push(item);
|
||||
// The previous promise should have been resolved by now.
|
||||
if (i > 0) {
|
||||
Assert.equal(promises[i - 1], null);
|
||||
}
|
||||
// Make a new promise that should continue iteration when resolved.
|
||||
let this_i = i++;
|
||||
let promise = new Promise(resolve => {
|
||||
// Resolve the promise one second from now. The idea is that if
|
||||
// forEachItem works correctly, then the callback should not be called
|
||||
// again before the promise resolves -- before one second elapases.
|
||||
// Maybe there's a better way to do this that doesn't hinge on timeouts.
|
||||
setTimeout(() => {
|
||||
promises[this_i] = null;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
promises.push(promise);
|
||||
return promise;
|
||||
}, {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
});
|
||||
|
||||
add_task(function* iterator_forEach() {
|
||||
// no limit
|
||||
let items = [];
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// limit one each time
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
for (let i = 0; i < gItems.length; i++) {
|
||||
yield iter.forEach(item => items.push(item), 1);
|
||||
checkItems(items, gItems.slice(0, i + 1));
|
||||
}
|
||||
yield iter.forEach(item => items.push(item), 100);
|
||||
checkItems(items, gItems);
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on a scalar property
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[1].guid,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* iterator_items() {
|
||||
// no limit
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let items = yield iter.items(gItems.length);
|
||||
checkItems(items, gItems);
|
||||
items = yield iter.items(100);
|
||||
checkItems(items, []);
|
||||
|
||||
// limit one each time
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
for (let i = 0; i < gItems.length; i++) {
|
||||
items = yield iter.items(1);
|
||||
checkItems(items, gItems.slice(i, i + 1));
|
||||
}
|
||||
items = yield iter.items(100);
|
||||
checkItems(items, []);
|
||||
|
||||
// match on a scalar property
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on an array
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[1].guid,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* iterator_forEach_promise() {
|
||||
// promises resolved immediately
|
||||
let items = [];
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => {
|
||||
items.push(item);
|
||||
return Promise.resolve();
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// promises resolved after a delay
|
||||
// See forEachItem_promises above for comments on this part.
|
||||
items = [];
|
||||
let i = 0;
|
||||
let promises = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => {
|
||||
items.push(item);
|
||||
if (i > 0) {
|
||||
Assert.equal(promises[i - 1], null);
|
||||
}
|
||||
let this_i = i++;
|
||||
let promise = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
promises[this_i] = null;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
promises.push(promise);
|
||||
return promise;
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
});
|
||||
|
||||
add_task(function* item() {
|
||||
let item = yield gList.item({ guid: gItems[0].guid });
|
||||
checkItems([item], [gItems[0]]);
|
||||
|
||||
item = yield gList.item({ guid: gItems[1].guid });
|
||||
checkItems([item], [gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* itemForURL() {
|
||||
let item = yield gList.itemForURL(gItems[0].url);
|
||||
checkItems([item], [gItems[0]]);
|
||||
|
||||
item = yield gList.itemForURL(gItems[1].url);
|
||||
checkItems([item], [gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* updateItem() {
|
||||
// get an item
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(items.length, 1);
|
||||
let item = items[0];
|
||||
|
||||
// update its title
|
||||
let newTitle = "updateItem new title";
|
||||
Assert.notEqual(item.title, newTitle);
|
||||
item.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
|
||||
// get the item again
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(items.length, 1);
|
||||
item = items[0];
|
||||
Assert.equal(item.title, newTitle);
|
||||
});
|
||||
|
||||
add_task(function* item_setRecord() {
|
||||
// get an item
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
|
||||
// Set item._record followed by an updateItem. After fetching the item again,
|
||||
// its title should be the new title.
|
||||
let newTitle = "item_setRecord title 1";
|
||||
item._record.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
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, newTitle);
|
||||
|
||||
// Set item.title directly and call updateItem. After fetching the item
|
||||
// again, its title should be the new title.
|
||||
newTitle = "item_setRecord title 2";
|
||||
item.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
Assert.equal(item.title, newTitle);
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
sameItem = (yield iter.items(1))[0];
|
||||
Assert.ok(item === sameItem);
|
||||
Assert.equal(sameItem.title, newTitle);
|
||||
|
||||
// Setting _record to an object with a bogus property should throw.
|
||||
let err = null;
|
||||
try {
|
||||
item._record = { bogus: "gnarly" };
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("Unrecognized item property:") >= 0);
|
||||
});
|
||||
|
||||
add_task(function* listeners() {
|
||||
Assert.equal((yield gList.count()), gItems.length);
|
||||
// add an item
|
||||
let resolve;
|
||||
let listenerPromise = new Promise(r => resolve = r);
|
||||
let listener = {
|
||||
onItemAdded: resolve,
|
||||
};
|
||||
gList.addListener(listener);
|
||||
let item = kindOfClone(gItems[0]);
|
||||
let items = yield Promise.all([listenerPromise, gList.addItem(item)]);
|
||||
Assert.ok(items[0]);
|
||||
Assert.ok(items[0] === items[1]);
|
||||
gList.removeListener(listener);
|
||||
Assert.equal((yield gList.count()), gItems.length + 1);
|
||||
|
||||
// update an item
|
||||
listenerPromise = new Promise(r => resolve = r);
|
||||
listener = {
|
||||
onItemUpdated: resolve,
|
||||
};
|
||||
gList.addListener(listener);
|
||||
items[0].title = "listeners new title";
|
||||
yield gList.updateItem(items[0]);
|
||||
let listenerItem = yield listenerPromise;
|
||||
Assert.ok(listenerItem);
|
||||
Assert.ok(listenerItem === items[0]);
|
||||
gList.removeListener(listener);
|
||||
Assert.equal((yield gList.count()), gItems.length + 1);
|
||||
|
||||
// delete an item
|
||||
listenerPromise = new Promise(r => resolve = r);
|
||||
listener = {
|
||||
onItemDeleted: resolve,
|
||||
};
|
||||
gList.addListener(listener);
|
||||
items[0].delete();
|
||||
listenerItem = yield listenerPromise;
|
||||
Assert.ok(listenerItem);
|
||||
Assert.ok(listenerItem === items[0]);
|
||||
gList.removeListener(listener);
|
||||
Assert.equal((yield gList.count()), gItems.length);
|
||||
});
|
||||
|
||||
// This test deletes items so it should probably run last of the 'gItems' tests...
|
||||
add_task(function* deleteItem() {
|
||||
// delete first item with item.delete()
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
let {url, guid} = item;
|
||||
Assert.ok((yield gList.itemForURL(url)), "should be able to get the item by URL before deletion");
|
||||
Assert.ok((yield gList.item({guid})), "should be able to get the item by GUID before deletion");
|
||||
|
||||
yield item.delete();
|
||||
try {
|
||||
yield item.delete();
|
||||
Assert.ok(false, "should not successfully delete the item a second time")
|
||||
} catch(ex) {
|
||||
Assert.ok(ex instanceof ReadingList.Error.Deleted);
|
||||
}
|
||||
|
||||
Assert.ok(!(yield gList.itemForURL(url)), "should fail to get a deleted item by URL");
|
||||
Assert.ok(!(yield gList.item({guid})), "should fail to get a deleted item by GUID");
|
||||
|
||||
gItems[0].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 1);
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(1));
|
||||
|
||||
// delete second item with list.deleteItem()
|
||||
yield gList.deleteItem(items[0]);
|
||||
try {
|
||||
yield gList.deleteItem(items[0]);
|
||||
Assert.ok(false, "should not successfully delete the item a second time")
|
||||
} catch(ex) {
|
||||
Assert.ok(ex instanceof ReadingList.Error.Deleted);
|
||||
}
|
||||
gItems[1].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 2);
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(2));
|
||||
|
||||
// delete third item with list.deleteItem()
|
||||
yield gList.deleteItem(items[0]);
|
||||
gItems[2].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 3);
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(3));
|
||||
});
|
||||
|
||||
// Check that when we delete an item with a GUID it's no longer available as
|
||||
// an item
|
||||
add_task(function* deletedItemRemovedFromMap() {
|
||||
yield gList.forEachItem(item => item.delete());
|
||||
Assert.equal((yield gList.count()), 0);
|
||||
let map = gList._itemsByNormalizedURL;
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
let record = {
|
||||
guid: "test-item",
|
||||
url: "http://localhost",
|
||||
syncStatus: gList.SyncStatus.SYNCED,
|
||||
}
|
||||
let item = yield gList.addItem(record);
|
||||
Assert.equal(map.size, 1);
|
||||
yield item.delete();
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
|
||||
// Now enumerate deleted items - should not come back.
|
||||
yield gList.forEachSyncedDeletedGUID(() => {});
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
});
|
||||
|
||||
function checkItems(actualItems, expectedItems) {
|
||||
Assert.equal(actualItems.length, expectedItems.length);
|
||||
for (let i = 0; i < expectedItems.length; i++) {
|
||||
for (let prop in expectedItems[i]._record) {
|
||||
Assert.ok(prop in actualItems[i]._record, prop);
|
||||
Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function kindOfClone(item) {
|
||||
let newItem = {};
|
||||
for (let prop in item) {
|
||||
newItem[prop] = item[prop];
|
||||
if (typeof(newItem[prop]) == "string") {
|
||||
newItem[prop] += " -- make this string different";
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
@ -1,333 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
|
||||
Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
|
||||
Cu.import("resource://gre/modules/Sqlite.jsm");
|
||||
|
||||
var gStore;
|
||||
var gItems;
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* prepare() {
|
||||
let basename = "reading-list-test.sqlite";
|
||||
let dbFile = do_get_profile();
|
||||
dbFile.append(basename);
|
||||
function removeDB() {
|
||||
if (dbFile.exists()) {
|
||||
dbFile.remove(true);
|
||||
}
|
||||
}
|
||||
removeDB();
|
||||
do_register_cleanup(function* () {
|
||||
// Wait for the store to close its connection to the database.
|
||||
yield gStore.destroy();
|
||||
removeDB();
|
||||
});
|
||||
|
||||
gStore = new SQLiteStore(dbFile.path);
|
||||
|
||||
gItems = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
gItems.push({
|
||||
guid: `guid${i}`,
|
||||
url: `http://example.com/${i}`,
|
||||
resolvedURL: `http://example.com/resolved/${i}`,
|
||||
title: `title ${i}`,
|
||||
excerpt: `excerpt ${i}`,
|
||||
unread: true,
|
||||
addedOn: i,
|
||||
});
|
||||
}
|
||||
|
||||
for (let item of gItems) {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* constraints() {
|
||||
// add an item again
|
||||
let err = null;
|
||||
try {
|
||||
yield gStore.addItem(gItems[0]);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists:") >= 0);
|
||||
|
||||
// add a new item with an existing guid
|
||||
function kindOfClone(item) {
|
||||
let newItem = {};
|
||||
for (let prop in item) {
|
||||
newItem[prop] = item[prop];
|
||||
if (typeof(newItem[prop]) == "string") {
|
||||
newItem[prop] += " -- make this string different";
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
let item = kindOfClone(gItems[0]);
|
||||
item.guid = gItems[0].guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: guid") >= 0);
|
||||
|
||||
// add a new item with an existing url
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.url = gItems[0].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: url") >= 0);
|
||||
|
||||
// update an item with an existing url
|
||||
item.guid = gItems[1].guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.updateItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
// The failure actually happens on items.guid, not items.url, because the item
|
||||
// is first looked up by url, and then its other properties are updated on the
|
||||
// resulting row.
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: guid") >= 0);
|
||||
|
||||
// add a new item with an existing resolvedURL
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.resolvedURL = gItems[0].resolvedURL;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: resolvedURL") >= 0);
|
||||
|
||||
// update an item with an existing resolvedURL
|
||||
item.url = gItems[1].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.updateItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: resolvedURL") >= 0);
|
||||
|
||||
// add a new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
let url1 = item.url;
|
||||
|
||||
// add a second new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[1]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
let url2 = item.url;
|
||||
|
||||
// Delete both items since other tests assume the store contains only gItems.
|
||||
yield gStore.deleteItemByURL(url1);
|
||||
yield gStore.deleteItemByURL(url2);
|
||||
let items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{ url: [url1, url2] }]);
|
||||
Assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
add_task(function* count() {
|
||||
let count = yield gStore.count();
|
||||
Assert.equal(count, gItems.length);
|
||||
|
||||
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), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems);
|
||||
|
||||
// first item
|
||||
items = [];
|
||||
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), [{
|
||||
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), [{
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
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), [{
|
||||
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), [{
|
||||
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), [{
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
sort: "guid",
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* updateItem() {
|
||||
let newTitle = "a new title";
|
||||
gItems[0].title = newTitle;
|
||||
yield gStore.updateItem(gItems[0]);
|
||||
let item;
|
||||
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);
|
||||
});
|
||||
|
||||
// This test deletes items so it should probably run last.
|
||||
add_task(function* deleteItemByURL() {
|
||||
// delete first item
|
||||
yield gStore.deleteItemByURL(gItems[0].url);
|
||||
Assert.equal((yield gStore.count()), gItems.length - 1);
|
||||
let items = [];
|
||||
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), [{
|
||||
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.deleteItemByGUID(gItems[2].guid);
|
||||
Assert.equal((yield gStore.count()), gItems.length - 3);
|
||||
let items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems.slice(3));
|
||||
});
|
||||
|
||||
function checkItems(actualItems, expectedItems) {
|
||||
Assert.equal(actualItems.length, expectedItems.length);
|
||||
for (let i = 0; i < expectedItems.length; i++) {
|
||||
for (let prop in expectedItems[i]) {
|
||||
Assert.ok(prop in actualItems[i], prop);
|
||||
Assert.equal(actualItems[i][prop], expectedItems[i][prop]);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,285 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://testing-common/httpd.js");
|
||||
Cu.import("resource:///modules/readinglist/ServerClient.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
|
||||
let appender = new Log.DumpAppender();
|
||||
for (let logName of ["FirefoxAccounts", "readinglist.serverclient"]) {
|
||||
Log.repository.getLogger(logName).addAppender(appender);
|
||||
}
|
||||
|
||||
// Some test servers we use.
|
||||
let Server = function(handlers) {
|
||||
this._server = null;
|
||||
this._handlers = handlers;
|
||||
}
|
||||
|
||||
Server.prototype = {
|
||||
start() {
|
||||
this._server = new HttpServer();
|
||||
for (let [path, handler] in Iterator(this._handlers)) {
|
||||
// httpd.js seems to swallow exceptions
|
||||
let thisHandler = handler;
|
||||
let wrapper = (request, response) => {
|
||||
try {
|
||||
thisHandler(request, response);
|
||||
} catch (ex) {
|
||||
print("**** Handler for", path, "failed:", ex, ex.stack);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
this._server.registerPathHandler(path, wrapper);
|
||||
}
|
||||
this._server.start(-1);
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise(resolve => {
|
||||
this._server.stop(resolve);
|
||||
this._server = null;
|
||||
});
|
||||
},
|
||||
|
||||
get host() {
|
||||
return "http://localhost:" + this._server.identity.primaryPort;
|
||||
},
|
||||
};
|
||||
|
||||
// An OAuth server that hands out tokens.
|
||||
function OAuthTokenServer() {
|
||||
let server;
|
||||
let handlers = {
|
||||
"/v1/authorization": (request, response) => {
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
let token = "token" + server.numTokenFetches;
|
||||
print("Test OAuth server handing out token", token);
|
||||
server.numTokenFetches += 1;
|
||||
server.activeTokens.add(token);
|
||||
response.write(JSON.stringify({access_token: token}));
|
||||
},
|
||||
"/v1/destroy": (request, response) => {
|
||||
// Getting the body seems harder than it should be!
|
||||
let sis = Cc["@mozilla.org/scriptableinputstream;1"]
|
||||
.createInstance(Ci.nsIScriptableInputStream);
|
||||
sis.init(request.bodyInputStream);
|
||||
let body = JSON.parse(sis.read(sis.available()));
|
||||
sis.close();
|
||||
let token = body.token;
|
||||
ok(server.activeTokens.delete(token));
|
||||
print("after destroy have", server.activeTokens.size, "tokens left.")
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write('{}');
|
||||
},
|
||||
}
|
||||
server = new Server(handlers);
|
||||
server.numTokenFetches = 0;
|
||||
server.activeTokens = new Set();
|
||||
return server;
|
||||
}
|
||||
|
||||
function promiseObserver(topic) {
|
||||
return new Promise(resolve => {
|
||||
function observe(subject, topic, data) {
|
||||
Services.obs.removeObserver(observe, topic);
|
||||
resolve(data);
|
||||
}
|
||||
Services.obs.addObserver(observe, topic, false);
|
||||
});
|
||||
}
|
||||
|
||||
// The tests.
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
// Arrange for the first token we hand out to be rejected - the client should
|
||||
// notice the 401 and silently get a new token and retry the request.
|
||||
add_task(function testAuthRetry() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
// We know the first token we will get is "token0", so we simulate that
|
||||
// "expiring" by only accepting "token1". Then we just echo the response
|
||||
// back.
|
||||
let authHeader;
|
||||
try {
|
||||
authHeader = request.getHeader("Authorization");
|
||||
} catch (ex) {}
|
||||
if (authHeader != "Bearer token1") {
|
||||
response.setStatusLine("1.1", 401, "Unauthorized");
|
||||
response.write("wrong token");
|
||||
return;
|
||||
}
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write(JSON.stringify({ok: true}));
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
let authServer = OAuthTokenServer();
|
||||
authServer.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", authServer.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
body: {foo: "bar"},
|
||||
});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
equal(authServer.numTokenFetches, 2, "took 2 tokens to get the 200")
|
||||
deepEqual(response.body, {ok: true});
|
||||
} finally {
|
||||
yield authServer.stop();
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that specified headers are seen by the server, and that server headers
|
||||
// in the response are seen by the client.
|
||||
add_task(function testHeaders() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
ok(request.hasHeader("x-foo"), "got our foo header");
|
||||
equal(request.getHeader("x-foo"), "bar", "foo header has the correct value");
|
||||
response.setHeader("Server-Sent-Header", "hello");
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
equal(response.headers["server-sent-header"], "hello", "got the server header");
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that a "backoff" header causes the correct notification.
|
||||
add_task(function testBackoffHeader() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
response.setHeader("Backoff", "123");
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
|
||||
let observerPromise = promiseObserver("readinglist:backoff-requested");
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
let data = yield observerPromise;
|
||||
equal(data, "123", "got the expected header value.")
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that a "backoff" header causes the correct notification.
|
||||
add_task(function testRetryAfterHeader() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
response.setHeader("Retry-After", "456");
|
||||
response.setStatusLine("1.1", 500, "Not OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
|
||||
let observerPromise = promiseObserver("readinglist:backoff-requested");
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 500, "got the 500 we expected");
|
||||
let data = yield observerPromise;
|
||||
equal(data, "456", "got the expected header value.")
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that unicode ends up as utf-8 in requests, and vice-versa in responses.
|
||||
// (Note the ServerClient assumes all strings in and out are UCS, and thus have
|
||||
// already been encoded/decoded (ie, it never expects to receive stuff already
|
||||
// utf-8 encoded, and never returns utf-8 encoded responses.)
|
||||
add_task(function testUTF8() {
|
||||
let handlers = {
|
||||
"/v1/hello": (request, response) => {
|
||||
// Get the body as bytes.
|
||||
let sis = Cc["@mozilla.org/scriptableinputstream;1"]
|
||||
.createInstance(Ci.nsIScriptableInputStream);
|
||||
sis.init(request.bodyInputStream);
|
||||
let body = sis.read(sis.available());
|
||||
sis.close();
|
||||
// The client sent "{"copyright: "\xa9"} where \xa9 is the copyright symbol.
|
||||
// It should have been encoded as utf-8 which is \xc2\xa9
|
||||
equal(body, '{"copyright":"\xc2\xa9"}', "server saw utf-8 encoded data");
|
||||
// and just write it back unchanged.
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write(body);
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let body = {copyright: "\xa9"}; // see above - \xa9 is the copyright symbol
|
||||
let response = yield sc.request({
|
||||
path: "/hello",
|
||||
method: "post",
|
||||
body: body
|
||||
});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
deepEqual(response.body, body);
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
@ -1,333 +0,0 @@
|
||||
/* 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);
|
||||
},
|
||||
|
||||
// There's a bug in pre-39's ES strict mode around forbidding the
|
||||
// redefinition of reserved keywords that flags defining `delete` on an
|
||||
// object as a syntax error. This weird syntax works around that.
|
||||
["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]);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,255 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
|
||||
// Setup logging prefs before importing the scheduler module.
|
||||
Services.prefs.setCharPref("readinglist.log.appender.dump", "Trace");
|
||||
|
||||
let {createTestableScheduler} = Cu.import("resource:///modules/readinglist/Scheduler.jsm", {});
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
|
||||
// Log rotation needs a profile dir.
|
||||
do_get_profile();
|
||||
|
||||
let prefs = new Preferences("readinglist.scheduler.");
|
||||
prefs.set("enabled", true);
|
||||
|
||||
function promiseObserver(topic) {
|
||||
return new Promise(resolve => {
|
||||
let obs = (subject, topic, data) => {
|
||||
Services.obs.removeObserver(obs, topic);
|
||||
resolve(data);
|
||||
}
|
||||
Services.obs.addObserver(obs, topic, false);
|
||||
});
|
||||
}
|
||||
|
||||
function ReadingListMock() {
|
||||
this.listener = null;
|
||||
}
|
||||
|
||||
ReadingListMock.prototype = {
|
||||
addListener(listener) {
|
||||
ok(!this.listener, "mock only expects 1 listener");
|
||||
this.listener = listener;
|
||||
},
|
||||
}
|
||||
|
||||
function createScheduler(options) {
|
||||
// avoid typos in the test and other footguns in the options.
|
||||
let allowedOptions = ["expectedDelay", "expectNewTimer", "syncFunction"];
|
||||
for (let key of Object.keys(options)) {
|
||||
if (allowedOptions.indexOf(key) == -1) {
|
||||
throw new Error("Invalid option " + key);
|
||||
}
|
||||
}
|
||||
let rlMock = new ReadingListMock();
|
||||
let scheduler = createTestableScheduler(rlMock);
|
||||
// make our hooks
|
||||
let syncFunction = options.syncFunction || Promise.resolve;
|
||||
scheduler._engine.start = syncFunction;
|
||||
// we expect _setTimeout to be called *twice* - first is the initial sync,
|
||||
// and there's no need to test the delay used for that. options.expectedDelay
|
||||
// is to check the *subsequent* timer.
|
||||
let numCalls = 0;
|
||||
scheduler._setTimeout = function(delay) {
|
||||
++numCalls;
|
||||
print("Test scheduler _setTimeout call number " + numCalls + " with delay=" + delay);
|
||||
switch (numCalls) {
|
||||
case 1:
|
||||
// this is the first and boring schedule as it initializes - do nothing
|
||||
// other than return a timer that fires immediately.
|
||||
return setTimeout(() => scheduler._doSync(), 0);
|
||||
break;
|
||||
case 2:
|
||||
// This is the one we are interested in, so check things.
|
||||
if (options.expectedDelay) {
|
||||
// a little slop is OK as it takes a few ms to actually set the timer
|
||||
ok(Math.abs(options.expectedDelay * 1000 - delay) < 500, [options.expectedDelay * 1000, delay]);
|
||||
}
|
||||
// and return a timeout that "never" fires
|
||||
return setTimeout(() => scheduler._doSync(), 10000000);
|
||||
break;
|
||||
default:
|
||||
// This is unexpected!
|
||||
ok(false, numCalls);
|
||||
}
|
||||
};
|
||||
// And a callback made once we've determined the next delay. This is always
|
||||
// called even if _setTimeout isn't (due to no timer being created)
|
||||
scheduler._onAutoReschedule = () => {
|
||||
// Most tests expect a new timer, so this is "opt out"
|
||||
let expectNewTimer = options.expectNewTimer === undefined ? true : options.expectNewTimer;
|
||||
ok(expectNewTimer ? scheduler._timer : !scheduler._timer);
|
||||
}
|
||||
// calling .init fires things off...
|
||||
scheduler.init();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
add_task(function* testSuccess() {
|
||||
// promises which resolve once we've got all the expected notifications.
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:finish"),
|
||||
];
|
||||
// New delay should be "as regularly scheduled".
|
||||
prefs.set("schedule", 100);
|
||||
let scheduler = createScheduler({expectedDelay: 100});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
// Test that if we get a reading list notification while we are syncing we
|
||||
// immediately start a new one when it complets.
|
||||
add_task(function* testImmediateResyncWhenChangedDuringSync() {
|
||||
// promises which resolve once we've got all the expected notifications.
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:finish"),
|
||||
];
|
||||
prefs.set("schedule", 100);
|
||||
// New delay should be "immediate".
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 0,
|
||||
syncFunction: () => {
|
||||
// we are now syncing - pretend the readinglist has an item change
|
||||
scheduler.readingList.listener.onItemAdded();
|
||||
return Promise.resolve();
|
||||
}});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testOffline() {
|
||||
let scheduler = createScheduler({expectNewTimer: false});
|
||||
Services.io.offline = true;
|
||||
ok(!scheduler._canSync(), "_canSync is false when offline.")
|
||||
ok(!scheduler._timer, "there is no current timer while offline.")
|
||||
Services.io.offline = false;
|
||||
ok(scheduler._canSync(), "_canSync is true when online.")
|
||||
ok(scheduler._timer, "there is a new timer when back online.")
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testRetryableError() {
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:error"),
|
||||
];
|
||||
prefs.set("retry", 10);
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 10,
|
||||
syncFunction: () => Promise.reject("transient"),
|
||||
});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testAuthError() {
|
||||
prefs.set("retry", 10);
|
||||
// We expect an auth error to result in no new timer (as it's waiting for
|
||||
// some indication it can proceed), but with the next delay being a normal
|
||||
// "retry" interval (so when we can proceed it is probably already stale, so
|
||||
// is effectively "immediate")
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 10,
|
||||
syncFunction: () => {
|
||||
return Promise.reject(ReadingListScheduler._engine.ERROR_AUTHENTICATION);
|
||||
},
|
||||
expectNewTimer: false
|
||||
});
|
||||
// XXX - TODO - send an observer that "unblocks" us and ensure we actually
|
||||
// do unblock.
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testBackoff() {
|
||||
let scheduler = createScheduler({expectedDelay: 1000});
|
||||
Services.obs.notifyObservers(null, "readinglist:backoff-requested", 1000);
|
||||
// XXX - this needs a little love as nothing checks createScheduler actually
|
||||
// made the checks we think it does.
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function testErrorBackoff() {
|
||||
// This test can't sanely use the "test scheduler" above, so make one more
|
||||
// suited.
|
||||
let rlMock = new ReadingListMock();
|
||||
let scheduler = createTestableScheduler(rlMock);
|
||||
scheduler._setTimeout = function(delay) {
|
||||
// create a timer that fires immediately
|
||||
return setTimeout(() => scheduler._doSync(), 0);
|
||||
}
|
||||
|
||||
// This does all the work...
|
||||
function checkBackoffs(expectedSequences) {
|
||||
let orig_maybeReschedule = scheduler._maybeReschedule;
|
||||
return new Promise(resolve => {
|
||||
let isSuccess = true; // ie, first run will put us in "fail" mode.
|
||||
let expected;
|
||||
function nextSequence() {
|
||||
if (expectedSequences.length == 0) {
|
||||
resolve();
|
||||
return true; // we are done.
|
||||
}
|
||||
// setup the current set of expected results.
|
||||
expected = expectedSequences.shift()
|
||||
// and toggle the success status of the engine.
|
||||
isSuccess = !isSuccess;
|
||||
if (isSuccess) {
|
||||
scheduler._engine.start = Promise.resolve;
|
||||
} else {
|
||||
scheduler._engine.start = () => {
|
||||
return Promise.reject(new Error("oh no"))
|
||||
}
|
||||
}
|
||||
return false; // not done.
|
||||
};
|
||||
// get the first sequence;
|
||||
nextSequence();
|
||||
// and setup the scheduler to check the sequences.
|
||||
scheduler._maybeReschedule = function(nextDelay) {
|
||||
let thisExpected = expected.shift();
|
||||
equal(thisExpected * 1000, nextDelay);
|
||||
if (expected.length == 0) {
|
||||
if (nextSequence()) {
|
||||
// we are done, so do nothing.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// call the original impl to get the next schedule.
|
||||
return orig_maybeReschedule.call(scheduler, nextDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prefs.set("schedule", 100);
|
||||
prefs.set("retry", 5);
|
||||
// The sequences of timeouts we expect as the Sync error state changes.
|
||||
let backoffsChecked = checkBackoffs([
|
||||
// first sequence is in failure mode - expect the timeout to double until 'schedule'
|
||||
[5, 10, 20, 40, 80, 100, 100],
|
||||
// Sync just started working - more 'schedule'
|
||||
[100, 100],
|
||||
// Just stopped again - error backoff process restarts.
|
||||
[5, 10],
|
||||
// Another success and we are back to 'schedule'
|
||||
[100, 100],
|
||||
]);
|
||||
|
||||
// fire things off.
|
||||
scheduler.init();
|
||||
|
||||
// and wait for completion.
|
||||
yield backoffsChecked;
|
||||
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
[DEFAULT]
|
||||
head = head.js
|
||||
firefox-appdir = browser
|
||||
|
||||
[test_ReadingList.js]
|
||||
[test_ServerClient.js]
|
||||
[test_scheduler.js]
|
||||
[test_SQLiteStore.js]
|
||||
[test_Sync.js]
|
@ -379,9 +379,10 @@ this.UITour = {
|
||||
},
|
||||
|
||||
onLocationChange: function(aLocation) {
|
||||
// The ReadingList/ReaderView tour page is expected to run in Reader View,
|
||||
// The ReaderView tour page is expected to run in Reader View,
|
||||
// which disables JavaScript on the page. To get around that, we
|
||||
// automatically start a pre-defined tour on page load.
|
||||
// automatically start a pre-defined tour on page load (for hysterical
|
||||
// raisins the ReaderView tour is known as "readinglist")
|
||||
let originalUrl = ReaderMode.getOriginalUrl(aLocation);
|
||||
if (this._readerViewTriggerRegEx.test(originalUrl)) {
|
||||
this.startSubTour("readinglist");
|
||||
|
@ -864,18 +864,6 @@ you can use these alternative items. Otherwise, their values should be empty. -
|
||||
<!ENTITY emeNotificationsDontAskAgain.label "Don't ask me again">
|
||||
<!ENTITY emeNotificationsDontAskAgain.accesskey "D">
|
||||
|
||||
<!ENTITY readingList.label "Reading List">
|
||||
<!ENTITY readingList.sidebar.commandKey "R">
|
||||
<!ENTITY readingList.showSidebar.label "Show Reading List Sidebar">
|
||||
<!-- Pre-landed string for bug 1124153 -->
|
||||
<!ENTITY readingList.sidebar.showMore.label "Show more…">
|
||||
<!-- Pre-landed string for bug 1133662 -->
|
||||
<!ENTITY readingList.sidebar.emptyText "Add articles to your Reading List to save them for later and find them easily when you need them.">
|
||||
<!ENTITY readingList.sidebar.delete.tooltip "Remove this from your Reading List">
|
||||
<!-- Pre-landed strings for bug 1123519 -->
|
||||
<!ENTITY readingList.sidebar.add.label "Add to Reading List">
|
||||
<!ENTITY readingList.sidebar.add.tooltip "Add this page to your Reading List">
|
||||
|
||||
<!-- LOCALIZATION NOTE (saveToPocketCmd.label, saveLinkToPocketCmd.label, pocketMenuitem.label): Pocket is a brand name -->
|
||||
<!ENTITY saveToPocketCmd.label "Save Page to Pocket">
|
||||
<!ENTITY saveToPocketCmd.accesskey "k">
|
||||
|
@ -736,82 +736,10 @@ appmenu.updateFailed.description = Background update failed, please download upd
|
||||
appmenu.restartBrowserButton.label = Restart %S
|
||||
appmenu.downloadUpdateButton.label = Download Update
|
||||
|
||||
# LOCALIZATION NOTE : FILE Reading List and Reader View are feature names and therefore typically used as proper nouns.
|
||||
# LOCALIZATION NOTE : FILE Reader View is a feature name and therefore typically used as a proper noun.
|
||||
|
||||
# Pre-landed string for bug 1124153
|
||||
# LOCALIZATION NOTE(readingList.sidebar.showMore.tooltip): %S is the number of items that will be added by clicking this button
|
||||
# Semicolon-separated list of plural forms. See:
|
||||
# http://developer.mozilla.org/en/docs/Localization_and_Plurals
|
||||
readingList.sidebar.showMore.tooltip = Show %S more item;Show %S more items
|
||||
# Pre-landed strings for bug 1131457 / bug 1131461
|
||||
readingList.urlbar.add = Add page to Reading List
|
||||
readingList.urlbar.addDone = Page added to Reading List
|
||||
readingList.urlbar.remove = Remove page from Reading List
|
||||
readingList.urlbar.removeDone = Page removed from Reading List
|
||||
# Pre-landed strings for bug 1133610 & bug 1133611
|
||||
# LOCALIZATION NOTE(readingList.promo.noSync.label): %S a link, using the text from readingList.promo.noSync.link
|
||||
readingList.promo.noSync.label = Access your Reading List on all your devices. %S
|
||||
# LOCALIZATION NOTE(readingList.promo.noSync.link): %S is syncBrandShortName
|
||||
readingList.promo.noSync.link = Get started with %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.hasSync.label): %S is syncBrandShortName
|
||||
readingList.promo.hasSync.label = You can now access your Reading List on all your devices connected by %S.
|
||||
|
||||
# Pre-landed strings for bug 1136570
|
||||
readerView.promo.firstDetectedArticle.title = Read and save articles easily
|
||||
readerView.promo.firstDetectedArticle.body = Click the book to make articles easier to read and use the plus to save them for later.
|
||||
readingList.promo.firstUse.exitTourButton = Close
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.tourDoneButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.tourDoneButton = Start Reading »
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readingList.multipleStepsTitle):
|
||||
# This is used when there are multiple steps in the tour.
|
||||
# %1$S is the current step's title (readingList.promo.firstUse.*.title), %2$S is the current step number of the tour, %3$S is the total number of steps.
|
||||
readingList.promo.firstUse.multipleStepsTitle = %1$S (%2$S/%3$S)
|
||||
readingList.promo.firstUse.readingList.title = Reading List
|
||||
readingList.promo.firstUse.readingList.body = Save articles for later and find them easily when you need them.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readingList.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.readingList.moveToButton = Next: Easy finding »
|
||||
readingList.promo.firstUse.readerView.title = Reader View
|
||||
readingList.promo.firstUse.readerView.body = Remove clutter so you can focus exactly on what you want to read.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readerView.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.readerView.moveToButton = Next: Easy reading »
|
||||
readingList.promo.firstUse.syncNotSignedIn.title = Sync
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncNotSignedIn.body): %S is brandShortName
|
||||
readingList.promo.firstUse.syncNotSignedIn.body = Sign in to access your Reading List everywhere you use %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncNotSignedIn.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.syncNotSignedIn.moveToButton = Next: Easy access »
|
||||
readingList.promo.firstUse.syncSignedIn.title = Sync
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncSignedIn.body): %S is brandShortName
|
||||
readingList.promo.firstUse.syncSignedIn.body = Open your Reading List articles everywhere you use %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncSignedIn.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.syncSignedIn.moveToButton = Next: Easy access »
|
||||
|
||||
# Pre-landed strings for bug 1136570
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.learnMore):
|
||||
# This will show as an item in the Reading List, and will link to a page that explains and shows how the Reading List and Reader View works.
|
||||
# This will be staged at:
|
||||
# https://www.allizom.org/firefox/reading/start/
|
||||
# And eventually available at:
|
||||
# https://www.mozilla.org/firefox/reading/start/
|
||||
# %S is brandShortName
|
||||
readingList.prepopulatedArticles.learnMore = Learn how %S makes reading more pleasant
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.supportReadingList):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing the Reading List:
|
||||
# https://support.mozilla.org/kb/save-sync-and-read-pages-anywhere-reading-list
|
||||
readingList.prepopulatedArticles.supportReadingList = Save, sync and read pages anywhere with Reading List
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.supportReaderView):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing the Reader View:
|
||||
# https://support.mozilla.org/kb/enjoy-clutter-free-web-pages-reader-view
|
||||
readingList.prepopulatedArticles.supportReaderView = Enjoy clutter-free Web pages with Reader View
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.learnMore):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing Sync:
|
||||
# https://support.mozilla.org/kb/how-do-i-set-up-firefox-sync
|
||||
# %S is syncBrandShortName
|
||||
readingList.prepopulatedArticles.supportSync = Access your Reading List anywhere with %S
|
||||
|
||||
# LOCALIZATION NOTE (e10s.offerPopup.mainMessage
|
||||
# e10s.offerPopup.highlight1
|
||||
|
@ -26,8 +26,6 @@
|
||||
<!ENTITY syncMy.label "Sync My">
|
||||
<!ENTITY engine.bookmarks.label "Bookmarks">
|
||||
<!ENTITY engine.bookmarks.accesskey "m">
|
||||
<!ENTITY engine.readinglist.label "Reading List">
|
||||
<!ENTITY engine.readinglist.accesskey "L">
|
||||
<!ENTITY engine.tabs.label "Tabs">
|
||||
<!ENTITY engine.tabs.accesskey "T">
|
||||
<!ENTITY engine.history.label "History">
|
||||
|
@ -15,8 +15,6 @@
|
||||
-->
|
||||
<!ENTITY engine.bookmarks.label "Bookmarks">
|
||||
<!ENTITY engine.bookmarks.accesskey "m">
|
||||
<!ENTITY engine.readinglist.label "Reading List">
|
||||
<!ENTITY engine.readinglist.accesskey "L">
|
||||
<!ENTITY engine.history.label "History">
|
||||
<!ENTITY engine.history.accesskey "r">
|
||||
<!ENTITY engine.tabs.label "Tabs">
|
||||
|
@ -16,7 +16,6 @@ Cu.import("resource://gre/modules/Task.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils","resource://gre/modules/PlacesUtils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList", "resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm");
|
||||
|
||||
const gStringBundle = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
|
||||
@ -48,23 +47,6 @@ let ReaderParent = {
|
||||
|
||||
receiveMessage: function(message) {
|
||||
switch (message.name) {
|
||||
case "Reader:AddToList": {
|
||||
let article = message.data.article;
|
||||
ReadingList.getMetadataFromBrowser(message.target).then(function(metadata) {
|
||||
if (metadata.previews.length > 0) {
|
||||
article.preview = metadata.previews[0];
|
||||
}
|
||||
|
||||
ReadingList.addItem({
|
||||
url: article.url,
|
||||
title: article.title,
|
||||
excerpt: article.excerpt,
|
||||
preview: article.preview
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "Reader:AddToPocket": {
|
||||
let doc = message.target.ownerDocument;
|
||||
let pocketWidget = doc.getElementById("pocket-button");
|
||||
@ -114,24 +96,6 @@ let ReaderParent = {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Reader:ListStatusRequest":
|
||||
ReadingList.hasItemForURL(message.data.url).then(inList => {
|
||||
let mm = message.target.messageManager
|
||||
// Make sure the target browser is still alive before trying to send data back.
|
||||
if (mm) {
|
||||
mm.sendAsyncMessage("Reader:ListStatusData",
|
||||
{ inReadingList: inList, url: message.data.url });
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "Reader:RemoveFromList":
|
||||
// We need to get the "real" item to delete it.
|
||||
ReadingList.itemForURL(message.data.url).then(item => {
|
||||
ReadingList.deleteItem(item)
|
||||
});
|
||||
break;
|
||||
|
||||
case "Reader:Share":
|
||||
// XXX: To implement.
|
||||
break;
|
||||
|
@ -520,11 +520,6 @@ menuitem:not([type]):not(.menuitem-tooltip):not(.menuitem-iconic-tooltip) {
|
||||
list-style-image: url("chrome://browser/skin/places/unsortedBookmarks.png");
|
||||
}
|
||||
|
||||
#menu_readingList,
|
||||
#BMB_readingList {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/readinglist-icon.svg");
|
||||
}
|
||||
|
||||
#panelMenu_pocket,
|
||||
#menu_pocket,
|
||||
#BMB_pocket {
|
||||
@ -1287,8 +1282,6 @@ richlistitem[type~="action"][actiontype="switchtab"] > .ac-url-box > .ac-action-
|
||||
list-style-image: url("chrome://browser/skin/Info.png");
|
||||
}
|
||||
|
||||
%include ../shared/readinglist/readinglist.inc.css
|
||||
|
||||
/* Reader mode button */
|
||||
|
||||
#reader-mode-button {
|
||||
|
@ -113,9 +113,6 @@ browser.jar:
|
||||
skin/classic/browser/reader-tour.png (../shared/reader/reader-tour.png)
|
||||
skin/classic/browser/reader-tour@2x.png (../shared/reader/reader-tour@2x.png)
|
||||
skin/classic/browser/readerMode.svg (../shared/reader/readerMode.svg)
|
||||
skin/classic/browser/readinglist/icons.svg (../shared/readinglist/icons.svg)
|
||||
skin/classic/browser/readinglist/readinglist-icon.svg (../shared/readinglist/readinglist-icon.svg)
|
||||
* skin/classic/browser/readinglist/sidebar.css (readinglist/sidebar.css)
|
||||
skin/classic/browser/webRTC-shareDevice-16.png (../shared/webrtc/webRTC-shareDevice-16.png)
|
||||
skin/classic/browser/webRTC-shareDevice-16@2x.png (../shared/webrtc/webRTC-shareDevice-16@2x.png)
|
||||
skin/classic/browser/webRTC-shareDevice-64.png (../shared/webrtc/webRTC-shareDevice-64.png)
|
||||
|
@ -1,41 +0,0 @@
|
||||
/* 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/. */
|
||||
|
||||
%include ../../shared/readinglist/sidebar.inc.css
|
||||
|
||||
html {
|
||||
border: 1px solid ThreeDShadow;
|
||||
background-color: -moz-Field;
|
||||
color: -moz-FieldText;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.item {
|
||||
-moz-padding-end: 0;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background-color: -moz-cellhighlight;
|
||||
color: -moz-cellhighlighttext;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 1px 0 0;
|
||||
}
|
||||
|
||||
.item-title, .item-domain {
|
||||
-moz-margin-end: 6px;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 16, 16, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 32, 16, 16);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 48, 16, 32);
|
||||
}
|
@ -567,11 +567,6 @@ toolbarpaletteitem[place="palette"] > #personal-bookmarks > #bookmarks-toolbar-p
|
||||
}
|
||||
}
|
||||
|
||||
/* #menu_readingList, svg icons don't work in the mac native menubar */
|
||||
#BMB_readingList {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/readinglist-icon.svg");
|
||||
}
|
||||
|
||||
#panelMenu_pocket,
|
||||
#menu_pocket,
|
||||
#BMB_pocket {
|
||||
@ -2022,8 +2017,6 @@ richlistitem[type~="action"][actiontype="switchtab"][selected="true"] > .ac-url-
|
||||
}
|
||||
}
|
||||
|
||||
%include ../shared/readinglist/readinglist.inc.css
|
||||
|
||||
/* Reader mode button */
|
||||
|
||||
#reader-mode-button {
|
||||
|
@ -150,9 +150,6 @@ browser.jar:
|
||||
skin/classic/browser/reader-tour.png (../shared/reader/reader-tour.png)
|
||||
skin/classic/browser/reader-tour@2x.png (../shared/reader/reader-tour@2x.png)
|
||||
skin/classic/browser/readerMode.svg (../shared/reader/readerMode.svg)
|
||||
skin/classic/browser/readinglist/icons.svg (../shared/readinglist/icons.svg)
|
||||
skin/classic/browser/readinglist/readinglist-icon.svg (../shared/readinglist/readinglist-icon.svg)
|
||||
* skin/classic/browser/readinglist/sidebar.css (readinglist/sidebar.css)
|
||||
skin/classic/browser/webRTC-shareDevice-16.png (../shared/webrtc/webRTC-shareDevice-16.png)
|
||||
skin/classic/browser/webRTC-shareDevice-16@2x.png (../shared/webrtc/webRTC-shareDevice-16@2x.png)
|
||||
skin/classic/browser/webRTC-shareDevice-64.png (../shared/webrtc/webRTC-shareDevice-64.png)
|
||||
|
@ -1,39 +0,0 @@
|
||||
/* 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/. */
|
||||
|
||||
%include ../../shared/readinglist/sidebar.inc.css
|
||||
|
||||
html {
|
||||
border-top: 1px solid #bdbdbd;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 16, 16, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 32, 16, 16);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 48, 16, 32);
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 32, 32, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 64, 32, 32);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 96, 32, 64);
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 14 14">
|
||||
<defs>
|
||||
<style>
|
||||
use:not(:target) {
|
||||
display: none;
|
||||
}
|
||||
#addpage, #alreadyadded {
|
||||
fill: #808080;
|
||||
}
|
||||
#addpage-hover, #alreadyadded-hover {
|
||||
fill: #555;
|
||||
}
|
||||
#addpage-active, #alreadyadded-active {
|
||||
fill: #0095dd;
|
||||
}
|
||||
</style>
|
||||
<mask id="plus-mask">
|
||||
<rect width="100%" height="100%" fill="#fff"/>
|
||||
<rect x="3" y="6" width="8" height="2"/>
|
||||
<rect x="6" y="3" width="2" height="8"/>
|
||||
</mask>
|
||||
<mask id="minus-mask">
|
||||
<rect width="100%" height="100%" fill="#fff"/>
|
||||
<rect x="3" y="6" width="8" height="2"/>
|
||||
</mask>
|
||||
<g id="addpage-shape">
|
||||
<circle cx="7" cy="7" r="6" mask="url(#plus-mask)"/>
|
||||
</g>
|
||||
<g id="removepage-shape">
|
||||
<circle cx="7" cy="7" r="6" mask="url(#minus-mask)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<use id="addpage" xlink:href="#addpage-shape"/>
|
||||
<use id="addpage-hover" xlink:href="#addpage-shape"/>
|
||||
<use id="addpage-active" xlink:href="#addpage-shape"/>
|
||||
<use id="alreadyadded" xlink:href="#removepage-shape"/>
|
||||
<use id="alreadyadded-hover" xlink:href="#removepage-shape"/>
|
||||
<use id="alreadyadded-active" xlink:href="#removepage-shape"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<rect x="4.8" y="6.4" fill="#808080" width="11.2" height="3.2"/>
|
||||
<rect x="4.8" y="11.2" fill="#808080" width="11.2" height="3.2"/>
|
||||
<rect x="4.8" y="1.6" fill="#808080" width="11.2" height="3.2"/>
|
||||
<circle fill="#808080" cx="1.6" cy="3.2" r="1.6"/>
|
||||
<circle fill="#808080" cx="1.6" cy="8" r="1.6"/>
|
||||
<circle fill="#808080" cx="1.6" cy="12.8" r="1.6"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 679 B |
@ -1,34 +0,0 @@
|
||||
/* Reading List button */
|
||||
|
||||
#urlbar:not([focused]):not(:hover) #readinglist-addremove-button {
|
||||
opacity: 0;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
#readinglist-addremove-button {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#addpage");
|
||||
-moz-image-region: rect(0, 14px, 14px, 0);
|
||||
transition: width 150ms ease-in-out, opacity 150ms ease-in-out 150ms;
|
||||
opacity: 1;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
#readinglist-addremove-button:hover {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#addpage-hover");
|
||||
}
|
||||
|
||||
#readinglist-addremove-button:active {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#addpage-active");
|
||||
}
|
||||
|
||||
#readinglist-addremove-button[already-added="true"] {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#alreadyadded");
|
||||
}
|
||||
|
||||
#readinglist-addremove-button[already-added="true"]:hover {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#alreadyadded-hover");
|
||||
}
|
||||
|
||||
#readinglist-addremove-button[already-added="true"]:active {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/icons.svg#alreadyadded-active");
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
% 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/.
|
||||
|
||||
:root, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: message-box;
|
||||
color: #333333;
|
||||
-moz-user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#emptyListInfo {
|
||||
cursor: default;
|
||||
padding: 3em 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transition: opacity 150ms ease-in-out, max-height 150ms ease-in-out 150ms;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: #FEFEFE;
|
||||
}
|
||||
|
||||
.item.selected {
|
||||
background: #FDFDFD;
|
||||
}
|
||||
|
||||
.item-thumb-container {
|
||||
min-width: 64px;
|
||||
max-width: 64px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0px 1px 2px rgba(0,0,0,.35);
|
||||
margin: 5px;
|
||||
background-color: #ebebeb;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-image: url("chrome://branding/content/silhouette-40.svg");
|
||||
}
|
||||
|
||||
.item-thumb-container.preview-available {
|
||||
background-color: #fff;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.item-summary-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
-moz-padding-start: 4px;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.item-title-lines {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
overflow: hidden;
|
||||
max-height: 2.8em;
|
||||
line-height: 1.4;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.item-domain {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-height: 1.4em;
|
||||
color: #0095DD;
|
||||
}
|
||||
|
||||
.item:hover .item-domain {
|
||||
color: #008ACB;
|
||||
}
|
||||
|
||||
.item:not(:hover):not(.selected) .remove-button {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
background-size: contain;
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.item.visible {
|
||||
opacity: 1;
|
||||
max-height: 80px;
|
||||
transition: max-height 250ms ease-in-out, opacity 250ms ease-in-out 250ms;
|
||||
}
|
@ -1764,8 +1764,6 @@ richlistitem[type~="action"][actiontype="switchtab"] > .ac-url-box > .ac-action-
|
||||
-moz-image-region: rect(0, 48px, 16px, 32px);
|
||||
}
|
||||
|
||||
%include ../shared/readinglist/readinglist.inc.css
|
||||
|
||||
/* Reader mode button */
|
||||
|
||||
#reader-mode-button {
|
||||
@ -2439,12 +2437,6 @@ notification[value="loop-sharing-notification"] .messageImage {
|
||||
-moz-image-region: auto;
|
||||
}
|
||||
|
||||
#menu_readingList,
|
||||
#BMB_readingList {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/readinglist-icon.svg");
|
||||
-moz-image-region: auto;
|
||||
}
|
||||
|
||||
#panelMenu_pocket,
|
||||
#menu_pocket,
|
||||
#BMB_pocket {
|
||||
|
@ -156,9 +156,6 @@ browser.jar:
|
||||
skin/classic/browser/reader-tour.png (../shared/reader/reader-tour.png)
|
||||
skin/classic/browser/reader-tour@2x.png (../shared/reader/reader-tour@2x.png)
|
||||
skin/classic/browser/readerMode.svg (../shared/reader/readerMode.svg)
|
||||
skin/classic/browser/readinglist/icons.svg (../shared/readinglist/icons.svg)
|
||||
skin/classic/browser/readinglist/readinglist-icon.svg (../shared/readinglist/readinglist-icon.svg)
|
||||
* skin/classic/browser/readinglist/sidebar.css (readinglist/sidebar.css)
|
||||
skin/classic/browser/notification-pluginNormal.png (../shared/plugins/notification-pluginNormal.png)
|
||||
skin/classic/browser/notification-pluginAlert.png (../shared/plugins/notification-pluginAlert.png)
|
||||
skin/classic/browser/notification-pluginBlocked.png (../shared/plugins/notification-pluginBlocked.png)
|
||||
|
@ -1,34 +0,0 @@
|
||||
/* 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/. */
|
||||
|
||||
%include ../../shared/readinglist/sidebar.inc.css
|
||||
|
||||
html {
|
||||
background-color: #EEF3FA;
|
||||
}
|
||||
|
||||
.item {
|
||||
-moz-padding-end: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 1px 0 0;
|
||||
}
|
||||
|
||||
.item-title, .item-domain {
|
||||
-moz-margin-end: 6px;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
-moz-margin-end: 2px;
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 16, 16, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 32, 16, 16);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 48, 16, 32);
|
||||
}
|
@ -313,8 +313,7 @@ user_pref("browser.tabs.remote.autostart.2", false);
|
||||
// Don't forceably kill content processes after a timeout
|
||||
user_pref("dom.ipc.tabs.shutdownTimeoutSecs", 0);
|
||||
|
||||
// Avoid performing Reading List and Reader Mode intros during tests.
|
||||
user_pref("browser.readinglist.introShown", true);
|
||||
// Avoid performing Reader Mode intros during tests.
|
||||
user_pref("browser.reader.detectedFirstArticle", true);
|
||||
|
||||
// Don't let PAC generator to set PAC, as mochitest framework has its own PAC
|
||||
|
Loading…
Reference in New Issue
Block a user