summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/extensions/newsblog
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/extensions/newsblog')
-rw-r--r--comm/mailnews/extensions/newsblog/.eslintrc.js18
-rw-r--r--comm/mailnews/extensions/newsblog/Feed.jsm700
-rw-r--r--comm/mailnews/extensions/newsblog/FeedItem.jsm490
-rw-r--r--comm/mailnews/extensions/newsblog/FeedParser.jsm1496
-rw-r--r--comm/mailnews/extensions/newsblog/FeedUtils.jsm2136
-rw-r--r--comm/mailnews/extensions/newsblog/NewsBlog.jsm28
-rw-r--r--comm/mailnews/extensions/newsblog/am-newsblog.js128
-rw-r--r--comm/mailnews/extensions/newsblog/am-newsblog.xhtml233
-rw-r--r--comm/mailnews/extensions/newsblog/components.conf21
-rw-r--r--comm/mailnews/extensions/newsblog/feed-subscriptions.js3120
-rw-r--r--comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml373
-rw-r--r--comm/mailnews/extensions/newsblog/feedAccountWizard.js56
-rw-r--r--comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml95
-rw-r--r--comm/mailnews/extensions/newsblog/jar.mn13
-rw-r--r--comm/mailnews/extensions/newsblog/moz.build25
-rw-r--r--comm/mailnews/extensions/newsblog/newsblogOverlay.js416
-rw-r--r--comm/mailnews/extensions/newsblog/test/browser/browser.ini20
-rw-r--r--comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js228
-rw-r--r--comm/mailnews/extensions/newsblog/test/browser/data/article.html17
-rw-r--r--comm/mailnews/extensions/newsblog/test/browser/data/rss.xml27
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/head_feeds.js35
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/README.md24
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json1
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf6
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json1
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf17
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json1
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf6
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json1
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf12
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json1
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf6
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json23
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf21
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json122
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf126
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json46
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf32
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml25
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml42
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf66
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf66
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js146
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js61
-rw-r--r--comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini8
45 files changed, 10535 insertions, 0 deletions
diff --git a/comm/mailnews/extensions/newsblog/.eslintrc.js b/comm/mailnews/extensions/newsblog/.eslintrc.js
new file mode 100644
index 0000000000..8254a84aaa
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/.eslintrc.js
@@ -0,0 +1,18 @@
+"use strict";
+
+module.exports = {
+ rules: {
+ // Enforce valid JSDoc comments.
+ "valid-jsdoc": [
+ "error",
+ {
+ prefer: { return: "returns" },
+ preferType: {
+ map: "Map",
+ set: "Set",
+ date: "Date",
+ },
+ },
+ ],
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/Feed.jsm b/comm/mailnews/extensions/newsblog/Feed.jsm
new file mode 100644
index 0000000000..8b899308d5
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/Feed.jsm
@@ -0,0 +1,700 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["Feed"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedParser",
+ "resource:///modules/FeedParser.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedUtils",
+ "resource:///modules/FeedUtils.jsm"
+);
+
+// Cache for all of the feeds currently being downloaded, indexed by URL,
+// so the load event listener can access the Feed objects after it finishes
+// downloading the feed.
+var FeedCache = {
+ mFeeds: {},
+
+ putFeed(aFeed) {
+ this.mFeeds[this.normalizeHost(aFeed.url)] = aFeed;
+ },
+
+ getFeed(aUrl) {
+ let index = this.normalizeHost(aUrl);
+ if (index in this.mFeeds) {
+ return this.mFeeds[index];
+ }
+
+ return null;
+ },
+
+ removeFeed(aUrl) {
+ let index = this.normalizeHost(aUrl);
+ if (index in this.mFeeds) {
+ delete this.mFeeds[index];
+ }
+ },
+
+ normalizeHost(aUrl) {
+ try {
+ let normalizedUrl = Services.io.newURI(aUrl);
+ let newHost = normalizedUrl.host.toLowerCase();
+ normalizedUrl = normalizedUrl.mutate().setHost(newHost).finalize();
+ return normalizedUrl.spec;
+ } catch (ex) {
+ return aUrl;
+ }
+ },
+};
+
+/**
+ * A Feed object. If aFolder is the account root folder, a new subfolder
+ * for the feed url is created otherwise the url will be subscribed to the
+ * existing aFolder, upon successful download() completion.
+ *
+ * @class
+ * @param {string} aFeedUrl - feed url.
+ * @param {nsIMsgFolder} aFolder - folder containing or to contain the feed
+ * subscription.
+ */
+function Feed(aFeedUrl, aFolder) {
+ this.url = aFeedUrl;
+ this.server = aFolder.server;
+ if (!aFolder.isServer) {
+ this.mFolder = aFolder;
+ }
+}
+
+Feed.prototype = {
+ url: null,
+ description: null,
+ author: null,
+ request: null,
+ server: null,
+ downloadCallback: null,
+ resource: null,
+ itemsToStore: [],
+ itemsStored: 0,
+ fileSize: 0,
+ mFolder: null,
+ mInvalidFeed: false,
+ mFeedType: null,
+ mLastModified: null,
+
+ get folder() {
+ return this.mFolder;
+ },
+
+ set folder(aFolder) {
+ this.mFolder = aFolder;
+ },
+
+ get name() {
+ // Used for the feed's title in Subscribe dialog and opml export.
+ let name = this.title || this.description || this.url;
+ /* eslint-disable-next-line no-control-regex */
+ return name.replace(/[\n\r\t]+/g, " ").replace(/[\x00-\x1F]+/g, "");
+ },
+
+ get folderName() {
+ if (this.mFolderName) {
+ return this.mFolderName;
+ }
+
+ // Get a unique sanitized name. Use title or description as a base;
+ // these are mandatory by spec. Length of 80 is plenty.
+ let folderName = (this.title || this.description || "").substr(0, 80);
+ let defaultName =
+ lazy.FeedUtils.strings.GetStringFromName("ImportFeedsNew");
+ return (this.mFolderName = lazy.FeedUtils.getSanitizedFolderName(
+ this.server.rootMsgFolder,
+ folderName,
+ defaultName,
+ true
+ ));
+ },
+
+ download(aParseItems, aCallback) {
+ // May be null.
+ this.downloadCallback = aCallback;
+
+ // Whether or not to parse items when downloading and parsing the feed.
+ // Defaults to true, but setting to false is useful for obtaining
+ // just the title of the feed when the user subscribes to it.
+ this.parseItems = aParseItems == null || aParseItems;
+
+ // Before we do anything, make sure the url is an http url. This is just
+ // a sanity check so we don't try opening mailto urls, imap urls, etc. that
+ // the user may have tried to subscribe to as an rss feed.
+ if (!lazy.FeedUtils.isValidScheme(this.url)) {
+ // Simulate an invalid feed error.
+ lazy.FeedUtils.log.info(
+ "Feed.download: invalid protocol for - " + this.url
+ );
+ this.onParseError(this);
+ return;
+ }
+
+ // Before we try to download the feed, make sure we aren't already
+ // processing the feed by looking up the url in our feed cache.
+ if (FeedCache.getFeed(this.url)) {
+ if (this.downloadCallback) {
+ this.downloadCallback.downloaded(
+ this,
+ lazy.FeedUtils.kNewsBlogFeedIsBusy
+ );
+ }
+
+ // Return, the feed is already in use.
+ return;
+ }
+
+ if (Services.io.offline) {
+ // If offline and don't want to go online, just add the feed subscription;
+ // it can be verified later (the folder name will be the url if not adding
+ // to an existing folder). Only for subscribe actions; passive biff and
+ // active get new messages are handled prior to getting here.
+ let win = Services.wm.getMostRecentWindow("mail:3pane");
+ if (!win.MailOfflineMgr.getNewMail()) {
+ this.storeNextItem();
+ return;
+ }
+ }
+
+ this.request = new XMLHttpRequest();
+ // Must set onProgress before calling open.
+ this.request.onprogress = this.onProgress;
+ this.request.open("GET", this.url, true);
+ this.request.channel.loadFlags |=
+ Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING;
+
+ // Some servers, if sent If-Modified-Since, will send 304 if subsequently
+ // not sent If-Modified-Since, as in the case of an unsubscribe and new
+ // subscribe. Send start of century date to force a download; some servers
+ // will 304 on older dates (such as epoch 1970).
+ let lastModified = this.lastModified || "Sat, 01 Jan 2000 00:00:00 GMT";
+ this.request.setRequestHeader("If-Modified-Since", lastModified);
+
+ // Only order what you're going to eat...
+ this.request.responseType = "document";
+ this.request.overrideMimeType("text/xml");
+ this.request.setRequestHeader("Accept", lazy.FeedUtils.REQUEST_ACCEPT);
+ this.request.timeout = lazy.FeedUtils.REQUEST_TIMEOUT;
+ this.request.onload = this.onDownloaded;
+ this.request.onreadystatechange = this.onReadyStateChange;
+ this.request.onerror = this.onDownloadError;
+ this.request.ontimeout = this.onDownloadError;
+ FeedCache.putFeed(this);
+ this.request.send(null);
+ },
+
+ onReadyStateChange(aEvent) {
+ // Once a server responds with data, reset the timeout to allow potentially
+ // large files to complete the download.
+ let request = aEvent.target;
+ if (request.timeout && request.readyState == request.LOADING) {
+ request.timeout = 0;
+ }
+ },
+
+ onDownloaded(aEvent) {
+ let request = aEvent.target;
+ let isHttp = request.channel.originalURI.scheme.startsWith("http");
+ let url = request.channel.originalURI.spec;
+ if (isHttp && (request.status < 200 || request.status >= 300)) {
+ Feed.prototype.onDownloadError(aEvent);
+ return;
+ }
+
+ lazy.FeedUtils.log.debug(
+ "Feed.onDownloaded: got a download, fileSize:url - " +
+ aEvent.loaded +
+ " : " +
+ url
+ );
+ let feed = FeedCache.getFeed(url);
+ if (!feed) {
+ throw new Error(
+ "Feed.onDownloaded: error - couldn't retrieve feed from cache"
+ );
+ }
+
+ // If the server sends a Last-Modified header, store the property on the
+ // feed so we can use it when making future requests, to avoid downloading
+ // and parsing feeds that have not changed. Don't update if merely checking
+ // the url, as for subscribe move/copy, as a subsequent refresh may get a 304.
+ // Save the response and persist it only upon successful completion of the
+ // refresh cycle (i.e. not if the request is cancelled).
+ let lastModifiedHeader = request.getResponseHeader("Last-Modified");
+ feed.mLastModified =
+ lastModifiedHeader && feed.parseItems ? lastModifiedHeader : null;
+
+ feed.fileSize = aEvent.loaded;
+
+ // The download callback is called asynchronously when parse() is done.
+ feed.parse();
+ },
+
+ onProgress(aEvent) {
+ let request = aEvent.target;
+ let url = request.channel.originalURI.spec;
+ let feed = FeedCache.getFeed(url);
+
+ if (feed.downloadCallback) {
+ feed.downloadCallback.onProgress(
+ feed,
+ aEvent.loaded,
+ aEvent.total,
+ aEvent.lengthComputable
+ );
+ }
+ },
+
+ onDownloadError(aEvent) {
+ let request = aEvent.target;
+ let url = request.channel.originalURI.spec;
+ let feed = FeedCache.getFeed(url);
+ if (feed.downloadCallback) {
+ // Generic network or 'not found' error initially.
+ let error = lazy.FeedUtils.kNewsBlogRequestFailure;
+ // Certain errors should disable the feed.
+ let disable = false;
+
+ if (request.status == 304) {
+ // If the http status code is 304, the feed has not been modified
+ // since we last downloaded it and does not need to be parsed.
+ error = lazy.FeedUtils.kNewsBlogNoNewItems;
+ } else {
+ let [errType, errName] =
+ lazy.FeedUtils.createTCPErrorFromFailedXHR(request);
+ lazy.FeedUtils.log.info(
+ "Feed.onDownloaded: request errType:errName:statusCode - " +
+ errType +
+ ":" +
+ errName +
+ ":" +
+ request.status
+ );
+ if (errType == "SecurityCertificate") {
+ // This is the code for nsINSSErrorsService.ERROR_CLASS_BAD_CERT
+ // overridable security certificate errors.
+ error = lazy.FeedUtils.kNewsBlogBadCertError;
+ }
+
+ if (request.status == 401 || request.status == 403) {
+ // Unauthorized or Forbidden.
+ error = lazy.FeedUtils.kNewsBlogNoAuthError;
+ }
+
+ if (
+ request.status != 0 ||
+ error == lazy.FeedUtils.kNewsBlogBadCertError ||
+ errName == "DomainNotFoundError"
+ ) {
+ disable = true;
+ }
+ }
+
+ feed.downloadCallback.downloaded(feed, error, disable);
+ }
+
+ FeedCache.removeFeed(url);
+ },
+
+ onParseError(aFeed) {
+ if (!aFeed) {
+ return;
+ }
+
+ aFeed.mInvalidFeed = true;
+ if (aFeed.downloadCallback) {
+ aFeed.downloadCallback.downloaded(
+ aFeed,
+ lazy.FeedUtils.kNewsBlogInvalidFeed,
+ true
+ );
+ }
+
+ FeedCache.removeFeed(aFeed.url);
+ },
+
+ onUrlChange(aFeed, aOldUrl) {
+ if (!aFeed) {
+ return;
+ }
+
+ // Simulate a cancel after a url update; next cycle will check the new url.
+ aFeed.mInvalidFeed = true;
+ if (aFeed.downloadCallback) {
+ aFeed.downloadCallback.downloaded(aFeed, lazy.FeedUtils.kNewsBlogCancel);
+ }
+
+ FeedCache.removeFeed(aOldUrl);
+ },
+
+ // nsIUrlListener methods for getDatabaseWithReparse().
+ OnStartRunningUrl(aUrl) {},
+ OnStopRunningUrl(aUrl, aExitCode) {
+ if (Components.isSuccessCode(aExitCode)) {
+ lazy.FeedUtils.log.debug(
+ "Feed.OnStopRunningUrl: rebuilt msgDatabase for " +
+ this.folder.name +
+ " - " +
+ this.folder.filePath.path
+ );
+ } else {
+ lazy.FeedUtils.log.error(
+ "Feed.OnStopRunningUrl: rebuild msgDatabase failed, " +
+ "error " +
+ aExitCode +
+ ", for " +
+ this.folder.name +
+ " - " +
+ this.folder.filePath.path
+ );
+ }
+ // Continue.
+ this.storeNextItem();
+ },
+
+ get title() {
+ return lazy.FeedUtils.getSubscriptionAttr(
+ this.url,
+ this.server,
+ "title",
+ ""
+ );
+ },
+
+ set title(aNewTitle) {
+ if (!aNewTitle) {
+ return;
+ }
+ lazy.FeedUtils.setSubscriptionAttr(
+ this.url,
+ this.server,
+ "title",
+ aNewTitle
+ );
+ },
+
+ get lastModified() {
+ return lazy.FeedUtils.getSubscriptionAttr(
+ this.url,
+ this.server,
+ "lastModified",
+ ""
+ );
+ },
+
+ set lastModified(aLastModified) {
+ lazy.FeedUtils.setSubscriptionAttr(
+ this.url,
+ this.server,
+ "lastModified",
+ aLastModified
+ );
+ },
+
+ get quickMode() {
+ let defaultValue = this.server.getBoolValue("quickMode");
+ return lazy.FeedUtils.getSubscriptionAttr(
+ this.url,
+ this.server,
+ "quickMode",
+ defaultValue
+ );
+ },
+
+ set quickMode(aNewQuickMode) {
+ lazy.FeedUtils.setSubscriptionAttr(
+ this.url,
+ this.server,
+ "quickMode",
+ aNewQuickMode
+ );
+ },
+
+ get options() {
+ let options = lazy.FeedUtils.getSubscriptionAttr(
+ this.url,
+ this.server,
+ "options",
+ null
+ );
+ if (options && options.version == lazy.FeedUtils._optionsDefault.version) {
+ return options;
+ }
+
+ let newOptions = lazy.FeedUtils.newOptions(options);
+ this.options = newOptions;
+ return newOptions;
+ },
+
+ set options(aOptions) {
+ let newOptions = aOptions ? aOptions : lazy.FeedUtils.optionsTemplate;
+ lazy.FeedUtils.setSubscriptionAttr(
+ this.url,
+ this.server,
+ "options",
+ newOptions
+ );
+ },
+
+ get link() {
+ return lazy.FeedUtils.getSubscriptionAttr(
+ this.url,
+ this.server,
+ "link",
+ ""
+ );
+ },
+
+ set link(aNewLink) {
+ if (!aNewLink) {
+ return;
+ }
+ lazy.FeedUtils.setSubscriptionAttr(this.url, this.server, "link", aNewLink);
+ },
+
+ parse() {
+ // Create a feed parser which will parse the feed.
+ let parser = new lazy.FeedParser();
+ this.itemsToStore = parser.parseFeed(this, this.request.responseXML);
+ parser = null;
+
+ if (this.mInvalidFeed) {
+ this.request = null;
+ this.mInvalidFeed = false;
+ return;
+ }
+
+ this.itemsToStoreIndex = 0;
+ this.itemsStored = 0;
+
+ // At this point, if we have items to potentially store and an existing
+ // folder, ensure the folder's msgDatabase is openable for new message
+ // processing. If not, reparse with an async nsIUrlListener |this| to
+ // continue once the reparse is complete.
+ if (
+ this.itemsToStore.length > 0 &&
+ this.folder &&
+ !lazy.FeedUtils.isMsgDatabaseOpenable(this.folder, true, this)
+ ) {
+ return;
+ }
+
+ // We have an msgDatabase; storeNextItem() will iterate through the parsed
+ // items, storing each one.
+ this.storeNextItem();
+ },
+
+ /**
+ * Clear the 'valid' field of all feeditems associated with this feed.
+ *
+ * @returns {void}
+ */
+ invalidateItems() {
+ let ds = lazy.FeedUtils.getItemsDS(this.server);
+ for (let id in ds.data) {
+ let item = ds.data[id];
+ if (item.feedURLs.includes(this.url)) {
+ item.valid = false;
+ lazy.FeedUtils.log.trace("Feed.invalidateItems: item - " + id);
+ }
+ }
+ ds.saveSoon();
+ },
+
+ /**
+ * Discards invalid items (in the feed item store) associated with the
+ * feed. There's a delay - invalid items are kept around for a set time
+ * before being purged.
+ *
+ * @param {Boolean} aDeleteFeed - is the feed being deleted (bypasses
+ * the delay time).
+ * @returns {void}
+ */
+ removeInvalidItems(aDeleteFeed) {
+ let ds = lazy.FeedUtils.getItemsDS(this.server);
+ lazy.FeedUtils.log.debug("Feed.removeInvalidItems: for url - " + this.url);
+
+ let currentTime = new Date().getTime();
+ for (let id in ds.data) {
+ let item = ds.data[id];
+ // skip valid items and ones not part of this feed.
+ if (!item.feedURLs.includes(this.url) || item.valid) {
+ continue;
+ }
+ let lastSeenTime = item.lastSeenTime || 0;
+
+ if (
+ currentTime - lastSeenTime < lazy.FeedUtils.INVALID_ITEM_PURGE_DELAY &&
+ !aDeleteFeed
+ ) {
+ // Don't immediately purge items in active feeds; do so for deleted feeds.
+ continue;
+ }
+
+ lazy.FeedUtils.log.trace("Feed.removeInvalidItems: item - " + id);
+ // Detach the item from this feed (it could be shared by multiple feeds).
+ item.feedURLs = item.feedURLs.filter(url => url != this.url);
+ if (item.feedURLs.length > 0) {
+ lazy.FeedUtils.log.debug(
+ "Feed.removeInvalidItems: " +
+ id +
+ " is from more than one feed; only the reference to" +
+ " this feed removed"
+ );
+ } else {
+ delete ds.data[id];
+ }
+ }
+ ds.saveSoon();
+ },
+
+ createFolder() {
+ if (this.folder) {
+ return;
+ }
+
+ try {
+ this.folder = this.server.rootMsgFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder(this.folderName);
+ } catch (ex) {
+ // An error creating.
+ lazy.FeedUtils.log.info(
+ "Feed.createFolder: error creating folder - '" +
+ this.folderName +
+ "' in parent folder " +
+ this.server.rootMsgFolder.filePath.path +
+ " -- " +
+ ex
+ );
+ // But its remnants are still there, clean up.
+ let xfolder = this.server.rootMsgFolder.getChildNamed(this.folderName);
+ this.server.rootMsgFolder.propagateDelete(xfolder, true);
+ }
+ },
+
+ // Gets the next item from itemsToStore and forces that item to be stored
+ // to the folder. If more items are left to be stored, fires a timer for
+ // the next one, otherwise triggers a download done notification to the UI.
+ storeNextItem() {
+ if (lazy.FeedUtils.CANCEL_REQUESTED) {
+ lazy.FeedUtils.CANCEL_REQUESTED = false;
+ this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogCancel);
+ return;
+ }
+
+ if (this.itemsToStore.length == 0) {
+ let code = lazy.FeedUtils.kNewsBlogSuccess;
+ this.createFolder();
+ if (!this.folder) {
+ code = lazy.FeedUtils.kNewsBlogFileError;
+ }
+
+ this.cleanupParsingState(this, code);
+ return;
+ }
+
+ let item = this.itemsToStore[this.itemsToStoreIndex];
+
+ if (item.store()) {
+ this.itemsStored++;
+ }
+
+ if (!this.folder) {
+ this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogFileError);
+ return;
+ }
+
+ this.itemsToStoreIndex++;
+
+ // If the listener is tracking progress for each item, report it here.
+ if (
+ item.feed.downloadCallback &&
+ item.feed.downloadCallback.onFeedItemStored
+ ) {
+ item.feed.downloadCallback.onFeedItemStored(
+ item.feed,
+ this.itemsToStoreIndex,
+ this.itemsToStore.length
+ );
+ }
+
+ // Eventually we'll report individual progress here.
+
+ if (this.itemsToStoreIndex < this.itemsToStore.length) {
+ if (!this.storeItemsTimer) {
+ this.storeItemsTimer = Cc["@mozilla.org/timer;1"].createInstance(
+ Ci.nsITimer
+ );
+ }
+
+ this.storeItemsTimer.initWithCallback(
+ this,
+ 50,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else {
+ // We have just finished downloading one or more feed items into the
+ // destination folder; if the folder is still listed as having new
+ // messages in it, then we should set the biff state on the folder so the
+ // right RDF UI changes happen in the folder pane to indicate new mail.
+ if (item.feed.folder.hasNewMessages) {
+ item.feed.folder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail;
+ // Run the bayesian spam filter, if enabled.
+ item.feed.folder.callFilterPlugins(null);
+ }
+
+ this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogSuccess);
+ }
+ },
+
+ cleanupParsingState(aFeed, aCode) {
+ // Now that we are done parsing the feed, remove the feed from the cache.
+ FeedCache.removeFeed(aFeed.url);
+
+ if (aFeed.parseItems) {
+ // Do this only if we're in parse/store mode.
+ aFeed.removeInvalidItems(false);
+
+ if (aCode == lazy.FeedUtils.kNewsBlogSuccess && aFeed.mLastModified) {
+ aFeed.lastModified = aFeed.mLastModified;
+ }
+
+ // Flush any feed item changes to disk.
+ let ds = lazy.FeedUtils.getItemsDS(aFeed.server);
+ ds.saveSoon();
+ lazy.FeedUtils.log.debug(
+ "Feed.cleanupParsingState: items stored - " + this.itemsStored
+ );
+ }
+
+ // Force the xml http request to go away. This helps reduce some nasty
+ // assertions on shut down.
+ this.request = null;
+ this.itemsToStore = [];
+ this.itemsToStoreIndex = 0;
+ this.storeItemsTimer = null;
+
+ if (aFeed.downloadCallback) {
+ aFeed.downloadCallback.downloaded(aFeed, aCode);
+ }
+ },
+
+ // nsITimerCallback
+ notify(aTimer) {
+ this.storeNextItem();
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/FeedItem.jsm b/comm/mailnews/extensions/newsblog/FeedItem.jsm
new file mode 100644
index 0000000000..40a0424a0d
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/FeedItem.jsm
@@ -0,0 +1,490 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+const EXPORTED_SYMBOLS = ["FeedItem", "FeedEnclosure"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedUtils",
+ "resource:///modules/FeedUtils.jsm"
+);
+
+function FeedItem() {
+ this.mDate = lazy.FeedUtils.getValidRFC5322Date();
+ this.mParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+}
+
+FeedItem.prototype = {
+ // Only for IETF Atom.
+ xmlContentBase: null,
+ id: null,
+ feed: null,
+ description: null,
+ content: null,
+ enclosures: [],
+ title: null,
+ // Author must be angle bracket enclosed to function as an addr-spec, in the
+ // absence of an addr-spec portion of an RFC5322 email address, as other
+ // functionality (gloda search) depends on this.
+ author: "<anonymous>",
+ inReplyTo: "",
+ keywords: [],
+ mURL: null,
+ characterSet: "UTF-8",
+
+ ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes
+ ENCLOSURE_HEADER_BOUNDARY_PREFIX: "------------", // 12 dashes
+ MESSAGE_TEMPLATE:
+ "\n" +
+ "<!DOCTYPE html>\n" +
+ "<html>\n" +
+ " <head>\n" +
+ " <title>%TITLE%</title>\n" +
+ ' <base href="%BASE%">\n' +
+ " </head>\n" +
+ ' <body id="msgFeedSummaryBody" selected="false">\n' +
+ " %CONTENT%\n" +
+ " </body>\n" +
+ "</html>\n",
+
+ get url() {
+ return this.mURL;
+ },
+
+ set url(aVal) {
+ try {
+ this.mURL = Services.io.newURI(aVal).spec;
+ } catch (ex) {
+ // The url as published or constructed can be a non url. It's used as a
+ // feeditem identifier in feeditems.rdf, as a messageId, and as an href
+ // and for the content-base header. Save as is; ensure not null.
+ this.mURL = aVal ? aVal : "";
+ }
+ },
+
+ get date() {
+ return this.mDate;
+ },
+
+ set date(aVal) {
+ this.mDate = aVal;
+ },
+
+ get identity() {
+ return this.feed.name + ": " + this.title + " (" + this.id + ")";
+ },
+
+ normalizeMessageID(messageID) {
+ // Escape occurrences of message ID meta characters <, >, and @.
+ messageID.replace(/</g, "%3C");
+ messageID.replace(/>/g, "%3E");
+ messageID.replace(/@/g, "%40");
+ messageID = "<" + messageID.trim() + "@localhost.localdomain>";
+
+ lazy.FeedUtils.log.trace(
+ "FeedItem.normalizeMessageID: messageID - " + messageID
+ );
+ return messageID;
+ },
+
+ get contentBase() {
+ if (this.xmlContentBase) {
+ return this.xmlContentBase;
+ }
+
+ return this.mURL;
+ },
+
+ /**
+ * Writes the item to the folder as a message and updates the feeditems db.
+ *
+ * @returns {void}
+ */
+ store() {
+ // this.title and this.content contain HTML.
+ // this.mUrl and this.contentBase contain plain text.
+
+ let stored = false;
+ let ds = lazy.FeedUtils.getItemsDS(this.feed.server);
+ let resource = this.findStoredResource();
+ if (!this.feed.folder) {
+ return stored;
+ }
+
+ if (resource == null) {
+ resource = {
+ feedURLs: [this.feed.url],
+ lastSeenTime: 0,
+ valid: false,
+ stored: false,
+ };
+ ds.data[this.id] = resource;
+ if (!this.content) {
+ lazy.FeedUtils.log.trace(
+ "FeedItem.store: " +
+ this.identity +
+ " no content; storing description or title"
+ );
+ this.content = this.description || this.title;
+ }
+
+ let content = this.MESSAGE_TEMPLATE;
+ content = content.replace(/%TITLE%/, this.title);
+ content = content.replace(/%BASE%/, this.htmlEscape(this.contentBase));
+ content = content.replace(/%CONTENT%/, this.content);
+ this.content = content;
+ this.writeToFolder();
+ this.markStored(resource);
+ stored = true;
+ }
+
+ this.markValid(resource);
+ ds.saveSoon();
+ return stored;
+ },
+
+ findStoredResource() {
+ // Checks to see if the item has already been stored in its feed's
+ // message folder.
+ lazy.FeedUtils.log.trace(
+ "FeedItem.findStoredResource: checking if stored - " + this.identity
+ );
+
+ let server = this.feed.server;
+ let folder = this.feed.folder;
+
+ if (!folder) {
+ lazy.FeedUtils.log.debug(
+ "FeedItem.findStoredResource: folder '" +
+ this.feed.folderName +
+ "' doesn't exist; creating as child of " +
+ server.rootMsgFolder.prettyName +
+ "\n"
+ );
+ this.feed.createFolder();
+ return null;
+ }
+
+ let ds = lazy.FeedUtils.getItemsDS(server);
+ let item = ds.data[this.id];
+ if (!item || !item.stored) {
+ lazy.FeedUtils.log.trace("FeedItem.findStoredResource: not stored");
+ return null;
+ }
+
+ lazy.FeedUtils.log.trace("FeedItem.findStoredResource: already stored");
+ return item;
+ },
+
+ markValid(resource) {
+ resource.lastSeenTime = new Date().getTime();
+ // Items can be in multiple feeds.
+ if (!resource.feedURLs.includes(this.feed.url)) {
+ resource.feedURLs.push(this.feed.url);
+ }
+ resource.valid = true;
+ },
+
+ markStored(resource) {
+ // Items can be in multiple feeds.
+ if (!resource.feedURLs.includes(this.feed.url)) {
+ resource.feedURLs.push(this.feed.url);
+ }
+ resource.stored = true;
+ },
+
+ writeToFolder() {
+ lazy.FeedUtils.log.trace(
+ "FeedItem.writeToFolder: " +
+ this.identity +
+ " writing to message folder " +
+ this.feed.name
+ );
+ // The subject may contain HTML entities. Convert these to their unencoded
+ // state. i.e. &amp; becomes '&'.
+ let title = this.title;
+ title = this.mParserUtils.convertToPlainText(
+ title,
+ Ci.nsIDocumentEncoder.OutputSelectionOnly |
+ Ci.nsIDocumentEncoder.OutputAbsoluteLinks,
+ 0
+ );
+
+ // Compress white space in the subject to make it look better. Trim
+ // leading/trailing spaces to prevent mbox header folding issue at just
+ // the right subject length.
+ this.title = title.replace(/[\t\r\n]+/g, " ").trim();
+
+ // If the date looks like it's in W3C-DTF format, convert it into
+ // an IETF standard date. Otherwise assume it's in IETF format.
+ if (this.mDate.search(/^\d\d\d\d/) != -1) {
+ this.mDate = new Date(this.mDate).toUTCString();
+ }
+
+ // If there is an inreplyto value, create the headers.
+ let inreplytoHdrsStr = this.inReplyTo
+ ? "References: " +
+ this.inReplyTo +
+ "\n" +
+ "In-Reply-To: " +
+ this.inReplyTo +
+ "\n"
+ : "";
+
+ // Support multiple authors in From.
+ let fromStr = this.createHeaderStrFromArray("From: ", this.author);
+
+ // If there are keywords (categories), create the headers.
+ let keywordsStr = this.createHeaderStrFromArray(
+ "Keywords: ",
+ this.keywords
+ );
+
+ // Escape occurrences of "From " at the beginning of lines of
+ // content per the mbox standard, since "From " denotes a new
+ // message, and add a line break so we know the last line has one.
+ this.content = this.content.replace(/([\r\n]+)(>*From )/g, "$1>$2");
+ this.content += "\n";
+
+ let source =
+ "From - " +
+ this.mDate +
+ "\n" +
+ "X-Mozilla-Status: 0000\n" +
+ "X-Mozilla-Status2: 00000000\n" +
+ "X-Mozilla-Keys: " +
+ " ".repeat(80) +
+ "\n" +
+ "Received: by localhost; " +
+ lazy.FeedUtils.getValidRFC5322Date() +
+ "\n" +
+ "Date: " +
+ this.mDate +
+ "\n" +
+ "Message-Id: " +
+ this.normalizeMessageID(this.id) +
+ "\n" +
+ fromStr +
+ "MIME-Version: 1.0\n" +
+ "Subject: " +
+ this.title +
+ "\n" +
+ inreplytoHdrsStr +
+ keywordsStr +
+ "Content-Transfer-Encoding: 8bit\n" +
+ "Content-Base: " +
+ this.mURL +
+ "\n";
+
+ if (this.enclosures.length) {
+ let boundaryID = source.length;
+ source +=
+ 'Content-Type: multipart/mixed; boundary="' +
+ this.ENCLOSURE_HEADER_BOUNDARY_PREFIX +
+ boundaryID +
+ '"\n\n' +
+ "This is a multi-part message in MIME format.\n" +
+ this.ENCLOSURE_BOUNDARY_PREFIX +
+ boundaryID +
+ "\n" +
+ "Content-Type: text/html; charset=" +
+ this.characterSet +
+ "\n" +
+ "Content-Transfer-Encoding: 8bit\n" +
+ this.content;
+
+ this.enclosures.forEach(function (enclosure) {
+ source += enclosure.convertToAttachment(boundaryID);
+ });
+
+ source += this.ENCLOSURE_BOUNDARY_PREFIX + boundaryID + "--\n\n\n";
+ } else {
+ source +=
+ "Content-Type: text/html; charset=" +
+ this.characterSet +
+ "\n" +
+ this.content;
+ }
+
+ lazy.FeedUtils.log.trace(
+ "FeedItem.writeToFolder: " +
+ this.identity +
+ " is " +
+ source.length +
+ " characters long"
+ );
+
+ // Get the folder and database storing the feed's messages and headers.
+ let folder = this.feed.folder.QueryInterface(Ci.nsIMsgLocalMailFolder);
+ let msgFolder = folder.QueryInterface(Ci.nsIMsgFolder);
+ msgFolder.gettingNewMessages = true;
+ // Source is a unicode js string, as UTF-16, and we want to save a
+ // char * cpp |string| as UTF-8 bytes. The source xml doc encoding is utf8.
+ source = unescape(encodeURIComponent(source));
+ let msgDBHdr = folder.addMessage(source);
+ msgDBHdr.orFlags(Ci.nsMsgMessageFlags.FeedMsg);
+ msgFolder.gettingNewMessages = false;
+ this.tagItem(msgDBHdr, this.keywords);
+ },
+
+ /**
+ * Create a header string from an array. Intended for comma separated headers
+ * like From or Keywords. In the case of a longer than RFC5322 recommended
+ * line length, create multiple folded lines (easier to parse than multiple
+ * headers).
+ *
+ * @param {string} headerName - Name of the header.
+ * @param {string[]} headerItemsArray - An Array of strings to concatenate.
+ *
+ * @returns {String} - The header string.
+ */
+ createHeaderStrFromArray(headerName, headerItemsArray) {
+ let headerStr = "";
+ if (!headerItemsArray || headerItemsArray.length == 0) {
+ return headerStr;
+ }
+
+ const HEADER = headerName;
+ const LINELENGTH = 78;
+ const MAXLINELENGTH = 990;
+ let items = [].concat(headerItemsArray);
+ let lines = [];
+ headerStr = HEADER;
+ while (items.length) {
+ let item = items.shift();
+ if (
+ headerStr.length + item.length > LINELENGTH &&
+ headerStr.length > HEADER.length
+ ) {
+ lines.push(headerStr);
+ headerStr = " ".repeat(HEADER.length);
+ }
+
+ headerStr +=
+ headerStr.length + item.length > MAXLINELENGTH
+ ? item.substr(0, MAXLINELENGTH - headerStr.length) + "…, "
+ : item + ", ";
+ }
+
+ headerStr = headerStr.replace(/,\s$/, "\n");
+ lines.push(headerStr);
+ headerStr = lines.join("\n");
+
+ return headerStr;
+ },
+
+ /**
+ * Autotag messages.
+ *
+ * @param {nsIMsgDBHdr} aMsgDBHdr - message to tag
+ * @param {Array} aKeywords - keywords (tags)
+ * @returns {void}
+ */
+ tagItem(aMsgDBHdr, aKeywords) {
+ let category = this.feed.options.category;
+ if (!aKeywords.length || !category.enabled) {
+ return;
+ }
+
+ let prefix = category.prefixEnabled ? category.prefix : "";
+ let rtl = Services.prefs.getIntPref("bidi.direction") == 2;
+
+ let keys = [];
+ for (let keyword of aKeywords) {
+ keyword = rtl ? keyword + prefix : prefix + keyword;
+ let keyForTag = MailServices.tags.getKeyForTag(keyword);
+ if (!keyForTag) {
+ // Add the tag if it doesn't exist.
+ MailServices.tags.addTag(keyword, "", lazy.FeedUtils.AUTOTAG);
+ keyForTag = MailServices.tags.getKeyForTag(keyword);
+ }
+
+ // Add the tag key to the keys array.
+ keys.push(keyForTag);
+ }
+
+ if (keys.length) {
+ // Add the keys to the message.
+ aMsgDBHdr.folder.addKeywordsToMessages([aMsgDBHdr], keys.join(" "));
+ }
+ },
+
+ htmlEscape(s) {
+ s = s.replace(/&/g, "&amp;");
+ s = s.replace(/>/g, "&gt;");
+ s = s.replace(/</g, "&lt;");
+ s = s.replace(/'/g, "&#39;");
+ s = s.replace(/"/g, "&quot;");
+ return s;
+ },
+};
+
+// A feed enclosure is to RSS what an attachment is for e-mail. We make
+// enclosures look like attachments in the UI.
+function FeedEnclosure(aURL, aContentType, aLength, aTitle) {
+ this.mURL = aURL;
+ // Store a reasonable mimetype if content-type is not present.
+ this.mContentType = aContentType || "application/unknown";
+ this.mLength = aLength;
+ this.mTitle = aTitle;
+
+ // Generate a fileName from the URL.
+ if (this.mURL) {
+ try {
+ let uri = Services.io.newURI(this.mURL).QueryInterface(Ci.nsIURL);
+ this.mFileName = uri.fileName;
+ // Determine mimetype from extension if content-type is not present.
+ if (!aContentType) {
+ let contentType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromExtension(uri.fileExtension);
+ this.mContentType = contentType;
+ }
+ } catch (ex) {
+ this.mFileName = this.mURL;
+ }
+ }
+}
+
+FeedEnclosure.prototype = {
+ mURL: "",
+ mContentType: "",
+ mLength: 0,
+ mFileName: "",
+ mTitle: "",
+ ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes
+
+ // Returns a string that looks like an e-mail attachment which represents
+ // the enclosure.
+ convertToAttachment(aBoundaryID) {
+ return (
+ "\n" +
+ this.ENCLOSURE_BOUNDARY_PREFIX +
+ aBoundaryID +
+ "\n" +
+ "Content-Type: " +
+ this.mContentType +
+ '; name="' +
+ (this.mTitle || this.mFileName) +
+ (this.mLength ? '"; size=' + this.mLength : '"') +
+ "\n" +
+ "X-Mozilla-External-Attachment-URL: " +
+ this.mURL +
+ "\n" +
+ 'Content-Disposition: attachment; filename="' +
+ this.mFileName +
+ '"\n\n' +
+ lazy.FeedUtils.strings.GetStringFromName("externalAttachmentMsg") +
+ "\n"
+ );
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/FeedParser.jsm b/comm/mailnews/extensions/newsblog/FeedParser.jsm
new file mode 100644
index 0000000000..863d5789fe
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/FeedParser.jsm
@@ -0,0 +1,1496 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["FeedParser"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedItem",
+ "resource:///modules/FeedItem.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedEnclosure",
+ "resource:///modules/FeedItem.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FeedUtils",
+ "resource:///modules/FeedUtils.jsm"
+);
+
+/**
+ * The feed parser. Depends on FeedItem.js, Feed.js.
+ *
+ * @class
+ */
+function FeedParser() {
+ this.parsedItems = [];
+ this.mSerializer = new XMLSerializer();
+}
+
+FeedParser.prototype = {
+ /**
+ * parseFeed() returns an array of parsed items ready for processing. It is
+ * currently a synchronous operation. If there is an error parsing the feed,
+ * parseFeed returns an empty feed in addition to calling aFeed.onParseError.
+ *
+ * @param {Feed} aFeed - The Feed object.
+ * @param {XMLDocument} aDOM - The document to parse.
+ * @returns {Array} - array of items, or empty array for error returns or
+ * nothing to do condition.
+ */
+ parseFeed(aFeed, aDOM) {
+ if (!XMLDocument.isInstance(aDOM)) {
+ // No xml doc.
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ let doc = aDOM.documentElement;
+ if (doc.namespaceURI == lazy.FeedUtils.MOZ_PARSERERROR_NS) {
+ // Gecko caught a basic parsing error.
+ let errStr =
+ doc.firstChild.textContent + "\n" + doc.firstElementChild.textContent;
+ lazy.FeedUtils.log.info("FeedParser.parseFeed: - " + errStr);
+ aFeed.onParseError(aFeed);
+ return [];
+ } else if (aDOM.querySelector("redirect")) {
+ // Check for RSS2.0 redirect document.
+ let channel = aDOM.querySelector("redirect");
+ if (this.isPermanentRedirect(aFeed, channel, null)) {
+ return [];
+ }
+
+ aFeed.onParseError(aFeed);
+ return [];
+ } else if (
+ doc.namespaceURI == lazy.FeedUtils.RDF_SYNTAX_NS &&
+ doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_NS, "channel")[0]
+ ) {
+ aFeed.mFeedType = "RSS_1.xRDF";
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseFeed: type:url - " +
+ aFeed.mFeedType +
+ " : " +
+ aFeed.url
+ );
+
+ return this.parseAsRSS1(aFeed, aDOM);
+ } else if (doc.namespaceURI == lazy.FeedUtils.ATOM_03_NS) {
+ aFeed.mFeedType = "ATOM_0.3";
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseFeed: type:url - " +
+ aFeed.mFeedType +
+ " : " +
+ aFeed.url
+ );
+ return this.parseAsAtom(aFeed, aDOM);
+ } else if (doc.namespaceURI == lazy.FeedUtils.ATOM_IETF_NS) {
+ aFeed.mFeedType = "ATOM_IETF";
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseFeed: type:url - " +
+ aFeed.mFeedType +
+ " : " +
+ aFeed.url
+ );
+ return this.parseAsAtomIETF(aFeed, aDOM);
+ } else if (
+ doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_090_NS, "channel")[0]
+ ) {
+ aFeed.mFeedType = "RSS_0.90";
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseFeed: type:url - " +
+ aFeed.mFeedType +
+ " : " +
+ aFeed.url
+ );
+ return this.parseAsRSS2(aFeed, aDOM);
+ }
+
+ // Parse as RSS 0.9x. In theory even RSS 1.0 feeds could be parsed by
+ // the 0.9x parser if the RSS namespace were the default.
+ let rssVer = doc.localName == "rss" ? doc.getAttribute("version") : null;
+ if (rssVer) {
+ aFeed.mFeedType = "RSS_" + rssVer;
+ } else {
+ aFeed.mFeedType = "RSS_0.9x?";
+ }
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseFeed: type:url - " + aFeed.mFeedType + " : " + aFeed.url
+ );
+ return this.parseAsRSS2(aFeed, aDOM);
+ },
+
+ parseAsRSS2(aFeed, aDOM) {
+ // Get the first channel (assuming there is only one per RSS File).
+ let channel = aDOM.querySelector("channel");
+ if (!channel) {
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ // Usually the empty string, unless this is RSS .90.
+ let nsURI = channel.namespaceURI || "";
+
+ if (this.isPermanentRedirect(aFeed, null, channel)) {
+ return [];
+ }
+
+ let tags = this.childrenByTagNameNS(channel, nsURI, "title");
+ aFeed.title = aFeed.title || this.getNodeValue(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(channel, nsURI, "description");
+ aFeed.description = this.getNodeValueFormatted(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(channel, nsURI, "link");
+ aFeed.link = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+
+ if (!(aFeed.title || aFeed.description)) {
+ lazy.FeedUtils.log.error(
+ "FeedParser.parseAsRSS2: missing mandatory element " +
+ "<title> and <description>"
+ );
+ // The RSS2 spec requires a <link> as well, but we can do without it
+ // so ignore the case of (valid) link missing.
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ if (!aFeed.parseItems) {
+ return [];
+ }
+
+ this.findSyUpdateTags(aFeed, channel);
+
+ aFeed.invalidateItems();
+ // XXX use getElementsByTagNameNS for now; childrenByTagNameNS would be
+ // better, but RSS .90 is still with us.
+ let itemNodes = aDOM.getElementsByTagNameNS(nsURI, "item");
+ itemNodes = itemNodes ? itemNodes : [];
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseAsRSS2: items to parse - " + itemNodes.length
+ );
+
+ for (let itemNode of itemNodes) {
+ if (!itemNode.childElementCount) {
+ continue;
+ }
+
+ let item = new lazy.FeedItem();
+ item.feed = aFeed;
+ item.enclosures = [];
+ item.keywords = [];
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.FEEDBURNER_NS,
+ "origLink"
+ );
+ let link = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+ if (!link) {
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "link");
+ link = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+ }
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "guid");
+ let guidNode = tags ? tags[0] : null;
+
+ let guid;
+ let isPermaLink = false;
+ if (guidNode) {
+ guid = this.getNodeValue(guidNode);
+ // isPermaLink is true if the value is "true" or if the attribute is
+ // not present; all other values, including "false" and "False" and
+ // for that matter "TRuE" and "meatcake" are false.
+ if (
+ !guidNode.hasAttribute("isPermaLink") ||
+ guidNode.getAttribute("isPermaLink") == "true"
+ ) {
+ isPermaLink = true;
+ }
+ // If attribute isPermaLink is missing, it is good to check the validity
+ // of <guid> value as an URL to avoid linking to non-URL strings.
+ if (!guidNode.hasAttribute("isPermaLink")) {
+ try {
+ Services.io.newURI(guid);
+ if (Services.io.extractScheme(guid) == "tag") {
+ isPermaLink = false;
+ }
+ } catch (ex) {
+ isPermaLink = false;
+ }
+ }
+
+ item.id = guid;
+ }
+
+ let guidLink = this.validLink(guid);
+ if (isPermaLink && guidLink) {
+ item.url = guidLink;
+ } else if (link) {
+ item.url = link;
+ } else {
+ item.url = null;
+ }
+
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "description");
+ item.description = this.getNodeValueFormatted(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "title");
+ item.title = this.getNodeValue(tags ? tags[0] : null);
+ if (!(item.title || item.description)) {
+ lazy.FeedUtils.log.info(
+ "FeedParser.parseAsRSS2: <item> missing mandatory " +
+ "element, either <title> or <description>; skipping"
+ );
+ continue;
+ }
+
+ if (!item.id) {
+ // At this point, if there is no guid, uniqueness cannot be guaranteed
+ // by any of link or date (optional) or title (optional unless there
+ // is no description). Use a big chunk of description; minimize dupes
+ // with url and title if present.
+ item.id =
+ (item.url || item.feed.url) +
+ "#" +
+ item.title +
+ "#" +
+ (this.stripTags(
+ item.description ? item.description.substr(0, 150) : null
+ ) || item.title);
+ item.id = item.id.replace(/[\n\r\t\s]+/g, " ");
+ }
+
+ // Escape html entities in <title>, which are unescaped as textContent
+ // values. If the title is used as content, it will remain escaped; if
+ // it is used as the title, it will be unescaped upon store. Bug 1240603.
+ // The <description> tag must follow escaping examples found in
+ // http://www.rssboard.org/rss-encoding-examples, i.e. single escape angle
+ // brackets for tags, which are removed if used as title, and double
+ // escape entities for presentation in title.
+ // Better: always use <title>. Best: use Atom.
+ if (!item.title) {
+ item.title = this.stripTags(item.description).substr(0, 150);
+ } else {
+ item.title = item.htmlEscape(item.title);
+ }
+
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "author");
+ if (!tags) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.DC_NS,
+ "creator"
+ );
+ }
+ let author = this.getNodeValue(tags ? tags[0] : null) || aFeed.title;
+ author = this.cleanAuthorName(author);
+ item.author = author ? ["<" + author + ">"] : item.author;
+
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "pubDate");
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(itemNode, lazy.FeedUtils.DC_NS, "date");
+ }
+ item.date = this.getNodeValue(tags ? tags[0] : null) || item.date;
+
+ // If the date is invalid, users will see the beginning of the epoch
+ // unless we reset it here, so they'll see the current time instead.
+ // This is typical aggregator behavior.
+ if (item.date) {
+ item.date = item.date.trim();
+ if (!lazy.FeedUtils.isValidRFC822Date(item.date)) {
+ // XXX Use this on the other formats as well.
+ item.date = this.dateRescue(item.date);
+ }
+ }
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.RSS_CONTENT_NS,
+ "encoded"
+ );
+ item.content = this.getNodeValueFormatted(tags ? tags[0] : null);
+
+ // Handle <enclosures> and <media:content>, which may be in a
+ // <media:group> (if present).
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "enclosure");
+ let encUrls = [];
+ if (tags) {
+ for (let tag of tags) {
+ let url = this.validLink(tag.getAttribute("url"));
+ if (url && !encUrls.includes(url)) {
+ let type = this.removeUnprintableASCII(tag.getAttribute("type"));
+ let length = this.removeUnprintableASCII(
+ tag.getAttribute("length")
+ );
+ item.enclosures.push(new lazy.FeedEnclosure(url, type, length));
+ encUrls.push(url);
+ }
+ }
+ }
+
+ tags = itemNode.getElementsByTagNameNS(lazy.FeedUtils.MRSS_NS, "content");
+ if (tags) {
+ for (let tag of tags) {
+ let url = this.validLink(tag.getAttribute("url"));
+ if (url && !encUrls.includes(url)) {
+ let type = this.removeUnprintableASCII(tag.getAttribute("type"));
+ let fileSize = this.removeUnprintableASCII(
+ tag.getAttribute("fileSize")
+ );
+ item.enclosures.push(new lazy.FeedEnclosure(url, type, fileSize));
+ }
+ }
+ }
+
+ // The <origEnclosureLink> tag has no specification, especially regarding
+ // whether more than one tag is allowed and, if so, how tags would
+ // relate to previously declared (and well specified) enclosure urls.
+ // The common usage is to include 1 origEnclosureLink, in addition to
+ // the specified enclosure tags for 1 enclosure. Thus, we will replace the
+ // first enclosure's, if found, url with the first <origEnclosureLink>
+ // url only or else add the <origEnclosureLink> url.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.FEEDBURNER_NS,
+ "origEnclosureLink"
+ );
+ let origEncUrl = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+ if (origEncUrl) {
+ if (item.enclosures.length) {
+ item.enclosures[0].mURL = origEncUrl;
+ } else {
+ item.enclosures.push(new lazy.FeedEnclosure(origEncUrl));
+ }
+ }
+
+ // Support <category> and autotagging.
+ tags = this.childrenByTagNameNS(itemNode, nsURI, "category");
+ if (tags) {
+ for (let tag of tags) {
+ let term = this.getNodeValue(tag);
+ term = term ? this.xmlUnescape(term.replace(/,/g, ";")) : null;
+ if (term && !item.keywords.includes(term)) {
+ item.keywords.push(term);
+ }
+ }
+ }
+
+ this.parsedItems.push(item);
+ }
+
+ return this.parsedItems;
+ },
+
+ /**
+ * Extracts feed details and (optionally) items from an RSS1
+ * feed which has already been XML-parsed as an XMLDocument.
+ * The feed items are extracted only if feed.parseItems is set.
+ *
+ * Technically RSS1 is supposed to be treated as RDFXML, but in practice
+ * no feed parser anywhere ever does this, and feeds in the wild are
+ * pretty shakey on their RDF encoding too. So we just treat it as raw
+ * XML and pick out the bits we want.
+ *
+ * @param {Feed} feed - The Feed object.
+ * @param {XMLDocument} doc - The document to parse.
+ * @returns {Array} - array of FeedItems or empty array for error returns or
+ * nothing to do condition (ie unset feed.parseItems).
+ */
+ parseAsRSS1(feed, doc) {
+ let channel = doc.querySelector("channel");
+ if (!channel) {
+ feed.onParseError(feed);
+ return [];
+ }
+
+ if (this.isPermanentRedirect(feed, null, channel)) {
+ return [];
+ }
+
+ let titleNode = this.childByTagNameNS(
+ channel,
+ lazy.FeedUtils.RSS_NS,
+ "title"
+ );
+ // If user entered a title manually, retain it.
+ feed.title = feed.title || this.getNodeValue(titleNode) || feed.url;
+
+ let descNode = this.childByTagNameNS(
+ channel,
+ lazy.FeedUtils.RSS_NS,
+ "description"
+ );
+ feed.description = this.getNodeValueFormatted(descNode) || "";
+
+ let linkNode = this.childByTagNameNS(
+ channel,
+ lazy.FeedUtils.RSS_NS,
+ "link"
+ );
+ feed.link = this.validLink(this.getNodeValue(linkNode)) || feed.url;
+
+ if (!(feed.title || feed.description) || !feed.link) {
+ lazy.FeedUtils.log.error(
+ "FeedParser.parseAsRSS1: missing mandatory element " +
+ "<title> and <description>, or <link>"
+ );
+ feed.onParseError(feed);
+ return [];
+ }
+
+ // If we're only interested in the overall feed description, we're done.
+ if (!feed.parseItems) {
+ return [];
+ }
+
+ this.findSyUpdateTags(feed, channel);
+
+ feed.invalidateItems();
+
+ // Now process all the individual items in the feed.
+ let itemNodes = doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_NS, "item");
+ itemNodes = itemNodes ? itemNodes : [];
+
+ for (let itemNode of itemNodes) {
+ let item = new lazy.FeedItem();
+ item.feed = feed;
+
+ // Prefer the value of the link tag to the item URI since the URI could be
+ // a relative URN.
+ let itemURI = itemNode.getAttribute("about") || "";
+ itemURI = this.removeUnprintableASCII(itemURI.trim());
+ let linkNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.RSS_NS,
+ "link"
+ );
+ item.id = this.getNodeValue(linkNode) || itemURI;
+ item.url = this.validLink(item.id);
+
+ let descNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.RSS_NS,
+ "description"
+ );
+ item.description = this.getNodeValueFormatted(descNode);
+
+ let titleNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.RSS_NS,
+ "title"
+ );
+ let subjectNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.DC_NS,
+ "subject"
+ );
+
+ item.title =
+ this.getNodeValue(titleNode) || this.getNodeValue(subjectNode);
+ if (!item.title && item.description) {
+ item.title = this.stripTags(item.description).substr(0, 150);
+ }
+ if (!item.url || !item.title) {
+ lazy.FeedUtils.log.info(
+ "FeedParser.parseAsRSS1: <item> missing mandatory " +
+ "element <item rdf:about> and <link>, or <title> and " +
+ "no <description>; skipping"
+ );
+ continue;
+ }
+
+ // TODO XXX: ignores multiple authors.
+ let authorNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.DC_NS,
+ "creator"
+ );
+ let channelCreatorNode = this.childByTagNameNS(
+ channel,
+ lazy.FeedUtils.DC_NS,
+ "creator"
+ );
+ let author =
+ this.getNodeValue(authorNode) ||
+ this.getNodeValue(channelCreatorNode) ||
+ feed.title;
+ author = this.cleanAuthorName(author);
+ item.author = author ? ["<" + author + ">"] : item.author;
+
+ let dateNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.DC_NS,
+ "date"
+ );
+ item.date = this.getNodeValue(dateNode) || item.date;
+
+ let contentNode = this.childByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.RSS_CONTENT_NS,
+ "encoded"
+ );
+ item.content = this.getNodeValueFormatted(contentNode);
+
+ this.parsedItems.push(item);
+ }
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseAsRSS1: items parsed - " + this.parsedItems.length
+ );
+
+ return this.parsedItems;
+ },
+
+ // TODO: deprecate ATOM_03_NS.
+ parseAsAtom(aFeed, aDOM) {
+ // Get the first channel (assuming there is only one per Atom File).
+ let channel = aDOM.querySelector("feed");
+ if (!channel) {
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ if (this.isPermanentRedirect(aFeed, null, channel)) {
+ return [];
+ }
+
+ let tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_03_NS,
+ "title"
+ );
+ aFeed.title =
+ aFeed.title || this.stripTags(this.getNodeValue(tags ? tags[0] : null));
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_03_NS,
+ "tagline"
+ );
+ aFeed.description = this.getNodeValueFormatted(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(channel, lazy.FeedUtils.ATOM_03_NS, "link");
+ aFeed.link = this.validLink(this.findAtomLink("alternate", tags));
+
+ if (!aFeed.title) {
+ lazy.FeedUtils.log.error(
+ "FeedParser.parseAsAtom: missing mandatory element <title>"
+ );
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ if (!aFeed.parseItems) {
+ return [];
+ }
+
+ this.findSyUpdateTags(aFeed, channel);
+
+ aFeed.invalidateItems();
+ let items = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_03_NS,
+ "entry"
+ );
+ items = items ? items : [];
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseAsAtom: items to parse - " + items.length
+ );
+
+ for (let itemNode of items) {
+ if (!itemNode.childElementCount) {
+ continue;
+ }
+
+ let item = new lazy.FeedItem();
+ item.feed = aFeed;
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "link"
+ );
+ item.url = this.validLink(this.findAtomLink("alternate", tags));
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "id"
+ );
+ item.id = this.getNodeValue(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "summary"
+ );
+ item.description = this.getNodeValueFormatted(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "title"
+ );
+ item.title =
+ this.getNodeValue(tags ? tags[0] : null) ||
+ (item.description ? item.description.substr(0, 150) : null);
+ if (!item.title || !item.id) {
+ // We're lenient about other mandatory tags, but insist on these.
+ lazy.FeedUtils.log.info(
+ "FeedParser.parseAsAtom: <entry> missing mandatory " +
+ "element <id>, or <title> and no <summary>; skipping"
+ );
+ continue;
+ }
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "author"
+ );
+ if (!tags) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "contributor"
+ );
+ }
+ if (!tags) {
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_03_NS,
+ "author"
+ );
+ }
+
+ let authorEl = tags ? tags[0] : null;
+
+ let author = "";
+ if (authorEl) {
+ tags = this.childrenByTagNameNS(
+ authorEl,
+ lazy.FeedUtils.ATOM_03_NS,
+ "name"
+ );
+ let name = this.getNodeValue(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ authorEl,
+ lazy.FeedUtils.ATOM_03_NS,
+ "email"
+ );
+ let email = this.getNodeValue(tags ? tags[0] : null);
+ if (name) {
+ author = name + (email ? " <" + email + ">" : "");
+ } else if (email) {
+ author = email;
+ }
+ }
+
+ item.author = author || item.author || aFeed.title;
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "modified"
+ );
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "issued"
+ );
+ }
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_03_NS,
+ "created"
+ );
+ }
+
+ item.date = this.getNodeValue(tags ? tags[0] : null) || item.date;
+
+ // XXX We should get the xml:base attribute from the content tag as well
+ // and use it as the base HREF of the message.
+ // XXX Atom feeds can have multiple content elements; we should differentiate
+ // between them and pick the best one.
+ // Some Atom feeds wrap the content in a CTYPE declaration; others use
+ // a namespace to identify the tags as HTML; and a few are buggy and put
+ // HTML tags in without declaring their namespace so they look like Atom.
+ // We deal with the first two but not the third.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_03_NS,
+ "content"
+ );
+ let contentNode = tags ? tags[0] : null;
+
+ let content;
+ if (contentNode) {
+ content = "";
+ for (let node of contentNode.childNodes) {
+ if (node.nodeType == node.CDATA_SECTION_NODE) {
+ content += node.data;
+ } else {
+ content += this.mSerializer.serializeToString(node);
+ }
+ }
+
+ if (contentNode.getAttribute("mode") == "escaped") {
+ content = content.replace(/&lt;/g, "<");
+ content = content.replace(/&gt;/g, ">");
+ content = content.replace(/&amp;/g, "&");
+ }
+
+ if (content == "") {
+ content = null;
+ }
+ }
+
+ item.content = content;
+ this.parsedItems.push(item);
+ }
+
+ return this.parsedItems;
+ },
+
+ parseAsAtomIETF(aFeed, aDOM) {
+ // Get the first channel (assuming there is only one per Atom File).
+ let channel = this.childrenByTagNameNS(
+ aDOM,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "feed"
+ )[0];
+ if (!channel) {
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ if (this.isPermanentRedirect(aFeed, null, channel)) {
+ return [];
+ }
+
+ let contentBase = channel.getAttribute("xml:base");
+
+ let tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "title"
+ );
+ aFeed.title =
+ aFeed.title ||
+ this.stripTags(this.serializeTextConstruct(tags ? tags[0] : null));
+
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "subtitle"
+ );
+ aFeed.description = this.serializeTextConstruct(tags ? tags[0] : null);
+
+ // Per spec, aFeed.link and contentBase may both end up null here.
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "link"
+ );
+ aFeed.link =
+ this.findAtomLink("self", tags, contentBase) ||
+ this.findAtomLink("alternate", tags, contentBase);
+ aFeed.link = this.validLink(aFeed.link);
+ if (!contentBase) {
+ contentBase = aFeed.link;
+ }
+
+ if (!aFeed.title) {
+ lazy.FeedUtils.log.error(
+ "FeedParser.parseAsAtomIETF: missing mandatory element <title>"
+ );
+ aFeed.onParseError(aFeed);
+ return [];
+ }
+
+ if (!aFeed.parseItems) {
+ return [];
+ }
+
+ this.findSyUpdateTags(aFeed, channel);
+
+ aFeed.invalidateItems();
+ let items = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "entry"
+ );
+ items = items ? items : [];
+ lazy.FeedUtils.log.debug(
+ "FeedParser.parseAsAtomIETF: items to parse - " + items.length
+ );
+
+ for (let itemNode of items) {
+ if (!itemNode.childElementCount) {
+ continue;
+ }
+
+ let item = new lazy.FeedItem();
+ item.feed = aFeed;
+ item.enclosures = [];
+ item.keywords = [];
+
+ contentBase = itemNode.getAttribute("xml:base") || contentBase;
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "source"
+ );
+ let source = tags ? tags[0] : null;
+
+ // Per spec, item.link and contentBase may both end up null here.
+ // If <content> is also not present, then <link rel="alternate"> is MUST
+ // but we're lenient.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.FEEDBURNER_NS,
+ "origLink"
+ );
+ item.url = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+ if (!item.url) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "link"
+ );
+ item.url =
+ this.validLink(this.findAtomLink("alternate", tags, contentBase)) ||
+ aFeed.link;
+ }
+ if (!contentBase) {
+ contentBase = item.url;
+ }
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "id"
+ );
+ item.id = this.getNodeValue(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "summary"
+ );
+ item.description = this.serializeTextConstruct(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "title"
+ );
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(
+ source,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "title"
+ );
+ }
+ item.title = this.stripTags(
+ this.serializeTextConstruct(tags ? tags[0] : null) ||
+ (item.description ? item.description.substr(0, 150) : null)
+ );
+ if (!item.title || !item.id) {
+ // We're lenient about other mandatory tags, but insist on these.
+ lazy.FeedUtils.log.info(
+ "FeedParser.parseAsAtomIETF: <entry> missing mandatory " +
+ "element <id>, or <title> and no <summary>; skipping"
+ );
+ continue;
+ }
+
+ // Support multiple authors.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "author"
+ );
+ if (!tags) {
+ tags = this.childrenByTagNameNS(
+ source,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "author"
+ );
+ }
+ if (!tags) {
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "author"
+ );
+ }
+
+ let authorTags = tags || [];
+ let authors = [];
+ for (let authorTag of authorTags) {
+ let author = "";
+ tags = this.childrenByTagNameNS(
+ authorTag,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "name"
+ );
+ let name = this.getNodeValue(tags ? tags[0] : null);
+ tags = this.childrenByTagNameNS(
+ authorTag,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "email"
+ );
+ let email = this.getNodeValue(tags ? tags[0] : null);
+ if (name) {
+ name = this.cleanAuthorName(name);
+ if (email) {
+ if (!email.match(/^<.*>$/)) {
+ email = " <" + email + ">";
+ }
+ author = name + email;
+ } else {
+ author = "<" + name + ">";
+ }
+ } else if (email) {
+ author = email;
+ }
+
+ if (author) {
+ authors.push(author);
+ }
+ }
+
+ if (authors.length == 0) {
+ tags = this.childrenByTagNameNS(
+ channel,
+ lazy.FeedUtils.DC_NS,
+ "publisher"
+ );
+ let author = this.getNodeValue(tags ? tags[0] : null) || aFeed.title;
+ author = this.cleanAuthorName(author);
+ item.author = author ? ["<" + author + ">"] : item.author;
+ } else {
+ item.author = authors;
+ }
+ lazy.FeedUtils.log.trace(
+ "FeedParser.parseAsAtomIETF: author(s) - " + item.author
+ );
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "updated"
+ );
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "published"
+ );
+ }
+ if (!tags || !this.getNodeValue(tags[0])) {
+ tags = this.childrenByTagNameNS(
+ source,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "published"
+ );
+ }
+ item.date = this.getNodeValue(tags ? tags[0] : null) || item.date;
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "content"
+ );
+ item.content = this.serializeTextConstruct(tags ? tags[0] : null);
+
+ // Ensure relative links can be resolved and Content-Base set to an
+ // absolute url for the entry. But it's not mandatory that a url is found
+ // for Content-Base, per spec.
+ if (item.content) {
+ item.xmlContentBase =
+ (tags && tags[0].getAttribute("xml:base")) || contentBase;
+ } else if (item.description) {
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "summary"
+ );
+ item.xmlContentBase =
+ (tags && tags[0].getAttribute("xml:base")) || contentBase;
+ } else {
+ item.xmlContentBase = contentBase;
+ }
+
+ item.xmlContentBase = this.validLink(item.xmlContentBase);
+
+ // Handle <link rel="enclosure"> (if present).
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "link"
+ );
+ let encUrls = [];
+ if (tags) {
+ for (let tag of tags) {
+ let url =
+ tag.getAttribute("rel") == "enclosure"
+ ? (tag.getAttribute("href") || "").trim()
+ : null;
+ url = this.validLink(url);
+ if (url && !encUrls.includes(url)) {
+ let type = this.removeUnprintableASCII(tag.getAttribute("type"));
+ let length = this.removeUnprintableASCII(
+ tag.getAttribute("length")
+ );
+ let title = this.removeUnprintableASCII(tag.getAttribute("title"));
+ item.enclosures.push(
+ new lazy.FeedEnclosure(url, type, length, title)
+ );
+ encUrls.push(url);
+ }
+ }
+ }
+
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.FEEDBURNER_NS,
+ "origEnclosureLink"
+ );
+ let origEncUrl = this.validLink(this.getNodeValue(tags ? tags[0] : null));
+ if (origEncUrl) {
+ if (item.enclosures.length) {
+ item.enclosures[0].mURL = origEncUrl;
+ } else {
+ item.enclosures.push(new lazy.FeedEnclosure(origEncUrl));
+ }
+ }
+
+ // Handle atom threading extension, RFC4685. There may be 1 or more tags,
+ // and each must contain a ref attribute with 1 Message-Id equivalent
+ // value. This is the only attr of interest in the spec for presentation.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_THREAD_NS,
+ "in-reply-to"
+ );
+ if (tags) {
+ for (let tag of tags) {
+ let ref = this.removeUnprintableASCII(tag.getAttribute("ref"));
+ if (ref) {
+ item.inReplyTo += item.normalizeMessageID(ref) + " ";
+ }
+ }
+ item.inReplyTo = item.inReplyTo.trimRight();
+ }
+
+ // Support <category> and autotagging.
+ tags = this.childrenByTagNameNS(
+ itemNode,
+ lazy.FeedUtils.ATOM_IETF_NS,
+ "category"
+ );
+ if (tags) {
+ for (let tag of tags) {
+ let term = this.removeUnprintableASCII(tag.getAttribute("term"));
+ term = term ? this.xmlUnescape(term.replace(/,/g, ";")).trim() : null;
+ if (term && !item.keywords.includes(term)) {
+ item.keywords.push(term);
+ }
+ }
+ }
+
+ this.parsedItems.push(item);
+ }
+
+ return this.parsedItems;
+ },
+
+ isPermanentRedirect(aFeed, aRedirDocChannel, aFeedChannel) {
+ // If subscribing to a new feed, do not check redirect tags.
+ if (!aFeed.downloadCallback || aFeed.downloadCallback.mSubscribeMode) {
+ return false;
+ }
+
+ let tags, tagName, newUrl;
+ let oldUrl = aFeed.url;
+
+ // Check for RSS2.0 redirect document <newLocation> tag.
+ if (aRedirDocChannel) {
+ tagName = "newLocation";
+ tags = this.childrenByTagNameNS(aRedirDocChannel, "", tagName);
+ newUrl = this.getNodeValue(tags ? tags[0] : null);
+ }
+
+ // Check for <itunes:new-feed-url> tag.
+ if (aFeedChannel) {
+ tagName = "new-feed-url";
+ tags = this.childrenByTagNameNS(
+ aFeedChannel,
+ lazy.FeedUtils.ITUNES_NS,
+ tagName
+ );
+ newUrl = this.getNodeValue(tags ? tags[0] : null);
+ tagName = "itunes:" + tagName;
+ }
+
+ if (
+ newUrl &&
+ newUrl != oldUrl &&
+ lazy.FeedUtils.isValidScheme(newUrl) &&
+ lazy.FeedUtils.changeUrlForFeed(aFeed, newUrl)
+ ) {
+ lazy.FeedUtils.log.info(
+ "FeedParser.isPermanentRedirect: found <" +
+ tagName +
+ "> tag; updated feed url from: " +
+ oldUrl +
+ " to: " +
+ newUrl +
+ " in folder: " +
+ lazy.FeedUtils.getFolderPrettyPath(aFeed.folder)
+ );
+ aFeed.onUrlChange(aFeed, oldUrl);
+ return true;
+ }
+
+ return false;
+ },
+
+ serializeTextConstruct(textElement) {
+ let content = "";
+ if (textElement) {
+ let textType = textElement.getAttribute("type");
+
+ // Atom spec says consider it "text" if not present.
+ if (!textType) {
+ textType = "text";
+ }
+
+ // There could be some strange content type we don't handle.
+ if (textType != "text" && textType != "html" && textType != "xhtml") {
+ return null;
+ }
+
+ for (let node of textElement.childNodes) {
+ if (node.nodeType == node.CDATA_SECTION_NODE) {
+ content += this.xmlEscape(node.data);
+ } else {
+ content += this.mSerializer.serializeToString(node);
+ }
+ }
+
+ if (textType == "html") {
+ content = this.xmlUnescape(content);
+ }
+
+ content = content.trim();
+ }
+
+ // Other parts of the code depend on this being null if there's no content.
+ return content ? content : null;
+ },
+
+ /**
+ * Return a cleaned up author name value.
+ *
+ * @param {string} authorString - A string.
+ * @returns {String} - A clean string value.
+ */
+ cleanAuthorName(authorString) {
+ if (!authorString) {
+ return "";
+ }
+ lazy.FeedUtils.log.trace(
+ "FeedParser.cleanAuthor: author1 - " + authorString
+ );
+ let author = authorString
+ .replace(/[\n\r\t]+/g, " ")
+ .replace(/"/g, '\\"')
+ .trim();
+ // If the name contains special chars, quote it.
+ if (author.match(/[<>@,"]/)) {
+ author = '"' + author + '"';
+ }
+ lazy.FeedUtils.log.trace("FeedParser.cleanAuthor: author2 - " + author);
+
+ return author;
+ },
+
+ /**
+ * Return a cleaned up node value. This is intended for values that are not
+ * multiline and not formatted. A sequence of tab or newline is converted to
+ * a space and unprintable ascii is removed.
+ *
+ * @param {Node} node - A DOM node.
+ * @returns {String} - A clean string value or null.
+ */
+ getNodeValue(node) {
+ let nodeValue = this.getNodeValueRaw(node);
+ if (!nodeValue) {
+ return null;
+ }
+
+ nodeValue = nodeValue.replace(/[\n\r\t]+/g, " ");
+ return this.removeUnprintableASCII(nodeValue);
+ },
+
+ /**
+ * Return a cleaned up formatted node value, meaning CR/LF/TAB are retained
+ * while all other unprintable ascii is removed. This is intended for values
+ * that are multiline and formatted, such as content or description tags.
+ *
+ * @param {Node} node - A DOM node.
+ * @returns {String} - A clean string value or null.
+ */
+ getNodeValueFormatted(node) {
+ let nodeValue = this.getNodeValueRaw(node);
+ if (!nodeValue) {
+ return null;
+ }
+
+ return this.removeUnprintableASCIIexCRLFTAB(nodeValue);
+ },
+
+ /**
+ * Return a raw node value, as received. This should be sanitized as
+ * appropriate.
+ *
+ * @param {Node} node - A DOM node.
+ * @returns {String} - A string value or null.
+ */
+ getNodeValueRaw(node) {
+ if (node && node.textContent) {
+ return node.textContent.trim();
+ }
+
+ if (node && node.firstChild) {
+ let ret = "";
+ for (let child = node.firstChild; child; child = child.nextSibling) {
+ let value = this.getNodeValueRaw(child);
+ if (value) {
+ ret += value;
+ }
+ }
+
+ if (ret) {
+ return ret.trim();
+ }
+ }
+
+ return null;
+ },
+
+ // Finds elements that are direct children of the first arg.
+ childrenByTagNameNS(aElement, aNamespace, aTagName) {
+ if (!aElement) {
+ return null;
+ }
+
+ let matches = aElement.getElementsByTagNameNS(aNamespace, aTagName);
+ let matchingChildren = [];
+ for (let match of matches) {
+ if (match.parentNode == aElement) {
+ matchingChildren.push(match);
+ }
+ }
+
+ return matchingChildren.length ? matchingChildren : null;
+ },
+
+ /**
+ * Returns first matching descendent of element, or null.
+ *
+ * @param {Element} element - DOM element to search.
+ * @param {string} namespace - Namespace of the search tag.
+ * @param {String} tagName - Tag to search for.
+ * @returns {Element|null} - Matching element, or null.
+ */
+ childByTagNameNS(element, namespace, tagName) {
+ if (!element) {
+ return null;
+ }
+ // Handily, item() returns null for out-of-bounds access.
+ return element.getElementsByTagNameNS(namespace, tagName).item(0);
+ },
+
+ /**
+ * Ensure <link> type tags start with http[s]://, ftp:// or magnet:
+ * for values stored in mail headers (content-base and remote enclosures),
+ * particularly to prevent data: uris, javascript, and other spoofing.
+ *
+ * @param {string} link - An intended http url string.
+ * @returns {String} - A clean string starting with http, ftp or magnet,
+ * else null.
+ */
+ validLink(link) {
+ if (/^((https?|ftp):\/\/|magnet:)/.test(link)) {
+ return this.removeUnprintableASCII(link.trim());
+ }
+
+ return null;
+ },
+
+ /**
+ * Return an absolute link for <entry> relative links. If xml:base is
+ * present in a <feed> attribute or child <link> element attribute, use it;
+ * otherwise the Feed.link will be the relevant <feed> child <link> value
+ * and will be the |baseURI| for <entry> child <link>s if there is no further
+ * xml:base, which may be an attribute of any element.
+ *
+ * @param {string} linkRel - the <link> rel attribute value to find.
+ * @param {NodeList} linkElements - the nodelist of <links> to search in.
+ * @param {string} baseURI - the url to use when resolving relative
+ * links to absolute values.
+ * @returns {String} or null - absolute url for a <link>, or null if the
+ * rel type is not found.
+ */
+ findAtomLink(linkRel, linkElements, baseURI) {
+ if (!linkElements) {
+ return null;
+ }
+
+ // XXX Need to check for MIME type and hreflang.
+ for (let alink of linkElements) {
+ if (
+ alink &&
+ // If there's a link rel.
+ ((alink.getAttribute("rel") && alink.getAttribute("rel") == linkRel) ||
+ // If there isn't, assume 'alternate'.
+ (!alink.getAttribute("rel") && linkRel == "alternate")) &&
+ alink.getAttribute("href")
+ ) {
+ // Atom links are interpreted relative to xml:base.
+ let href = alink.getAttribute("href");
+ baseURI = alink.getAttribute("xml:base") || baseURI || href;
+ try {
+ return Services.io.newURI(baseURI).resolve(href);
+ } catch (ex) {}
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Find RSS Syndication extension tags.
+ * http://web.resource.org/rss/1.0/modules/syndication/
+ *
+ * @param {Feed} aFeed - the feed object.
+ * @param {Node | String} aChannel - dom node for the <channel>.
+ * @returns {void}
+ */
+ findSyUpdateTags(aFeed, aChannel) {
+ let tag, updatePeriod, updateFrequency, updateBase;
+ tag = this.childrenByTagNameNS(
+ aChannel,
+ lazy.FeedUtils.RSS_SY_NS,
+ "updatePeriod"
+ );
+ updatePeriod = this.getNodeValue(tag ? tag[0] : null) || "";
+ tag = this.childrenByTagNameNS(
+ aChannel,
+ lazy.FeedUtils.RSS_SY_NS,
+ "updateFrequency"
+ );
+ updateFrequency = this.getNodeValue(tag ? tag[0] : null) || "";
+ tag = this.childrenByTagNameNS(
+ aChannel,
+ lazy.FeedUtils.RSS_SY_NS,
+ "updateBase"
+ );
+ updateBase = this.getNodeValue(tag ? tag[0] : null) || "";
+ lazy.FeedUtils.log.debug(
+ "FeedParser.findSyUpdateTags: updatePeriod:updateFrequency - " +
+ updatePeriod +
+ ":" +
+ updateFrequency
+ );
+
+ if (updatePeriod) {
+ if (lazy.FeedUtils.RSS_SY_UNITS.includes(updatePeriod.toLowerCase())) {
+ updatePeriod = updatePeriod.toLowerCase();
+ } else {
+ updatePeriod = "daily";
+ }
+ }
+
+ updateFrequency = isNaN(updateFrequency) ? 1 : updateFrequency;
+
+ let options = aFeed.options;
+ if (
+ options.updates.updatePeriod == updatePeriod &&
+ options.updates.updateFrequency == updateFrequency &&
+ options.updates.updateBase == updateBase
+ ) {
+ return;
+ }
+
+ options.updates.updatePeriod = updatePeriod;
+ options.updates.updateFrequency = updateFrequency;
+ options.updates.updateBase = updateBase;
+ aFeed.options = options;
+ },
+
+ /**
+ * Remove unprintable ascii, particularly CR/LF, for non formatted tag values.
+ *
+ * @param {string} s - String to clean.
+ * @returns {String} - Cleaned string.
+ */
+ removeUnprintableASCII(s) {
+ /* eslint-disable-next-line no-control-regex */
+ return s ? s.replace(/[\x00-\x1F\x7F]+/g, "") : "";
+ },
+
+ /**
+ * Remove unprintable ascii, except CR/LF/TAB, for formatted tag values.
+ *
+ * @param {string} s - String to clean.
+ * @returns {String} - Cleaned string.
+ */
+ removeUnprintableASCIIexCRLFTAB(s) {
+ /* eslint-disable-next-line no-control-regex */
+ return s ? s.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]+/g, "") : "";
+ },
+
+ stripTags(someHTML) {
+ return someHTML ? someHTML.replace(/<[^>]+>/g, "") : someHTML;
+ },
+
+ xmlUnescape(s) {
+ s = s.replace(/&lt;/g, "<");
+ s = s.replace(/&gt;/g, ">");
+ s = s.replace(/&amp;/g, "&");
+ return s;
+ },
+
+ xmlEscape(s) {
+ s = s.replace(/&/g, "&amp;");
+ s = s.replace(/>/g, "&gt;");
+ s = s.replace(/</g, "&lt;");
+ return s;
+ },
+
+ dateRescue(dateString) {
+ // Deal with various kinds of invalid dates.
+ if (!isNaN(parseInt(dateString))) {
+ // It's an integer, so maybe it's a timestamp.
+ let d = new Date(parseInt(dateString) * 1000);
+ let now = new Date();
+ let yeardiff = now.getFullYear() - d.getFullYear();
+ lazy.FeedUtils.log.trace(
+ "FeedParser.dateRescue: Rescue Timestamp date - " +
+ d.toString() +
+ " ,year diff - " +
+ yeardiff
+ );
+ if (yeardiff >= 0 && yeardiff < 3) {
+ // It's quite likely the correct date.
+ return d.toString();
+ }
+ }
+
+ // Could be an ISO8601/W3C date. If not, get the current time.
+ return lazy.FeedUtils.getValidRFC5322Date(dateString);
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/FeedUtils.jsm b/comm/mailnews/extensions/newsblog/FeedUtils.jsm
new file mode 100644
index 0000000000..b881c358d8
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/FeedUtils.jsm
@@ -0,0 +1,2136 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+const EXPORTED_SYMBOLS = ["FeedUtils"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Feed: "resource:///modules/Feed.jsm",
+ jsmime: "resource:///modules/jsmime.jsm",
+ MailUtils: "resource:///modules/MailUtils.jsm",
+});
+
+var FeedUtils = {
+ MOZ_PARSERERROR_NS: "http://www.mozilla.org/newlayout/xml/parsererror.xml",
+
+ RDF_SYNTAX_NS: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ RDF_SYNTAX_TYPE: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
+ RSS_090_NS: "http://my.netscape.com/rdf/simple/0.9/",
+
+ RSS_NS: "http://purl.org/rss/1.0/",
+
+ RSS_CONTENT_NS: "http://purl.org/rss/1.0/modules/content/",
+
+ RSS_SY_NS: "http://purl.org/rss/1.0/modules/syndication/",
+ RSS_SY_UNITS: ["hourly", "daily", "weekly", "monthly", "yearly"],
+ kBiffUnitsMinutes: "min",
+ kBiffUnitsDays: "d",
+
+ DC_NS: "http://purl.org/dc/elements/1.1/",
+
+ MRSS_NS: "http://search.yahoo.com/mrss/",
+ FEEDBURNER_NS: "http://rssnamespace.org/feedburner/ext/1.0",
+ ITUNES_NS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
+
+ FZ_NS: "urn:forumzilla:",
+ FZ_ITEM_NS: "urn:feeditem:",
+
+ // Atom constants
+ ATOM_03_NS: "http://purl.org/atom/ns#",
+ ATOM_IETF_NS: "http://www.w3.org/2005/Atom",
+ ATOM_THREAD_NS: "http://purl.org/syndication/thread/1.0",
+
+ // Accept content mimetype preferences for feeds.
+ REQUEST_ACCEPT:
+ "application/atom+xml," +
+ "application/rss+xml;q=0.9," +
+ "application/rdf+xml;q=0.8," +
+ "application/xml;q=0.7,text/xml;q=0.7," +
+ "*/*;q=0.1",
+ // Timeout for nonresponse to request, 30 seconds.
+ REQUEST_TIMEOUT: 30 * 1000,
+
+ MILLISECONDS_PER_DAY: 24 * 60 * 60 * 1000,
+
+ // Maximum number of concurrent in progress feeds, across all accounts.
+ kMaxConcurrentFeeds: 25,
+ get MAX_CONCURRENT_FEEDS() {
+ let pref = "rss.max_concurrent_feeds";
+ if (Services.prefs.prefHasUserValue(pref)) {
+ return Services.prefs.getIntPref(pref);
+ }
+
+ Services.prefs.setIntPref(pref, FeedUtils.kMaxConcurrentFeeds);
+ return FeedUtils.kMaxConcurrentFeeds;
+ },
+
+ // The amount of time, specified in milliseconds, to leave an item in the
+ // feeditems cache after the item has disappeared from the publisher's
+ // file. The default delay is one day.
+ kInvalidItemPurgeDelayDays: 1,
+ get INVALID_ITEM_PURGE_DELAY() {
+ let pref = "rss.invalid_item_purge_delay_days";
+ if (Services.prefs.prefHasUserValue(pref)) {
+ return Services.prefs.getIntPref(pref) * this.MILLISECONDS_PER_DAY;
+ }
+
+ Services.prefs.setIntPref(pref, FeedUtils.kInvalidItemPurgeDelayDays);
+ return FeedUtils.kInvalidItemPurgeDelayDays * this.MILLISECONDS_PER_DAY;
+ },
+
+ // Polling interval to check individual feed update interval preference.
+ kBiffPollMinutes: 1,
+ kNewsBlogSuccess: 0,
+ // Usually means there was an error trying to parse the feed.
+ kNewsBlogInvalidFeed: 1,
+ // Generic networking failure when trying to download the feed.
+ kNewsBlogRequestFailure: 2,
+ kNewsBlogFeedIsBusy: 3,
+ // For 304 Not Modified; There are no new articles for this feed.
+ kNewsBlogNoNewItems: 4,
+ kNewsBlogCancel: 5,
+ kNewsBlogFileError: 6,
+ // Invalid certificate, for overridable user exception errors.
+ kNewsBlogBadCertError: 7,
+ // For 401 Unauthorized or 403 Forbidden.
+ kNewsBlogNoAuthError: 8,
+
+ CANCEL_REQUESTED: false,
+ AUTOTAG: "~AUTOTAG",
+
+ FEED_ACCOUNT_TYPES: ["rss"],
+
+ /**
+ * Get all rss account servers rootFolders.
+ *
+ * @returns {nsIMsgIncomingServer}[] - Array of servers (empty array if none).
+ */
+ getAllRssServerRootFolders() {
+ let rssRootFolders = [];
+ for (let server of MailServices.accounts.allServers) {
+ if (server && server.type == "rss") {
+ rssRootFolders.push(server.rootFolder);
+ }
+ }
+
+ // By default, Tb sorts by hostname, ie Feeds, Feeds-1, and not by alpha
+ // prettyName. Do the same as a stock install to match folderpane order.
+ rssRootFolders.sort(function (a, b) {
+ return a.hostname > b.hostname;
+ });
+
+ return rssRootFolders;
+ },
+
+ /**
+ * Create rss account.
+ *
+ * @param {String} aName - Optional account name to override default.
+ * @returns {nsIMsgAccount} - The creaged account.
+ */
+ createRssAccount(aName) {
+ let userName = "nobody";
+ let hostName = "Feeds";
+ let hostNamePref = hostName;
+ let server;
+ let serverType = "rss";
+ let defaultName = FeedUtils.strings.GetStringFromName("feeds-accountname");
+ let i = 2;
+ while (MailServices.accounts.findServer(userName, hostName, serverType)) {
+ // If "Feeds" exists, try "Feeds-2", then "Feeds-3", etc.
+ hostName = hostNamePref + "-" + i++;
+ }
+
+ server = MailServices.accounts.createIncomingServer(
+ userName,
+ hostName,
+ serverType
+ );
+ server.biffMinutes = FeedUtils.kBiffPollMinutes;
+ server.prettyName = aName ? aName : defaultName;
+ server.valid = true;
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = server;
+ // Initialize the feed_options now.
+ this.getOptionsAcct(server);
+
+ // Ensure the Trash folder db (.msf) is created otherwise folder/message
+ // deletes will throw until restart creates it.
+ server.msgStore.discoverSubFolders(server.rootMsgFolder, false);
+
+ // Save new accounts in case of a crash.
+ try {
+ MailServices.accounts.saveAccountInfo();
+ } catch (ex) {
+ this.log.error(
+ "FeedUtils.createRssAccount: error on saveAccountInfo - " + ex
+ );
+ }
+
+ this.log.debug(
+ "FeedUtils.createRssAccount: " +
+ account.incomingServer.rootFolder.prettyName
+ );
+
+ return account;
+ },
+
+ /**
+ * Helper routine that checks our subscriptions list array and returns
+ * true if the url is already in our list. This is used to prevent the
+ * user from subscribing to the same feed multiple times for the same server.
+ *
+ * @param {string} aUrl - The url.
+ * @param {nsIMsgIncomingServer} aServer - Account server.
+ * @returns {Boolean} - true if exists else false.
+ */
+ feedAlreadyExists(aUrl, aServer) {
+ let ds = this.getSubscriptionsDS(aServer);
+ let sub = ds.data.find(x => x.url == aUrl);
+ if (sub === undefined) {
+ return false;
+ }
+ let folder = sub.destFolder;
+ this.log.info(
+ "FeedUtils.feedAlreadyExists: feed url " +
+ aUrl +
+ " subscribed in folder url " +
+ decodeURI(folder)
+ );
+
+ return true;
+ },
+
+ /**
+ * Download a feed url on biff or get new messages.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ * @param {nsIUrlListener} aUrlListener - Feed url.
+ * @param {Boolean} aIsBiff - true if biff, false if manual get.
+ * @param {nsIDOMWindow} aMsgWindow - The window.
+ *
+ * @returns {Promise<void>} When all feeds downloading have been set off.
+ */
+ async downloadFeed(aFolder, aUrlListener, aIsBiff, aMsgWindow) {
+ FeedUtils.log.debug(
+ "downloadFeed: account isBiff:isOffline - " +
+ aIsBiff +
+ " : " +
+ Services.io.offline
+ );
+ // User set.
+ if (Services.io.offline) {
+ return;
+ }
+
+ // No network connection. Unfortunately, this is only set if the event is
+ // received by Tb from the OS (ie it must already be running) and doesn't
+ // necessarily mean connectivity to the internet, only the nearest network
+ // point. But it's something.
+ if (!Services.io.connectivity) {
+ FeedUtils.log.warn("downloadFeed: network connection unavailable");
+ return;
+ }
+
+ // We don't yet support the ability to check for new articles while we are
+ // in the middle of subscribing to a feed. For now, abort the check for
+ // new feeds.
+ if (FeedUtils.progressNotifier.mSubscribeMode) {
+ FeedUtils.log.warn(
+ "downloadFeed: Aborting RSS New Mail Check. " +
+ "Feed subscription in progress\n"
+ );
+ return;
+ }
+
+ let forceDownload = !aIsBiff;
+ let inStartup = false;
+ if (aFolder.isServer) {
+ // The lastUpdateTime is |null| only at session startup/initialization.
+ // Note: feed processing does not impact startup, as the biff poll
+ // will go off in about kBiffPollMinutes (1) and process each feed
+ // according to its own lastUpdatTime/update frequency.
+ if (FeedUtils.getStatus(aFolder, aFolder.URI).lastUpdateTime === null) {
+ inStartup = true;
+ }
+
+ FeedUtils.setStatus(aFolder, aFolder.URI, "lastUpdateTime", Date.now());
+ }
+
+ let allFolders = aFolder.descendants;
+ if (!aFolder.isServer) {
+ // Add the base folder; it does not get returned by .descendants. Do not
+ // add the account folder as it doesn't have the feedUrl property or even
+ // a msgDatabase necessarily.
+ allFolders.unshift(aFolder);
+ }
+
+ let folder;
+ async function* feeder() {
+ for (let i = 0; i < allFolders.length; i++) {
+ folder = allFolders[i];
+ FeedUtils.log.debug(
+ "downloadFeed: START x/# folderName:folderPath - " +
+ (i + 1) +
+ "/" +
+ allFolders.length +
+ " " +
+ folder.name +
+ " : " +
+ folder.filePath.path
+ );
+
+ let feedUrlArray = FeedUtils.getFeedUrlsInFolder(folder);
+ // Continue if there are no feedUrls for the folder in the feeds
+ // database. All folders in Trash are skipped.
+ if (!feedUrlArray) {
+ continue;
+ }
+
+ FeedUtils.log.debug(
+ "downloadFeed: CONTINUE foldername:urlArray - " +
+ folder.name +
+ " : " +
+ feedUrlArray
+ );
+
+ // We need to kick off a download for each feed.
+ let now = Date.now();
+ for (let url of feedUrlArray) {
+ // Check whether this feed should be updated; if forceDownload is true
+ // skip the per feed check.
+ let status = FeedUtils.getStatus(folder, url);
+ if (!forceDownload) {
+ // Also skip if user paused, or error paused (but not inStartup;
+ // go check the feed again), or update interval hasn't expired.
+ if (
+ status.enabled === false ||
+ (status.enabled === null && !inStartup) ||
+ now - status.lastUpdateTime < status.updateMinutes * 60000
+ ) {
+ FeedUtils.log.debug(
+ "downloadFeed: SKIP feed, " +
+ "aIsBiff:enabled:minsSinceLastUpdate::url - " +
+ aIsBiff +
+ " : " +
+ status.enabled +
+ " : " +
+ Math.round((now - status.lastUpdateTime) / 60) / 1000 +
+ " :: " +
+ url
+ );
+ continue;
+ }
+ }
+ // Update feed icons only once every 24h, tops.
+ if (
+ forceDownload ||
+ now - status.lastUpdateTime >= 24 * 60 * 60 * 1000
+ ) {
+ await FeedUtils.getFavicon(folder, url);
+ }
+
+ // Create a feed object.
+ let feed = new lazy.Feed(url, folder);
+
+ // init can be called multiple times. Checks if it should actually
+ // init itself.
+ FeedUtils.progressNotifier.init(aMsgWindow, false);
+
+ // Bump our pending feed download count. From now on, all feeds will
+ // be resolved and finish with progressNotifier.downloaded(). Any
+ // early returns must call downloaded() so mNumPendingFeedDownloads
+ // is decremented and notification/status feedback is reset.
+ FeedUtils.progressNotifier.mNumPendingFeedDownloads++;
+
+ // If the current active count exceeds the max desired, exit from
+ // the current poll cycle. Only throttle for a background biff; for
+ // a user manual get messages, do them all.
+ if (
+ aIsBiff &&
+ FeedUtils.progressNotifier.mNumPendingFeedDownloads >
+ FeedUtils.MAX_CONCURRENT_FEEDS
+ ) {
+ FeedUtils.log.debug(
+ "downloadFeed: RETURN active feeds count is greater " +
+ "than the max - " +
+ FeedUtils.MAX_CONCURRENT_FEEDS
+ );
+ FeedUtils.progressNotifier.downloaded(
+ feed,
+ FeedUtils.kNewsBlogFeedIsBusy
+ );
+ return;
+ }
+
+ // Set status info and download.
+ FeedUtils.log.debug("downloadFeed: DOWNLOAD feed url - " + url);
+ FeedUtils.setStatus(
+ folder,
+ url,
+ "code",
+ FeedUtils.kNewsBlogFeedIsBusy
+ );
+ feed.download(true, FeedUtils.progressNotifier);
+
+ Services.tm.mainThread.dispatch(function () {
+ try {
+ let done = getFeed.next().done;
+ if (done) {
+ // Finished with all feeds in base aFolder and its subfolders.
+ FeedUtils.log.debug(
+ "downloadFeed: Finished with folder - " + aFolder.name
+ );
+ folder = null;
+ allFolders = null;
+ }
+ } catch (ex) {
+ FeedUtils.log.error("downloadFeed: error - " + ex);
+ FeedUtils.progressNotifier.downloaded(
+ feed,
+ FeedUtils.kNewsBlogFeedIsBusy
+ );
+ }
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+
+ yield undefined;
+ }
+ }
+ }
+
+ let getFeed = await feeder();
+ try {
+ let done = getFeed.next().done;
+ if (done) {
+ // Nothing to do.
+ FeedUtils.log.debug(
+ "downloadFeed: Nothing to do in folder - " + aFolder.name
+ );
+ folder = null;
+ allFolders = null;
+ }
+ } catch (ex) {
+ FeedUtils.log.error("downloadFeed: error - " + ex);
+ FeedUtils.progressNotifier.downloaded(
+ { folder: aFolder, url: "" },
+ FeedUtils.kNewsBlogFeedIsBusy
+ );
+ }
+ },
+
+ /**
+ * Subscribe a new feed url.
+ *
+ * @param {String} aUrl - Feed url.
+ * @param {nsIMsgFolder} aFolder - Folder.
+ *
+ * @returns {void}
+ */
+ subscribeToFeed(aUrl, aFolder) {
+ // We don't support the ability to subscribe to several feeds at once yet.
+ // For now, abort the subscription if we are already in the middle of
+ // subscribing to a feed via drag and drop.
+ if (FeedUtils.progressNotifier.mNumPendingFeedDownloads > 0) {
+ FeedUtils.log.warn(
+ "subscribeToFeed: Aborting RSS subscription. " +
+ "Feed downloads already in progress\n"
+ );
+ return;
+ }
+
+ // If aFolder is null, then use the root folder for the first RSS account.
+ if (!aFolder) {
+ aFolder = FeedUtils.getAllRssServerRootFolders()[0];
+ }
+
+ // If the user has no Feeds account yet, create one.
+ if (!aFolder) {
+ aFolder = FeedUtils.createRssAccount().incomingServer.rootFolder;
+ }
+
+ // If aUrl is a feed url, then it is either of the form
+ // feed://example.org/feed.xml or feed:https://example.org/feed.xml.
+ // Replace feed:// with http:// per the spec, then strip off feed:
+ // for the second case.
+ aUrl = aUrl.replace(/^feed:\x2f\x2f/i, "http://");
+ aUrl = aUrl.replace(/^feed:/i, "");
+
+ let msgWindow = Services.wm.getMostRecentWindow("mail:3pane").msgWindow;
+
+ // Make sure we aren't already subscribed to this feed before we attempt
+ // to subscribe to it.
+ if (FeedUtils.feedAlreadyExists(aUrl, aFolder.server)) {
+ msgWindow.statusFeedback.showStatusString(
+ FeedUtils.strings.GetStringFromName("subscribe-feedAlreadySubscribed")
+ );
+ return;
+ }
+
+ let feed = new lazy.Feed(aUrl, aFolder);
+ // Default setting for new feeds per account settings.
+ feed.quickMode = feed.server.getBoolValue("quickMode");
+ feed.options = FeedUtils.getOptionsAcct(feed.server);
+
+ FeedUtils.progressNotifier.init(msgWindow, true);
+ FeedUtils.progressNotifier.mNumPendingFeedDownloads++;
+ feed.download(true, FeedUtils.progressNotifier);
+ },
+
+ /**
+ * Enable or disable updates for all subscriptions in a folder, or all
+ * subscriptions in an account if the folder is the account folder.
+ * A folder's subfolders' feeds are not included.
+ *
+ * @param {nsIMsgFolder} aFolder - Folder or account folder (server).
+ * @param {boolean} aPause - To pause or not to pause.
+ * @param {Boolean} aBiffNow - If aPause is false, and aBiffNow is true
+ * do the biff immediately.
+ * @returns {void}
+ */
+ pauseFeedFolderUpdates(aFolder, aPause, aBiffNow) {
+ if (aFolder.isServer) {
+ let serverFolder = aFolder.server.rootFolder;
+ // Remove server from biff first. If enabling biff, this will make the
+ // latest biffMinutes take effect now rather than waiting for the timer
+ // to expire.
+ aFolder.server.doBiff = false;
+ if (!aPause) {
+ aFolder.server.doBiff = true;
+ }
+
+ FeedUtils.setStatus(serverFolder, serverFolder.URI, "enabled", !aPause);
+ if (!aPause && aBiffNow) {
+ aFolder.server.performBiff(null);
+ }
+
+ return;
+ }
+
+ let feedUrls = FeedUtils.getFeedUrlsInFolder(aFolder);
+ if (!feedUrls) {
+ return;
+ }
+
+ for (let feedUrl of feedUrls) {
+ let feed = new lazy.Feed(feedUrl, aFolder);
+ let options = feed.options;
+ options.updates.enabled = !aPause;
+ feed.options = options;
+ FeedUtils.setStatus(aFolder, feedUrl, "enabled", !aPause);
+ FeedUtils.log.debug(
+ "pauseFeedFolderUpdates: enabled:url " + !aPause + ": " + feedUrl
+ );
+ }
+
+ let win = Services.wm.getMostRecentWindow("Mail:News-BlogSubscriptions");
+ if (win) {
+ let curItem = win.FeedSubscriptions.mView.currentItem;
+ win.FeedSubscriptions.refreshSubscriptionView();
+ if (curItem.container) {
+ win.FeedSubscriptions.selectFolder(curItem.folder);
+ } else {
+ let feed = new lazy.Feed(curItem.url, curItem.parentFolder);
+ win.FeedSubscriptions.selectFeed(feed);
+ }
+ }
+ },
+
+ /**
+ * Add a feed record to the feeds database and update the folder's feedUrl
+ * property.
+ *
+ * @param {Feed} aFeed - Our feed object.
+ *
+ * @returns {void}
+ */
+ addFeed(aFeed) {
+ // Find or create subscription entry.
+ let ds = this.getSubscriptionsDS(aFeed.server);
+ let sub = ds.data.find(x => x.url == aFeed.url);
+ if (sub === undefined) {
+ sub = {};
+ ds.data.push(sub);
+ }
+ sub.url = aFeed.url;
+ sub.destFolder = aFeed.folder.URI;
+ if (aFeed.title) {
+ sub.title = aFeed.title;
+ }
+ ds.saveSoon();
+
+ // Update folderpane.
+ Services.obs.notifyObservers(aFeed.folder, "folder-properties-changed");
+ },
+
+ /**
+ * Delete a feed record from the feeds database and update the folder's
+ * feedUrl property.
+ *
+ * @param {Feed} aFeed - Our feed object.
+ *
+ * @returns {void}
+ */
+ deleteFeed(aFeed) {
+ // Remove items associated with this feed from the items db.
+ aFeed.invalidateItems();
+ aFeed.removeInvalidItems(true);
+
+ // Remove the entry in the subscriptions db.
+ let ds = this.getSubscriptionsDS(aFeed.server);
+ ds.data = ds.data.filter(x => x.url != aFeed.url);
+ ds.saveSoon();
+
+ // Update folderpane.
+ Services.obs.notifyObservers(aFeed.folder, "folder-properties-changed");
+ },
+
+ /**
+ * Change an existing feed's url.
+ *
+ * @param {Feed} aFeed - The feed object.
+ * @param {string} aNewUrl - New url.
+ *
+ * @returns {Boolean} - true if successful, else false.
+ */
+ changeUrlForFeed(aFeed, aNewUrl) {
+ if (!aFeed || !aFeed.folder || !aNewUrl) {
+ return false;
+ }
+
+ if (this.feedAlreadyExists(aNewUrl, aFeed.server)) {
+ this.log.info(
+ "FeedUtils.changeUrlForFeed: new feed url " +
+ aNewUrl +
+ " already subscribed in account " +
+ aFeed.server.prettyName
+ );
+ return false;
+ }
+
+ let title = aFeed.title;
+ let link = aFeed.link;
+ let quickMode = aFeed.quickMode;
+ let options = aFeed.options;
+
+ this.deleteFeed(aFeed);
+ aFeed.url = aNewUrl;
+ aFeed.title = title;
+ aFeed.link = link;
+ aFeed.quickMode = quickMode;
+ aFeed.options = options;
+ this.addFeed(aFeed);
+
+ let win = Services.wm.getMostRecentWindow("Mail:News-BlogSubscriptions");
+ if (win) {
+ win.FeedSubscriptions.refreshSubscriptionView(aFeed.folder, aNewUrl);
+ }
+
+ return true;
+ },
+
+ /**
+ * Determine if a message is a feed message. Prior to Tb15, a message had to
+ * be in an rss acount type folder. In Tb15 and later, a flag is set on the
+ * message itself upon initial store; the message can be moved to any folder.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message.
+ *
+ * @returns {Boolean} - true if message is a feed, false if not.
+ */
+ isFeedMessage(aMsgHdr) {
+ return Boolean(
+ aMsgHdr instanceof Ci.nsIMsgDBHdr &&
+ (aMsgHdr.flags & Ci.nsMsgMessageFlags.FeedMsg ||
+ this.isFeedFolder(aMsgHdr.folder))
+ );
+ },
+
+ /**
+ * Determine if a folder is a feed acount folder. Trash or a folder in Trash
+ * should be checked with FeedUtils.isInTrash() if required.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ *
+ * @returns {Boolean} - true if folder's server.type is in FEED_ACCOUNT_TYPES,
+ * false if not.
+ */
+ isFeedFolder(aFolder) {
+ return Boolean(
+ aFolder instanceof Ci.nsIMsgFolder &&
+ this.FEED_ACCOUNT_TYPES.includes(aFolder.server.type)
+ );
+ },
+
+ /**
+ * Get the list of feed urls for a folder.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ *
+ * @returns {String}[] - Array of urls, or null if none.
+ */
+ getFeedUrlsInFolder(aFolder) {
+ if (
+ !aFolder ||
+ aFolder.isServer ||
+ aFolder.server.type != "rss" ||
+ aFolder.getFlag(Ci.nsMsgFolderFlags.Trash) ||
+ aFolder.getFlag(Ci.nsMsgFolderFlags.Virtual) ||
+ !aFolder.filePath.exists()
+ ) {
+ // There are never any feedUrls in the account/non-feed/trash/virtual
+ // folders or in a ghost folder (nonexistent on disk yet found in
+ // aFolder.subFolders).
+ return null;
+ }
+
+ let feedUrlArray = [];
+
+ // Get the list from the feeds database.
+ try {
+ let ds = this.getSubscriptionsDS(aFolder.server);
+ for (const sub of ds.data) {
+ if (sub.destFolder == aFolder.URI) {
+ feedUrlArray.push(sub.url);
+ }
+ }
+ } catch (ex) {
+ this.log.error("getFeedUrlsInFolder: feeds db error - " + ex);
+ this.log.error(
+ "getFeedUrlsInFolder: feeds db error for account - " +
+ aFolder.server.serverURI +
+ " : " +
+ aFolder.server.prettyName
+ );
+ }
+
+ return feedUrlArray.length ? feedUrlArray : null;
+ },
+
+ /**
+ * Check if the folder's msgDatabase is openable, reparse if desired.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ * @param {boolean} aReparse - Reparse if true.
+ * @param {nsIUrlListener} aUrlListener - Object implementing nsIUrlListener.
+ *
+ * @returns {Boolean} - true if msgDb is available, else false
+ */
+ isMsgDatabaseOpenable(aFolder, aReparse, aUrlListener) {
+ let msgDb;
+ try {
+ msgDb = Cc["@mozilla.org/msgDatabase/msgDBService;1"]
+ .getService(Ci.nsIMsgDBService)
+ .openFolderDB(aFolder, true);
+ } catch (ex) {}
+
+ if (msgDb) {
+ return true;
+ }
+
+ if (!aReparse) {
+ return false;
+ }
+
+ // Force a reparse.
+ FeedUtils.log.debug(
+ "checkMsgDb: rebuild msgDatabase for " +
+ aFolder.name +
+ " - " +
+ aFolder.filePath.path
+ );
+ try {
+ // Ignore error returns.
+ aFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .getDatabaseWithReparse(aUrlListener, null);
+ } catch (ex) {}
+
+ return false;
+ },
+
+ /**
+ * Return properties for nsITreeView getCellProperties, for a tree row item in
+ * folderpane or subscribe dialog tree.
+ *
+ * @param {nsIMsgFolder} aFolder - Folder or a feed url's parent folder.
+ * @param {string} aFeedUrl - Feed url for a feed row, null for folder.
+ *
+ * @returns {string} - Space separated properties.
+ */
+ getFolderProperties(aFolder, aFeedUrl) {
+ let folder = aFolder;
+ let feedUrls = aFeedUrl ? [aFeedUrl] : this.getFeedUrlsInFolder(aFolder);
+ if (!feedUrls && !folder.isServer) {
+ return "";
+ }
+
+ let serverEnabled = this.getStatus(
+ folder.server.rootFolder,
+ folder.server.rootFolder.URI
+ ).enabled;
+ if (folder.isServer) {
+ return !serverEnabled ? " isPaused" : "";
+ }
+
+ let properties = aFeedUrl ? " isFeed-true" : " isFeedFolder-true";
+ let hasError,
+ isBusy,
+ numPaused = 0;
+ for (let feedUrl of feedUrls) {
+ let feedStatus = this.getStatus(folder, feedUrl);
+ if (
+ feedStatus.code == FeedUtils.kNewsBlogInvalidFeed ||
+ feedStatus.code == FeedUtils.kNewsBlogRequestFailure ||
+ feedStatus.code == FeedUtils.kNewsBlogBadCertError ||
+ feedStatus.code == FeedUtils.kNewsBlogNoAuthError
+ ) {
+ hasError = true;
+ }
+ if (feedStatus.code == FeedUtils.kNewsBlogFeedIsBusy) {
+ isBusy = true;
+ }
+ if (!feedStatus.enabled) {
+ numPaused++;
+ }
+ }
+
+ properties += hasError ? " hasError" : "";
+ properties += isBusy ? " isBusy" : "";
+ properties += numPaused == feedUrls.length ? " isPaused" : "";
+
+ return properties;
+ },
+
+ /**
+ * Get a cached feed or folder status.
+ *
+ * @param {nsIMsgFolder} aFolder - Folder.
+ * @param {string} aUrl - Url key (feed url or folder URI).
+ *
+ * @returns {String} aValue - The value.
+ */
+ getStatus(aFolder, aUrl) {
+ if (!aFolder || !aUrl) {
+ return null;
+ }
+
+ let serverKey = aFolder.server.serverURI;
+ if (!this[serverKey]) {
+ this[serverKey] = {};
+ }
+
+ if (!this[serverKey][aUrl]) {
+ // Seed the status object.
+ this[serverKey][aUrl] = {};
+ this[serverKey][aUrl].status = this.statusTemplate;
+ if (FeedUtils.isValidScheme(aUrl)) {
+ // Seed persisted status properties for feed urls.
+ let feed = new lazy.Feed(aUrl, aFolder);
+ this[serverKey][aUrl].status.enabled = feed.options.updates.enabled;
+ this[serverKey][aUrl].status.updateMinutes =
+ feed.options.updates.updateMinutes;
+ this[serverKey][aUrl].status.lastUpdateTime =
+ feed.options.updates.lastUpdateTime;
+ feed = null;
+ } else {
+ // Seed persisted status properties for servers.
+ let optionsAcct = FeedUtils.getOptionsAcct(aFolder.server);
+ this[serverKey][aUrl].status.enabled = optionsAcct.doBiff;
+ }
+ FeedUtils.log.debug("getStatus: seed url - " + aUrl);
+ }
+
+ return this[serverKey][aUrl].status;
+ },
+
+ /**
+ * Update a feed or folder status and refresh folderpane.
+ *
+ * @param {nsIMsgFolder} aFolder - Folder.
+ * @param {string} aUrl - Url key (feed url or folder URI).
+ * @param {string} aProperty - Url status property.
+ * @param {string} aValue - Value.
+ *
+ * @returns {void}
+ */
+ setStatus(aFolder, aUrl, aProperty, aValue) {
+ if (!aFolder || !aUrl || !aProperty) {
+ return;
+ }
+
+ if (
+ !this[aFolder.server.serverURI] ||
+ !this[aFolder.server.serverURI][aUrl]
+ ) {
+ // Not yet seeded, so do it.
+ this.getStatus(aFolder, aUrl);
+ }
+
+ this[aFolder.server.serverURI][aUrl].status[aProperty] = aValue;
+
+ Services.obs.notifyObservers(aFolder, "folder-properties-changed");
+
+ let win = Services.wm.getMostRecentWindow("Mail:News-BlogSubscriptions");
+ if (win) {
+ win.FeedSubscriptions.mView.tree.invalidate();
+ }
+ },
+
+ /**
+ * Get the favicon for a feed folder subscription url (first one) or a feed
+ * message url. The favicon service caches it in memory if places history is
+ * not enabled.
+ *
+ * @param {?nsIMsgFolder} folder - The feed folder or null if url set.
+ * @param {?string} feedURL - A url (feed, message, other) or null if folder set.
+ *
+ * @returns {Promise<string>} - The favicon url or empty string.
+ */
+ async getFavicon(folder, feedURL) {
+ if (
+ !Services.prefs.getBoolPref("browser.chrome.site_icons") ||
+ !Services.prefs.getBoolPref("browser.chrome.favicons") ||
+ !Services.prefs.getBoolPref("places.history.enabled")
+ ) {
+ return "";
+ }
+
+ let url = feedURL;
+ if (!url) {
+ // Get the proposed iconUrl from the folder's first subscribed feed's
+ // <link>.
+ url = this.getFeedUrlsInFolder(folder)[0];
+ if (!url) {
+ return "";
+ }
+ feedURL = url;
+ }
+
+ if (folder) {
+ let feed = new lazy.Feed(url, folder);
+ url = feed.link && feed.link.startsWith("http") ? feed.link : url;
+ }
+
+ /**
+ * Convert a Blob to data URL.
+ * @param {Blob} blob - Blob to convert.
+ * @returns {string} data URL.
+ */
+ let blobToBase64 = blob => {
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+ reader.onloadend = () => {
+ if (reader.result.endsWith("base64,")) {
+ reject(new Error(`Invalid blob encountered.`));
+ return;
+ }
+ resolve(reader.result);
+ };
+ reader.readAsDataURL(blob);
+ });
+ };
+
+ /**
+ * Try getting favicon from url.
+ * @param {string} url - The favicon url.
+ * @returns {Blob} - Existing favicon.
+ */
+ let fetchFavicon = async url => {
+ let response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`No favicon for url ${url}`);
+ }
+ if (!/^image\//i.test(response.headers.get("Content-Type"))) {
+ throw new Error(`Non-image favicon for ${url}`);
+ }
+ return response.blob();
+ };
+
+ /**
+ * Try getting favicon from the a html page.
+ * @param {string} page - The page url to check.
+ * @returns {Blob} - Found favicon.
+ */
+ let discoverFaviconURL = async page => {
+ let response = await fetch(page);
+ if (!response.ok) {
+ throw new Error(`No favicon for page ${page}`);
+ }
+ if (!/^text\/html/i.test(response.headers.get("Content-Type"))) {
+ throw new Error(`No page to get favicon from for ${page}`);
+ }
+ let doc = new DOMParser().parseFromString(
+ await response.text(),
+ "text/html"
+ );
+ let iconLink = doc.querySelector(
+ `link[href][rel~='icon']:is([sizes~='any'],[sizes~='16x16' i],[sizes~='32x32' i],:not([sizes])`
+ );
+ if (!iconLink) {
+ throw new Error(`No iconLink discovered for page=${page}`);
+ }
+ if (/^https?:/.test(iconLink.href)) {
+ return iconLink.href;
+ }
+ if (iconLink.href.at(0) != "/") {
+ iconLink.href = "/" + iconLink.href;
+ }
+ return new URL(page).origin + iconLink.href;
+ };
+
+ let uri = Services.io.newURI(url);
+ let iconURL = await fetchFavicon(uri.prePath + "/favicon.ico")
+ .then(blobToBase64)
+ .catch(e => {
+ return discoverFaviconURL(url)
+ .catch(() => discoverFaviconURL(uri.prePath))
+ .then(fetchFavicon)
+ .then(blobToBase64)
+ .catch(() => "");
+ });
+
+ if (!iconURL) {
+ return "";
+ }
+
+ // setAndFetchFaviconForPage needs the url to be in the database first.
+ await lazy.PlacesUtils.history.insert({
+ url: feedURL,
+ visits: [
+ {
+ date: new Date(),
+ },
+ ],
+ });
+ return new Promise(resolve => {
+ // All good. Now store iconURL for future usage.
+ lazy.PlacesUtils.favicons.setAndFetchFaviconForPage(
+ Services.io.newURI(feedURL),
+ Services.io.newURI(iconURL),
+ true,
+ lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ faviconURI => {
+ resolve(faviconURI.spec);
+ },
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+ },
+
+ /**
+ * Update the feeds database for rename and move/copy folder name changes.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder, new if rename or target of
+ * move/copy folder (new parent).
+ * @param {nsIMsgFolder} aOrigFolder - Original folder.
+ * @param {String} aAction - "move" or "copy" or "rename".
+ *
+ * @returns {void}
+ */
+ updateSubscriptionsDS(aFolder, aOrigFolder, aAction) {
+ this.log.debug(
+ "FeedUtils.updateSubscriptionsDS: " +
+ "\nfolder changed - " +
+ aAction +
+ "\nnew folder - " +
+ aFolder.filePath.path +
+ "\norig folder - " +
+ aOrigFolder.filePath.path
+ );
+
+ this.log.debug(
+ `updateSubscriptions(${aFolder.name}, ${aOrigFolder.name}, ${aAction})`
+ );
+
+ if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aOrigFolder)) {
+ // Target not a feed account folder; nothing to do, or move/rename in
+ // trash; no subscriptions already.
+ return;
+ }
+
+ let newFolder = aFolder;
+ let newParentURI = aFolder.URI;
+ let origParentURI = aOrigFolder.URI;
+ if (aAction == "move" || aAction == "copy") {
+ // Get the new folder. Don't process the entire parent (new dest folder)!
+ newFolder = aFolder.getChildNamed(aOrigFolder.name);
+ origParentURI = aOrigFolder.parent
+ ? aOrigFolder.parent.URI
+ : aOrigFolder.rootFolder.URI;
+ }
+
+ this.updateFolderChangeInFeedsDS(newFolder, aOrigFolder, null, null);
+
+ // There may be subfolders, but we only get a single notification; iterate
+ // over all descendent folders of the folder whose location has changed.
+ for (let newSubFolder of newFolder.descendants) {
+ FeedUtils.updateFolderChangeInFeedsDS(
+ newSubFolder,
+ aOrigFolder,
+ newParentURI,
+ origParentURI
+ );
+ }
+ },
+
+ /**
+ * Update the feeds database with the new folder's or subfolder's location
+ * for rename and move/copy name changes. The feeds subscriptions db is
+ * also synced on cross account folder copies. Note that if a copied folder's
+ * url exists in the new account, its active subscription will be switched to
+ * the folder being copied, to enforce the one unique url per account design.
+ *
+ * @param {nsIMsgFolder} aFolder - New folder.
+ * @param {nsIMsgFolder} aOrigFolder - Original folder.
+ * @param {string} aNewAncestorURI - For subfolders, ancestor new folder.
+ * @param {String} aOrigAncestorURI - For subfolders, ancestor original folder.
+ *
+ * @returns {void}
+ */
+ updateFolderChangeInFeedsDS(
+ aFolder,
+ aOrigFolder,
+ aNewAncestorURI,
+ aOrigAncestorURI
+ ) {
+ this.log.debug(
+ "updateFolderChangeInFeedsDS: " +
+ "\naFolder - " +
+ aFolder.URI +
+ "\naOrigFolder - " +
+ aOrigFolder.URI +
+ "\naOrigAncestor - " +
+ aOrigAncestorURI +
+ "\naNewAncestor - " +
+ aNewAncestorURI
+ );
+
+ // Get the original folder's URI.
+ let folderURI = aFolder.URI;
+ let origURI =
+ aNewAncestorURI && aOrigAncestorURI
+ ? folderURI.replace(aNewAncestorURI, aOrigAncestorURI)
+ : aOrigFolder.URI;
+ this.log.debug("updateFolderChangeInFeedsDS: urls origURI - " + origURI);
+
+ // Get affected feed subscriptions - all the ones in the original folder.
+ let origDS = this.getSubscriptionsDS(aOrigFolder.server);
+ let affectedSubs = origDS.data.filter(sub => sub.destFolder == origURI);
+ if (affectedSubs.length == 0) {
+ this.log.debug("updateFolderChangeInFeedsDS: no feedUrls in this folder");
+ return;
+ }
+
+ if (this.isInTrash(aFolder)) {
+ // Moving to trash. Unsubscribe.
+ affectedSubs.forEach(function (sub) {
+ let feed = new lazy.Feed(sub.url, aFolder);
+ FeedUtils.deleteFeed(feed);
+ });
+ // note: deleteFeed() calls saveSoon(), so we don't need to.
+ } else if (aFolder.server == aOrigFolder.server) {
+ // Staying in same account - just update destFolder as required
+ for (let sub of affectedSubs) {
+ sub.destFolder = folderURI;
+ }
+ origDS.saveSoon();
+ } else {
+ // Moving between accounts.
+ let destDS = this.getSubscriptionsDS(aFolder.server);
+ for (let sub of affectedSubs) {
+ // Move to the new subscription db (replacing any existing entry).
+ origDS.data = origDS.data.filter(x => x.url != sub.url);
+ destDS.data = destDS.data.filter(x => x.url != sub.url);
+ sub.destFolder = folderURI;
+ destDS.data.push(sub);
+ }
+
+ origDS.saveSoon();
+ destDS.saveSoon();
+ }
+ },
+
+ /**
+ * When subscribing to feeds by dnd on, or adding a url to, the account
+ * folder (only), or creating folder structure via opml import, a subfolder is
+ * autocreated and thus the derived/given name must be sanitized to prevent
+ * filesystem errors. Hashing invalid chars based on OS rather than filesystem
+ * is not strictly correct.
+ *
+ * @param {nsIMsgFolder} aParentFolder - Parent folder.
+ * @param {String} aProposedName - Proposed name.
+ * @param {String} aDefaultName - Default name if proposed sanitizes to
+ * blank, caller ensures sane value.
+ * @param {Boolean} aUnique - If true, return a unique indexed name.
+ *
+ * @returns {String} - Sanitized unique name.
+ */
+ getSanitizedFolderName(aParentFolder, aProposedName, aDefaultName, aUnique) {
+ // Clean up the name for the strictest fs (fat) and to ensure portability.
+ // 1) Replace line breaks and tabs '\n\r\t' with a space.
+ // 2) Remove nonprintable ascii.
+ // 3) Remove invalid win chars '* | \ / : < > ? "'.
+ // 4) Remove all '.' as starting/ending with one is trouble on osx/win.
+ // 5) No leading/trailing spaces.
+ /* eslint-disable no-control-regex */
+ let folderName = aProposedName
+ .replace(/[\n\r\t]+/g, " ")
+ .replace(/[\x00-\x1F]+/g, "")
+ .replace(/[*|\\\/:<>?"]+/g, "")
+ .replace(/[\.]+/g, "")
+ .trim();
+ /* eslint-enable no-control-regex */
+
+ // Prefix with __ if name is:
+ // 1) a reserved win filename.
+ // 2) an undeletable/unrenameable special folder name (bug 259184).
+ if (
+ folderName
+ .toUpperCase()
+ .match(/^COM\d$|^LPT\d$|^CON$|PRN$|^AUX$|^NUL$|^CLOCK\$/) ||
+ folderName
+ .toUpperCase()
+ .match(/^INBOX$|^OUTBOX$|^UNSENT MESSAGES$|^TRASH$/)
+ ) {
+ folderName = "__" + folderName;
+ }
+
+ // Use a default if no name is found.
+ if (!folderName) {
+ folderName = aDefaultName;
+ }
+
+ if (!aUnique) {
+ return folderName;
+ }
+
+ // Now ensure the folder name is not a dupe; if so append index.
+ let folderNameBase = folderName;
+ let i = 2;
+ while (aParentFolder.containsChildNamed(folderName)) {
+ folderName = folderNameBase + "-" + i++;
+ }
+
+ return folderName;
+ },
+
+ /**
+ * This object contains feed/account status info.
+ */
+ _statusDefault: {
+ // Derived from persisted value.
+ enabled: null,
+ // Last update result code, a kNewsBlog* value.
+ code: 0,
+ updateMinutes: null,
+ // JS Date; startup state is null indicating no update since startup.
+ lastUpdateTime: null,
+ },
+
+ get statusTemplate() {
+ // Copy the object.
+ return JSON.parse(JSON.stringify(this._statusDefault));
+ },
+
+ /**
+ * This object will contain all persisted feed specific properties.
+ */
+ _optionsDefault: {
+ version: 2,
+ updates: {
+ enabled: true,
+ // User set.
+ updateMinutes: 100,
+ // User set: "min"=minutes, "d"=days
+ updateUnits: "min",
+ // JS Date.
+ lastUpdateTime: null,
+ // The last time a new message was stored. JS Date.
+ lastDownloadTime: null,
+ // Publisher recommended from the feed.
+ updatePeriod: null,
+ updateFrequency: 1,
+ updateBase: null,
+ },
+ // Autotag and <category> handling options.
+ category: {
+ enabled: false,
+ prefixEnabled: false,
+ prefix: null,
+ },
+ },
+
+ get optionsTemplate() {
+ // Copy the object.
+ return JSON.parse(JSON.stringify(this._optionsDefault));
+ },
+
+ getOptionsAcct(aServer) {
+ let optionsAcct;
+ let optionsAcctPref = "mail.server." + aServer.key + ".feed_options";
+ let check_new_mail = "mail.server." + aServer.key + ".check_new_mail";
+ let check_time = "mail.server." + aServer.key + ".check_time";
+
+ // Biff enabled or not. Make sure pref exists.
+ if (!Services.prefs.prefHasUserValue(check_new_mail)) {
+ Services.prefs.setBoolPref(check_new_mail, true);
+ }
+
+ // System polling interval. Make sure pref exists.
+ if (!Services.prefs.prefHasUserValue(check_time)) {
+ Services.prefs.setIntPref(check_time, FeedUtils.kBiffPollMinutes);
+ }
+
+ try {
+ optionsAcct = JSON.parse(Services.prefs.getCharPref(optionsAcctPref));
+ // Add the server specific biff enabled state.
+ optionsAcct.doBiff = Services.prefs.getBoolPref(check_new_mail);
+ } catch (ex) {}
+
+ if (optionsAcct && optionsAcct.version == this._optionsDefault.version) {
+ return optionsAcct;
+ }
+
+ // Init account updates options if new or upgrading to version in
+ // |_optionsDefault.version|.
+ if (!optionsAcct || optionsAcct.version < this._optionsDefault.version) {
+ this.initAcct(aServer);
+ }
+
+ let newOptions = this.newOptions(optionsAcct);
+ this.setOptionsAcct(aServer, newOptions);
+ newOptions.doBiff = Services.prefs.getBoolPref(check_new_mail);
+ return newOptions;
+ },
+
+ setOptionsAcct(aServer, aOptions) {
+ let optionsAcctPref = "mail.server." + aServer.key + ".feed_options";
+ aOptions = aOptions || this.optionsTemplate;
+ Services.prefs.setCharPref(optionsAcctPref, JSON.stringify(aOptions));
+ },
+
+ initAcct(aServer) {
+ let serverPrefStr = "mail.server." + aServer.key;
+ // System polling interval. Effective after current interval expires on
+ // change; no user facing ui.
+ Services.prefs.setIntPref(
+ serverPrefStr + ".check_time",
+ FeedUtils.kBiffPollMinutes
+ );
+
+ // If this pref is false, polling on biff is disabled and account updates
+ // are paused; ui in account server settings and folderpane context menu
+ // (Pause All Updates). Checking Enable updates or unchecking Pause takes
+ // effect immediately. Here on startup, we just ensure the polling interval
+ // above is reset immediately.
+ let doBiff = Services.prefs.getBoolPref(serverPrefStr + ".check_new_mail");
+ FeedUtils.log.debug(
+ "initAcct: " + aServer.prettyName + " doBiff - " + doBiff
+ );
+ this.pauseFeedFolderUpdates(aServer.rootFolder, !doBiff, false);
+ },
+
+ newOptions(aCurrentOptions) {
+ if (!aCurrentOptions) {
+ return this.optionsTemplate;
+ }
+
+ // Options version has changed; meld current template with existing
+ // aCurrentOptions settings, removing items gone from the template while
+ // preserving user settings for retained template items.
+ let newOptions = this.optionsTemplate;
+ this.Mixins.meld(aCurrentOptions, false, true).into(newOptions);
+ newOptions.version = this.optionsTemplate.version;
+ return newOptions;
+ },
+
+ /**
+ * A generalized recursive melder of two objects. Getters/setters not included.
+ */
+ Mixins: {
+ meld(source, keep, replace) {
+ function meldin(source, target, keep, replace) {
+ for (let attribute in source) {
+ // Recurse for objects.
+ if (
+ typeof source[attribute] == "object" &&
+ typeof target[attribute] == "object"
+ ) {
+ meldin(source[attribute], target[attribute], keep, replace);
+ } else {
+ // Use attribute values from source for the target, unless
+ // replace is false.
+ if (attribute in target && !replace) {
+ continue;
+ }
+ // Don't copy attribute from source to target if it is not in the
+ // target, unless keep is true.
+ if (!(attribute in target) && !keep) {
+ continue;
+ }
+
+ target[attribute] = source[attribute];
+ }
+ }
+ }
+ return {
+ source,
+ into(target) {
+ meldin(this.source, target, keep, replace);
+ },
+ };
+ },
+ },
+
+ /**
+ * Returns a reference to the feed subscriptions store for the given server
+ * (the feeds.json data).
+ *
+ * @param {nsIMsgIncomingServer} aServer - server to fetch item data for.
+ * @returns {JSONFile} - a JSONFile holding the array of feed subscriptions
+ * in its data field.
+ */
+ getSubscriptionsDS(aServer) {
+ if (this[aServer.serverURI] && this[aServer.serverURI].FeedsDS) {
+ return this[aServer.serverURI].FeedsDS;
+ }
+
+ let rssServer = aServer.QueryInterface(Ci.nsIRssIncomingServer);
+ let feedsFile = rssServer.subscriptionsPath; // Path to feeds.json
+ let exists = feedsFile.exists();
+ let ds = new lazy.JSONFile({
+ path: feedsFile.path,
+ backupTo: feedsFile.path + ".backup",
+ });
+ ds.ensureDataReady();
+ if (!this[aServer.serverURI]) {
+ this[aServer.serverURI] = {};
+ }
+ this[aServer.serverURI].FeedsDS = ds;
+ if (!exists) {
+ // No feeds.json, so we need to initialise.
+ ds.data = [];
+ }
+ return ds;
+ },
+
+ /**
+ * Fetch an attribute for a subscribed feed.
+ *
+ * @param {string} feedURL - URL of the feed.
+ * @param {nsIMsgIncomingServer} server - Server holding the subscription.
+ * @param {String} attrName - Name of attribute to fetch.
+ * @param {undefined} defaultValue - Value to return if not found.
+ *
+ * @returns {undefined} - the fetched value (defaultValue if the
+ * subscription or attribute doesn't exist).
+ */
+ getSubscriptionAttr(feedURL, server, attrName, defaultValue) {
+ let ds = this.getSubscriptionsDS(server);
+ let sub = ds.data.find(feed => feed.url == feedURL);
+ if (sub === undefined || sub[attrName] === undefined) {
+ return defaultValue;
+ }
+ return sub[attrName];
+ },
+
+ /**
+ * Set an attribute for a feed in the subscriptions store.
+ * NOTE: If the feed is not already in the store, it will be
+ * added.
+ *
+ * @param {string} feedURL - URL of the feed.
+ * @param {nsIMsgIncomingServer} server - server holding subscription.
+ * @param {String} attrName - Name of attribute to fetch.
+ * @param {undefined} value - Value to store.
+ *
+ * @returns {void}
+ */
+ setSubscriptionAttr(feedURL, server, attrName, value) {
+ let ds = this.getSubscriptionsDS(server);
+ let sub = ds.data.find(feed => feed.url == feedURL);
+ if (sub === undefined) {
+ // Add a new entry.
+ sub = { url: feedURL };
+ ds.data.push(sub);
+ }
+ sub[attrName] = value;
+ ds.saveSoon();
+ },
+
+ /**
+ * Returns a reference to the feeditems store for the given server
+ * (the feeditems.json data).
+ *
+ * @param {nsIMsgIncomingServer} aServer - server to fetch item data for.
+ * @returns {JSONFile} - JSONFile with data field containing a collection
+ * of feeditems indexed by item url.
+ */
+ getItemsDS(aServer) {
+ if (this[aServer.serverURI] && this[aServer.serverURI].FeedItemsDS) {
+ return this[aServer.serverURI].FeedItemsDS;
+ }
+
+ let rssServer = aServer.QueryInterface(Ci.nsIRssIncomingServer);
+ let itemsFile = rssServer.feedItemsPath; // Path to feeditems.json
+ let exists = itemsFile.exists();
+ let ds = new lazy.JSONFile({
+ path: itemsFile.path,
+ backupTo: itemsFile.path + ".backup",
+ });
+ ds.ensureDataReady();
+ if (!this[aServer.serverURI]) {
+ this[aServer.serverURI] = {};
+ }
+ this[aServer.serverURI].FeedItemsDS = ds;
+ if (!exists) {
+ // No feeditems.json, need to initialise our data.
+ ds.data = {};
+ }
+ return ds;
+ },
+
+ /**
+ * Dragging something from somewhere. It may be a nice x-moz-url or from a
+ * browser or app that provides a less nice dataTransfer object in the event.
+ * Extract the url and if it passes the scheme test, try to subscribe.
+ *
+ * @param {nsISupports} aDataTransfer - The dnd event's dataTransfer.
+ *
+ * @returns {nsIURI} or null - A uri if valid, null if none.
+ */
+ getFeedUriFromDataTransfer(aDataTransfer) {
+ let dt = aDataTransfer;
+ let types = ["text/x-moz-url-data", "text/x-moz-url"];
+ let validUri = false;
+ let uri;
+
+ if (dt.getData(types[0])) {
+ // The url is the data.
+ uri = Services.io.newURI(dt.mozGetDataAt(types[0], 0));
+ validUri = this.isValidScheme(uri);
+ this.log.trace(
+ "getFeedUriFromDataTransfer: dropEffect:type:value - " +
+ dt.dropEffect +
+ " : " +
+ types[0] +
+ " : " +
+ uri.spec
+ );
+ } else if (dt.getData(types[1])) {
+ // The url is the first part of the data, the second part is random.
+ uri = Services.io.newURI(dt.mozGetDataAt(types[1], 0).split("\n")[0]);
+ validUri = this.isValidScheme(uri);
+ this.log.trace(
+ "getFeedUriFromDataTransfer: dropEffect:type:value - " +
+ dt.dropEffect +
+ " : " +
+ types[0] +
+ " : " +
+ uri.spec
+ );
+ } else {
+ // Go through the types and see if there's a url; get the first one.
+ for (let i = 0; i < dt.types.length; i++) {
+ let spec = dt.mozGetDataAt(dt.types[i], 0);
+ this.log.trace(
+ "getFeedUriFromDataTransfer: dropEffect:index:type:value - " +
+ dt.dropEffect +
+ " : " +
+ i +
+ " : " +
+ dt.types[i] +
+ " : " +
+ spec
+ );
+ try {
+ uri = Services.io.newURI(spec);
+ validUri = this.isValidScheme(uri);
+ } catch (ex) {}
+
+ if (validUri) {
+ break;
+ }
+ }
+ }
+
+ return validUri ? uri : null;
+ },
+
+ /**
+ * Returns security/certificate/network error details for an XMLHTTPRequest.
+ *
+ * @param {XMLHTTPRequest} xhr - The xhr request.
+ *
+ * @returns {Array}[{String} errType or null, {String} errName or null]
+ * - Array with 2 error codes, (nulls if not determined).
+ */
+ createTCPErrorFromFailedXHR(xhr) {
+ let status = xhr.channel.QueryInterface(Ci.nsIRequest).status;
+
+ let errType = null;
+ let errName = null;
+ if ((status & 0xff0000) === 0x5a0000) {
+ // Security module.
+ const nsINSSErrorsService = Ci.nsINSSErrorsService;
+ let nssErrorsService =
+ Cc["@mozilla.org/nss_errors_service;1"].getService(nsINSSErrorsService);
+ let errorClass;
+
+ // getErrorClass()) will throw a generic NS_ERROR_FAILURE if the error
+ // code is somehow not in the set of covered errors.
+ try {
+ errorClass = nssErrorsService.getErrorClass(status);
+ } catch (ex) {
+ // Catch security protocol exception.
+ errorClass = "SecurityProtocol";
+ }
+
+ if (errorClass == nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ errType = "SecurityCertificate";
+ } else {
+ errType = "SecurityProtocol";
+ }
+
+ // NSS_SEC errors (happen below the base value because of negative vals).
+ if (
+ (status & 0xffff) <
+ Math.abs(nsINSSErrorsService.NSS_SEC_ERROR_BASE)
+ ) {
+ // The bases are actually negative, so in our positive numeric space,
+ // we need to subtract the base off our value.
+ let nssErr =
+ Math.abs(nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff);
+
+ switch (nssErr) {
+ case 11: // SEC_ERROR_EXPIRED_CERTIFICATE, sec(11)
+ errName = "SecurityExpiredCertificateError";
+ break;
+ case 12: // SEC_ERROR_REVOKED_CERTIFICATE, sec(12)
+ errName = "SecurityRevokedCertificateError";
+ break;
+
+ // Per bsmith, we will be unable to tell these errors apart very soon,
+ // so it makes sense to just folder them all together already.
+ case 13: // SEC_ERROR_UNKNOWN_ISSUER, sec(13)
+ case 20: // SEC_ERROR_UNTRUSTED_ISSUER, sec(20)
+ case 21: // SEC_ERROR_UNTRUSTED_CERT, sec(21)
+ case 36: // SEC_ERROR_CA_CERT_INVALID, sec(36)
+ errName = "SecurityUntrustedCertificateIssuerError";
+ break;
+ case 90: // SEC_ERROR_INADEQUATE_KEY_USAGE, sec(90)
+ errName = "SecurityInadequateKeyUsageError";
+ break;
+ case 176: // SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED, sec(176)
+ errName = "SecurityCertificateSignatureAlgorithmDisabledError";
+ break;
+ default:
+ errName = "SecurityError";
+ break;
+ }
+ } else {
+ // Calculating the difference.
+ let sslErr =
+ Math.abs(nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff);
+
+ switch (sslErr) {
+ case 3: // SSL_ERROR_NO_CERTIFICATE, ssl(3)
+ errName = "SecurityNoCertificateError";
+ break;
+ case 4: // SSL_ERROR_BAD_CERTIFICATE, ssl(4)
+ errName = "SecurityBadCertificateError";
+ break;
+ case 8: // SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE, ssl(8)
+ errName = "SecurityUnsupportedCertificateTypeError";
+ break;
+ case 9: // SSL_ERROR_UNSUPPORTED_VERSION, ssl(9)
+ errName = "SecurityUnsupportedTLSVersionError";
+ break;
+ case 12: // SSL_ERROR_BAD_CERT_DOMAIN, ssl(12)
+ errName = "SecurityCertificateDomainMismatchError";
+ break;
+ default:
+ errName = "SecurityError";
+ break;
+ }
+ }
+ } else {
+ errType = "Network";
+ switch (status) {
+ // Connect to host:port failed.
+ case 0x804b000c: // NS_ERROR_CONNECTION_REFUSED, network(13)
+ errName = "ConnectionRefusedError";
+ break;
+ // network timeout error.
+ case 0x804b000e: // NS_ERROR_NET_TIMEOUT, network(14)
+ errName = "NetworkTimeoutError";
+ break;
+ // Hostname lookup failed.
+ case 0x804b001e: // NS_ERROR_UNKNOWN_HOST, network(30)
+ errName = "DomainNotFoundError";
+ break;
+ case 0x804b0047: // NS_ERROR_NET_INTERRUPT, network(71)
+ errName = "NetworkInterruptError";
+ break;
+ default:
+ errName = "NetworkError";
+ break;
+ }
+ }
+
+ return [errType, errName];
+ },
+
+ /**
+ * Returns if a uri/url is valid to subscribe.
+ *
+ * @param {nsIURI} aUri or {String} aUrl - The Uri/Url.
+ *
+ * @returns {boolean} - true if a valid scheme, false if not.
+ */
+ _validSchemes: ["http", "https", "file"],
+ isValidScheme(aUri) {
+ if (!(aUri instanceof Ci.nsIURI)) {
+ try {
+ aUri = Services.io.newURI(aUri);
+ } catch (ex) {
+ return false;
+ }
+ }
+
+ return this._validSchemes.includes(aUri.scheme);
+ },
+
+ /**
+ * Is a folder Trash or in Trash.
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ *
+ * @returns {Boolean} - true if folder is Trash else false.
+ */
+ isInTrash(aFolder) {
+ let trashFolder = aFolder.rootFolder.getFolderWithFlags(
+ Ci.nsMsgFolderFlags.Trash
+ );
+ if (
+ trashFolder &&
+ (trashFolder == aFolder || trashFolder.isAncestorOf(aFolder))
+ ) {
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Return a folder path string constructed from individual folder UTF8 names
+ * stored as properties (not possible hashes used to construct disk foldername).
+ *
+ * @param {nsIMsgFolder} aFolder - The folder.
+ *
+ * @returns {String} prettyName or null - Name or null if not a disk folder.
+ */
+ getFolderPrettyPath(aFolder) {
+ let msgFolder = lazy.MailUtils.getExistingFolder(aFolder.URI);
+ if (!msgFolder) {
+ // Not a real folder uri.
+ return null;
+ }
+
+ if (msgFolder.URI == msgFolder.server.serverURI) {
+ return msgFolder.server.prettyName;
+ }
+
+ // Server part first.
+ let pathParts = [msgFolder.server.prettyName];
+ let rawPathParts = msgFolder.URI.split(msgFolder.server.serverURI + "/");
+ let folderURI = msgFolder.server.serverURI;
+ rawPathParts = rawPathParts[1].split("/");
+ for (let i = 0; i < rawPathParts.length - 1; i++) {
+ // Two or more folders deep parts here.
+ folderURI += "/" + rawPathParts[i];
+ msgFolder = lazy.MailUtils.getExistingFolder(folderURI);
+ pathParts.push(msgFolder.name);
+ }
+
+ // Leaf folder last.
+ pathParts.push(aFolder.name);
+ return pathParts.join("/");
+ },
+
+ /**
+ * Date validator for feeds.
+ *
+ * @param {string} aDate - Date string.
+ *
+ * @returns {Boolean} - true if passes regex test, false if not.
+ */
+ isValidRFC822Date(aDate) {
+ const FZ_RFC822_RE =
+ "^(((Mon)|(Tue)|(Wed)|(Thu)|(Fri)|(Sat)|(Sun)), *)?\\d\\d?" +
+ " +((Jan)|(Feb)|(Mar)|(Apr)|(May)|(Jun)|(Jul)|(Aug)|(Sep)|(Oct)|(Nov)|(Dec))" +
+ " +\\d\\d(\\d\\d)? +\\d\\d:\\d\\d(:\\d\\d)? +(([+-]?\\d\\d\\d\\d)|(UT)|(GMT)" +
+ "|(EST)|(EDT)|(CST)|(CDT)|(MST)|(MDT)|(PST)|(PDT)|\\w)$";
+ let regex = new RegExp(FZ_RFC822_RE);
+ return regex.test(aDate);
+ },
+
+ /**
+ * Create rfc5322 date.
+ *
+ * @param {string} aDateString - Optional date string; if null or invalid
+ * date, get the current datetime.
+ *
+ * @returns {String} - An rfc5322 date string.
+ */
+ getValidRFC5322Date(aDateString) {
+ let d = new Date(aDateString || new Date().getTime());
+ d = isNaN(d.getTime()) ? new Date() : d;
+ return lazy.jsmime.headeremitter
+ .emitStructuredHeader("Date", d, {})
+ .substring(6)
+ .trim();
+ },
+
+ /**
+ * Progress glue code. Acts as a go between the RSS back end and the mail
+ * window front end determined by the aMsgWindow parameter passed into
+ * nsINewsBlogFeedDownloader.
+ */
+ progressNotifier: {
+ mSubscribeMode: false,
+ mMsgWindow: null,
+ mStatusFeedback: null,
+ mFeeds: {},
+ // Keeps track of the total number of feeds we have been asked to download.
+ // This number may not reflect the # of entries in our mFeeds array because
+ // not all feeds may have reported in for the first time.
+ mNumPendingFeedDownloads: 0,
+
+ /**
+ * @param {?nsIMsgWindow} aMsgWindow - Associated aMsgWindow if any.
+ * @param {boolean} aSubscribeMode - Whether we're in subscribe mode.
+ * @returns {void}
+ */
+ init(aMsgWindow, aSubscribeMode) {
+ if (this.mNumPendingFeedDownloads == 0) {
+ // If we aren't already in the middle of downloading feed items.
+ this.mStatusFeedback = aMsgWindow ? aMsgWindow.statusFeedback : null;
+ this.mSubscribeMode = aSubscribeMode;
+ this.mMsgWindow = aMsgWindow;
+
+ if (this.mStatusFeedback) {
+ this.mStatusFeedback.startMeteors();
+ this.mStatusFeedback.showStatusString(
+ FeedUtils.strings.GetStringFromName(
+ aSubscribeMode
+ ? "subscribe-validating-feed"
+ : "newsblog-getNewMsgsCheck"
+ )
+ );
+ }
+ }
+ },
+
+ /**
+ * Called on final success or error resolution of a feed download and
+ * parsing. If aDisable is true, the error shouldn't be retried continually
+ * and the url should be verified by the user. A bad html response code or
+ * cert error will cause the url to be disabled, while general network
+ * connectivity errors applying to all urls will not.
+ *
+ * @param {Feed} feed - The Feed object, or a synthetic object that must
+ * contain members {nsIMsgFolder folder, String url}.
+ * @param {Integer} aErrorCode - The resolution code, a kNewsBlog* value.
+ * @param {Boolean} aDisable - If true, disable/pause the feed.
+ *
+ * @returns {void}
+ */
+ downloaded(feed, aErrorCode, aDisable) {
+ let folderName = feed.folder
+ ? feed.folder.name
+ : feed.server.rootFolder.prettyName;
+ FeedUtils.log.debug(
+ "downloaded: " +
+ (this.mSubscribeMode ? "Subscribe " : "Update ") +
+ "errorCode:folderName:feedUrl - " +
+ aErrorCode +
+ " : " +
+ folderName +
+ " : " +
+ feed.url
+ );
+ if (this.mSubscribeMode) {
+ if (aErrorCode == FeedUtils.kNewsBlogSuccess) {
+ // Add the feed to the databases.
+ FeedUtils.addFeed(feed);
+
+ // Nice touch: notify so the window ca select the folder that now
+ // contains the newly subscribed feed.
+ // This is particularly nice if we just finished subscribing
+ // to a feed URL that the operating system gave us.
+ Services.obs.notifyObservers(feed.folder, "folder-subscribed");
+
+ // Check for an existing feed subscriptions window and update it.
+ let subscriptionsWindow = Services.wm.getMostRecentWindow(
+ "Mail:News-BlogSubscriptions"
+ );
+ if (subscriptionsWindow) {
+ subscriptionsWindow.FeedSubscriptions.FolderListener.folderAdded(
+ feed.folder
+ );
+ }
+ } else if (feed && feed.url && feed.server) {
+ // Non success. Remove intermediate traces from the feeds database.
+ FeedUtils.deleteFeed(feed);
+ }
+ }
+
+ if (aErrorCode != FeedUtils.kNewsBlogFeedIsBusy) {
+ if (
+ aErrorCode == FeedUtils.kNewsBlogSuccess ||
+ aErrorCode == FeedUtils.kNewsBlogNoNewItems
+ ) {
+ // Update lastUpdateTime only if successful normal processing.
+ let options = feed.options;
+ let now = Date.now();
+ options.updates.lastUpdateTime = now;
+ if (feed.itemsStored) {
+ options.updates.lastDownloadTime = now;
+ }
+
+ // If a previously disabled due to error feed is successful, set
+ // enabled state on, as that was the desired user setting.
+ if (options.updates.enabled == null) {
+ options.updates.enabled = true;
+ FeedUtils.setStatus(feed.folder, feed.url, "enabled", true);
+ }
+
+ feed.options = options;
+ FeedUtils.setStatus(feed.folder, feed.url, "lastUpdateTime", now);
+ } else if (aDisable) {
+ if (
+ Services.prefs.getBoolPref("rss.disable_feeds_on_update_failure")
+ ) {
+ // Do not keep retrying feeds with error states. Set persisted state
+ // to |null| to indicate error disable (and not user disable), but
+ // only if the feed is user enabled.
+ let options = feed.options;
+ if (options.updates.enabled) {
+ options.updates.enabled = null;
+ }
+
+ feed.options = options;
+ FeedUtils.setStatus(feed.folder, feed.url, "enabled", false);
+ FeedUtils.log.warn(
+ "downloaded: updates disabled due to error, " +
+ "check the url - " +
+ feed.url
+ );
+ } else {
+ FeedUtils.log.warn(
+ "downloaded: update failed, check the url - " + feed.url
+ );
+ }
+ }
+
+ if (!this.mSubscribeMode) {
+ FeedUtils.setStatus(feed.folder, feed.url, "code", aErrorCode);
+
+ if (
+ feed.folder &&
+ !FeedUtils.getFolderProperties(feed.folder).includes("isBusy")
+ ) {
+ // Free msgDatabase after new mail biff is set; if busy let the next
+ // result do the freeing. Otherwise new messages won't be indicated.
+ // This feed may belong to a folder with multiple other feeds, some
+ // of which may not yet be finished, so free only if the folder is
+ // no longer busy.
+ feed.folder.msgDatabase = null;
+ FeedUtils.log.debug(
+ "downloaded: msgDatabase freed - " + feed.folder.name
+ );
+ }
+ }
+ }
+
+ let message = "";
+ switch (aErrorCode) {
+ case FeedUtils.kNewsBlogSuccess:
+ case FeedUtils.kNewsBlogFeedIsBusy:
+ message = "";
+ break;
+ case FeedUtils.kNewsBlogNoNewItems:
+ message =
+ feed.url +
+ ". " +
+ FeedUtils.strings.GetStringFromName(
+ "newsblog-noNewArticlesForFeed"
+ );
+ break;
+ case FeedUtils.kNewsBlogInvalidFeed:
+ message = FeedUtils.strings.formatStringFromName(
+ "newsblog-feedNotValid",
+ [feed.url]
+ );
+ break;
+ case FeedUtils.kNewsBlogRequestFailure:
+ message = FeedUtils.strings.formatStringFromName(
+ "newsblog-networkError",
+ [feed.url]
+ );
+ break;
+ case FeedUtils.kNewsBlogFileError:
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-errorOpeningFile"
+ );
+ break;
+ case FeedUtils.kNewsBlogBadCertError:
+ let host = Services.io.newURI(feed.url).host;
+ message = FeedUtils.strings.formatStringFromName(
+ "newsblog-badCertError",
+ [host]
+ );
+ break;
+ case FeedUtils.kNewsBlogNoAuthError:
+ message = FeedUtils.strings.formatStringFromName(
+ "newsblog-noAuthError",
+ [feed.url]
+ );
+ break;
+ }
+
+ if (message) {
+ let location =
+ FeedUtils.getFolderPrettyPath(feed.folder || feed.server.rootFolder) +
+ " -> ";
+ FeedUtils.log.info(
+ "downloaded: " +
+ (this.mSubscribeMode ? "Subscribe: " : "Update: ") +
+ location +
+ message
+ );
+ }
+
+ if (this.mStatusFeedback) {
+ this.mStatusFeedback.showStatusString(message);
+ this.mStatusFeedback.stopMeteors();
+ }
+
+ this.mNumPendingFeedDownloads--;
+
+ if (this.mNumPendingFeedDownloads == 0) {
+ this.mFeeds = {};
+ this.mSubscribeMode = false;
+ FeedUtils.log.debug("downloaded: all pending downloads finished");
+
+ // Should we do this on a timer so the text sticks around for a little
+ // while? It doesn't look like we do it on a timer for newsgroups so
+ // we'll follow that model. Don't clear the status text if we just
+ // dumped an error to the status bar!
+ if (aErrorCode == FeedUtils.kNewsBlogSuccess && this.mStatusFeedback) {
+ this.mStatusFeedback.showStatusString("");
+ }
+ }
+
+ feed = null;
+ },
+
+ /**
+ * This gets called after the RSS parser finishes storing a feed item to
+ * disk. aCurrentFeedItems is an integer corresponding to how many feed
+ * items have been downloaded so far. aMaxFeedItems is an integer
+ * corresponding to the total number of feed items to download.
+ *
+ * @param {Feed} feed - The Feed object.
+ * @param {Integer} aCurrentFeedItems - Number downloaded so far.
+ * @param {Integer} aMaxFeedItems - Total number to download.
+ *
+ * @returns {void}
+ */
+ onFeedItemStored(feed, aCurrentFeedItems, aMaxFeedItems) {
+ // We currently don't do anything here. Eventually we may add status
+ // text about the number of new feed articles received.
+
+ if (this.mSubscribeMode && this.mStatusFeedback) {
+ // If we are subscribing to a feed, show feed download progress.
+ this.mStatusFeedback.showStatusString(
+ FeedUtils.strings.formatStringFromName("subscribe-gettingFeedItems", [
+ aCurrentFeedItems,
+ aMaxFeedItems,
+ ])
+ );
+ this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems);
+ }
+ },
+
+ onProgress(feed, aProgress, aProgressMax, aLengthComputable) {
+ if (feed.url in this.mFeeds) {
+ // Have we already seen this feed?
+ this.mFeeds[feed.url].currentProgress = aProgress;
+ } else {
+ this.mFeeds[feed.url] = {
+ currentProgress: aProgress,
+ maxProgress: aProgressMax,
+ };
+ }
+
+ this.updateProgressBar();
+ },
+
+ updateProgressBar() {
+ let currentProgress = 0;
+ let maxProgress = 0;
+ for (let index in this.mFeeds) {
+ currentProgress += this.mFeeds[index].currentProgress;
+ maxProgress += this.mFeeds[index].maxProgress;
+ }
+
+ // If we start seeing weird "jumping" behavior where the progress bar
+ // goes below a threshold then above it again, then we can factor a
+ // fudge factor here based on the number of feeds that have not reported
+ // yet and the avg progress we've already received for existing feeds.
+ // Fortunately the progressmeter is on a timer and only updates every so
+ // often. For the most part all of our request have initial progress
+ // before the UI actually picks up a progress value.
+ if (this.mStatusFeedback) {
+ let progress = (currentProgress * 100) / maxProgress;
+ this.mStatusFeedback.showProgress(progress);
+ }
+ },
+ },
+};
+
+XPCOMUtils.defineLazyGetter(FeedUtils, "log", function () {
+ return console.createInstance({
+ prefix: "feeds",
+ maxLogLevelPref: "feeds.loglevel",
+ });
+});
+
+XPCOMUtils.defineLazyGetter(FeedUtils, "strings", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger-newsblog/locale/newsblog.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(FeedUtils, "stringsPrefs", function () {
+ return Services.strings.createBundle(
+ "chrome://messenger/locale/prefs.properties"
+ );
+});
diff --git a/comm/mailnews/extensions/newsblog/NewsBlog.jsm b/comm/mailnews/extensions/newsblog/NewsBlog.jsm
new file mode 100644
index 0000000000..b1a1a22536
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/NewsBlog.jsm
@@ -0,0 +1,28 @@
+/* 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/. */
+
+var EXPORTED_SYMBOLS = ["FeedDownloader", "FeedAcctMgrExtension"];
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+function FeedDownloader() {}
+FeedDownloader.prototype = {
+ downloadFeed(aFolder, aUrlListener, aIsBiff, aMsgWindow) {
+ FeedUtils.downloadFeed(aFolder, aUrlListener, aIsBiff, aMsgWindow);
+ },
+ updateSubscriptionsDS(aFolder, aOrigFolder, aAction) {
+ FeedUtils.updateSubscriptionsDS(aFolder, aOrigFolder, aAction);
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsINewsBlogFeedDownloader"]),
+};
+
+function FeedAcctMgrExtension() {}
+FeedAcctMgrExtension.prototype = {
+ name: "newsblog",
+ chromePackageName: "messenger-newsblog",
+ showPanel: server => false,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgAccountManagerExtension"]),
+};
diff --git a/comm/mailnews/extensions/newsblog/am-newsblog.js b/comm/mailnews/extensions/newsblog/am-newsblog.js
new file mode 100644
index 0000000000..eacf2a3ae5
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/am-newsblog.js
@@ -0,0 +1,128 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/* import-globals-from ../../base/prefs/content/am-prefs.js */
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+var gAccount,
+ gUpdateEnabled,
+ gUpdateValue,
+ gBiffUnits,
+ gAutotagEnable,
+ gAutotagUsePrefix,
+ gAutotagPrefix;
+
+/**
+ * Initialize am-newsblog account settings page when it gets shown.
+ * Update an account's main settings title etc.
+ *
+ * @returns {void}
+ */
+function onInit() {
+ setAccountTitle();
+
+ let optionsAcct = FeedUtils.getOptionsAcct(gAccount.incomingServer);
+ document.getElementById("doBiff").checked = optionsAcct.doBiff;
+
+ gUpdateEnabled = document.getElementById("updateEnabled");
+ gUpdateValue = document.getElementById("updateValue");
+ gBiffUnits = document.getElementById("biffUnits");
+ gAutotagEnable = document.getElementById("autotagEnable");
+ gAutotagUsePrefix = document.getElementById("autotagUsePrefix");
+ gAutotagPrefix = document.getElementById("autotagPrefix");
+
+ gUpdateEnabled.checked = optionsAcct.updates.enabled;
+ gBiffUnits.value = optionsAcct.updates.updateUnits;
+ let minutes =
+ optionsAcct.updates.updateUnits == FeedUtils.kBiffUnitsMinutes
+ ? optionsAcct.updates.updateMinutes
+ : optionsAcct.updates.updateMinutes / (24 * 60);
+ gUpdateValue.value = Number(minutes);
+ onCheckItem("updateValue", ["updateEnabled"]);
+ onCheckItem("biffMinutes", ["updateEnabled"]);
+ onCheckItem("biffDays", ["updateEnabled"]);
+
+ gAutotagEnable.checked = optionsAcct.category.enabled;
+ gAutotagUsePrefix.disabled = !gAutotagEnable.checked;
+ gAutotagUsePrefix.checked = optionsAcct.category.prefixEnabled;
+ gAutotagPrefix.disabled =
+ gAutotagUsePrefix.disabled || !gAutotagUsePrefix.checked;
+ gAutotagPrefix.value = optionsAcct.category.prefix;
+}
+
+function onPreInit(account, accountValues) {
+ gAccount = account;
+}
+
+/**
+ * Handle the blur event of the #server.prettyName pref input.
+ * Update account name in account manager tree and account settings' main title.
+ *
+ * @param {Event} event - Blur event from the pretty name input.
+ * @returns {void}
+ */
+function serverPrettyNameOnBlur(event) {
+ parent.setAccountLabel(gAccount.key, event.target.value);
+ setAccountTitle();
+}
+
+/**
+ * Update an account's main settings title with the account name if applicable.
+ *
+ * @returns {void}
+ */
+function setAccountTitle() {
+ let accountName = document.getElementById("server.prettyName");
+ let title = document.querySelector("#am-newsblog-title .dialogheader-title");
+ let titleValue = title.getAttribute("defaultTitle");
+ if (accountName.value) {
+ titleValue += " - " + accountName.value;
+ }
+
+ title.setAttribute("value", titleValue);
+ document.title = titleValue;
+}
+
+function setPrefs(aNode) {
+ let optionsAcct = FeedUtils.getOptionsAcct(gAccount.incomingServer);
+ switch (aNode.id) {
+ case "doBiff":
+ FeedUtils.pauseFeedFolderUpdates(
+ gAccount.incomingServer.rootFolder,
+ !aNode.checked,
+ true
+ );
+ break;
+ case "updateEnabled":
+ case "updateValue":
+ case "biffUnits":
+ optionsAcct.updates.enabled = gUpdateEnabled.checked;
+ onCheckItem("updateValue", ["updateEnabled"]);
+ onCheckItem("biffMinutes", ["updateEnabled"]);
+ onCheckItem("biffDays", ["updateEnabled"]);
+ let minutes =
+ gBiffUnits.value == FeedUtils.kBiffUnitsMinutes
+ ? gUpdateValue.value
+ : gUpdateValue.value * 24 * 60;
+ optionsAcct.updates.updateMinutes = Number(minutes);
+ optionsAcct.updates.updateUnits = gBiffUnits.value;
+ break;
+ case "autotagEnable":
+ optionsAcct.category.enabled = aNode.checked;
+ gAutotagUsePrefix.disabled = !aNode.checked;
+ gAutotagPrefix.disabled = !aNode.checked || !gAutotagUsePrefix.checked;
+ break;
+ case "autotagUsePrefix":
+ optionsAcct.category.prefixEnabled = aNode.checked;
+ gAutotagPrefix.disabled = aNode.disabled || !aNode.checked;
+ break;
+ case "autotagPrefix":
+ optionsAcct.category.prefix = aNode.value;
+ break;
+ }
+
+ FeedUtils.setOptionsAcct(gAccount.incomingServer, optionsAcct);
+}
diff --git a/comm/mailnews/extensions/newsblog/am-newsblog.xhtml b/comm/mailnews/extensions/newsblog/am-newsblog.xhtml
new file mode 100644
index 0000000000..e6ba0f6a5d
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/am-newsblog.xhtml
@@ -0,0 +1,233 @@
+<?xml version="1.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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/accountManage.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger-newsblog/skin/feed-subscriptions.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % newsblogDTD SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd">
+%newsblogDTD;
+<!ENTITY % feedDTD SYSTEM "chrome://messenger-newsblog/locale/feed-subscriptions.dtd" >
+%feedDTD;
+<!ENTITY % accountNoIdentDTD SYSTEM "chrome://messenger/locale/am-serverwithnoidentities.dtd" >
+%accountNoIdentDTD;
+<!ENTITY % accountServerTopDTD SYSTEM "chrome://messenger/locale/am-server-top.dtd">
+%accountServerTopDTD; ]>
+
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <head>
+ <title>&accountTitle.label;</title>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/AccountManager.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger-newsblog/content/am-newsblog.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger-newsblog/content/newsblogOverlay.js"
+ ></script>
+ <script defer="defer" src="chrome://messenger/content/amUtils.js"></script>
+ <script defer="defer" src="chrome://messenger/content/am-prefs.js"></script>
+ <script>
+ // FIXME: move to script file.
+ window.addEventListener("load", event => {
+ parent.onPanelLoaded("am-newsblog.xhtml");
+ });
+ </script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <vbox id="containerBox" flex="1">
+ <hbox id="am-newsblog-title" class="dialogheader">
+ <label class="dialogheader-title" defaultTitle="&accountTitle.label;" />
+ </hbox>
+
+ <description class="secDesc">&accountSettingsDesc.label;</description>
+
+ <hbox class="input-container">
+ <label
+ id="server.prettyNameLabel"
+ value="&accountName.label;"
+ accesskey="&accountName.accesskey;"
+ control="server.prettyName"
+ />
+ <html:input
+ id="server.prettyName"
+ type="text"
+ wsm_persist="true"
+ class="input-inline"
+ aria-labelledby="server.prettyNameLabel"
+ onblur="serverPrettyNameOnBlur(event);"
+ prefstring="mail.server.%serverkey%.name"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend>&serverSettings.label;</html:legend>
+ <checkbox
+ id="doBiff"
+ label="&biffAll.label;"
+ accesskey="&biffAll.accesskey;"
+ oncommand="setPrefs(this)"
+ />
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend>&newFeedSettings.label;</html:legend>
+
+ <hbox align="center">
+ <checkbox
+ id="updateEnabled"
+ label="&biffStart.label;"
+ accesskey="&biffStart.accesskey;"
+ oncommand="setPrefs(this)"
+ />
+ <html:input
+ id="updateValue"
+ type="number"
+ class="size3"
+ min="1"
+ aria-labelledby="updateEnabled updateValue biffMinutes biffDays"
+ onchange="setPrefs(this)"
+ />
+ <radiogroup
+ id="biffUnits"
+ orient="horizontal"
+ oncommand="setPrefs(this)"
+ >
+ <radio
+ id="biffMinutes"
+ value="min"
+ label="&biffMinutes.label;"
+ accesskey="&biffMinutes.accesskey;"
+ />
+ <radio
+ id="biffDays"
+ value="d"
+ label="&biffDays.label;"
+ accesskey="&biffDays.accesskey;"
+ />
+ </radiogroup>
+ </hbox>
+
+ <checkbox
+ id="server.quickMode"
+ wsm_persist="true"
+ genericattr="true"
+ label="&quickMode.label;"
+ accesskey="&quickMode.accesskey;"
+ preftype="bool"
+ prefattribute="value"
+ prefstring="mail.server.%serverkey%.quickMode"
+ />
+
+ <checkbox
+ id="autotagEnable"
+ accesskey="&autotagEnable.accesskey;"
+ label="&autotagEnable.label;"
+ oncommand="setPrefs(this)"
+ />
+ <hbox class="input-container">
+ <checkbox
+ id="autotagUsePrefix"
+ class="indent"
+ accesskey="&autotagUsePrefix.accesskey;"
+ label="&autotagUsePrefix.label;"
+ oncommand="setPrefs(this)"
+ />
+ <html:input
+ id="autotagPrefix"
+ type="text"
+ class="input-inline"
+ aria-labelledby="autotagUsePrefix"
+ placeholder="&autoTagPrefix.placeholder;"
+ onchange="setPrefs(this)"
+ />
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <separator class="thin" />
+
+ <hbox pack="end">
+ <button
+ label="&manageSubscriptions.label;"
+ accesskey="&manageSubscriptions.accesskey;"
+ oncommand="openSubscriptionsDialog(gAccount.incomingServer.rootFolder);"
+ />
+ </hbox>
+
+ <separator class="thin" />
+
+ <html:div>
+ <html:fieldset>
+ <html:legend>&messageStorage.label;</html:legend>
+
+ <checkbox
+ id="server.emptyTrashOnExit"
+ wsm_persist="true"
+ label="&emptyTrashOnExit.label;"
+ accesskey="&emptyTrashOnExit.accesskey;"
+ prefattribute="value"
+ prefstring="mail.server.%serverkey%.empty_trash_on_exit"
+ />
+
+ <separator class="thin" />
+
+ <vbox>
+ <hbox align="center">
+ <label
+ id="server.localPathLabel"
+ value="&localPath1.label;"
+ control="server.localPath"
+ />
+ <hbox class="input-container" flex="1">
+ <html:input
+ id="server.localPath"
+ type="text"
+ readonly="readonly"
+ class="uri-element input-inline"
+ aria-labelledby="server.localPathLabel"
+ wsm_persist="true"
+ datatype="nsIFile"
+ prefstring="mail.server.%serverkey%.directory"
+ />
+ </hbox>
+ <button
+ id="browseForLocalFolder"
+ label="&browseFolder.label;"
+ filepickertitle="&localFolderPicker.label;"
+ accesskey="&browseFolder.accesskey;"
+ oncommand="BrowseForLocalFolders();"
+ />
+ </hbox>
+ </vbox>
+ </html:fieldset>
+ </html:div>
+ </vbox>
+ </html:body>
+</html>
diff --git a/comm/mailnews/extensions/newsblog/components.conf b/comm/mailnews/extensions/newsblog/components.conf
new file mode 100644
index 0000000000..0e2c4ce03a
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/components.conf
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ "cid": "{5c124537-adca-4456-b2b5-641ab687d1f6}",
+ "contract_ids": ["@mozilla.org/newsblog-feed-downloader;1"],
+ "jsm": "resource:///modules/NewsBlog.jsm",
+ "constructor": "FeedDownloader",
+ },
+ {
+ "cid": "{e109c05f-d304-4ca5-8c44-6de1bfaf1f74}",
+ "contract_ids": ["@mozilla.org/accountmanager/extension;1?name=newsblog"],
+ "jsm": "resource:///modules/NewsBlog.jsm",
+ "constructor": "FeedAcctMgrExtension",
+ "categories": {"mailnews-accountmanager-extensions": "newsblog"},
+ },
+]
diff --git a/comm/mailnews/extensions/newsblog/feed-subscriptions.js b/comm/mailnews/extensions/newsblog/feed-subscriptions.js
new file mode 100644
index 0000000000..43ce61b11b
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/feed-subscriptions.js
@@ -0,0 +1,3120 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/**
+ * @file
+ * GUI-side code for managing folder subscriptions.
+ */
+
+var { Feed } = ChromeUtils.import("resource:///modules/Feed.jsm");
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+
+var FeedSubscriptions = {
+ get mMainWin() {
+ return Services.wm.getMostRecentWindow("mail:3pane");
+ },
+
+ get mTree() {
+ return document.getElementById("rssSubscriptionsList");
+ },
+
+ mFeedContainers: [],
+ mRSSServer: null,
+ mActionMode: null,
+ kSubscribeMode: 1,
+ kUpdateMode: 2,
+ kMoveMode: 3,
+ kCopyMode: 4,
+ kImportingOPML: 5,
+ kVerifyUrlMode: 6,
+
+ get FOLDER_ACTIONS() {
+ return (
+ Ci.nsIMsgFolderNotificationService.folderAdded |
+ Ci.nsIMsgFolderNotificationService.folderDeleted |
+ Ci.nsIMsgFolderNotificationService.folderRenamed |
+ Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted
+ );
+ },
+
+ onLoad() {
+ // Extract the folder argument.
+ let folder;
+ if (window.arguments && window.arguments[0].folder) {
+ folder = window.arguments[0].folder;
+ }
+
+ // Ensure dialog is fully loaded before selecting, to get visible row.
+ setTimeout(() => {
+ FeedSubscriptions.refreshSubscriptionView(folder);
+ }, 100);
+ let message = FeedUtils.strings.GetStringFromName("subscribe-loading");
+ this.updateStatusItem("statusText", message);
+
+ FeedUtils.CANCEL_REQUESTED = false;
+
+ if (this.mMainWin) {
+ MailServices.mfn.addListener(this.FolderListener, this.FOLDER_ACTIONS);
+ }
+ },
+
+ onDialogAccept() {
+ let dismissDialog = true;
+
+ // If we are in the middle of subscribing to a feed, inform the user that
+ // dismissing the dialog right now will abort the feed subscription.
+ if (this.mActionMode == this.kSubscribeMode) {
+ let pTitle = FeedUtils.strings.GetStringFromName(
+ "subscribe-cancelSubscriptionTitle"
+ );
+ let pMessage = FeedUtils.strings.GetStringFromName(
+ "subscribe-cancelSubscription"
+ );
+ dismissDialog = !Services.prompt.confirmEx(
+ window,
+ pTitle,
+ pMessage,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ }
+
+ if (dismissDialog) {
+ FeedUtils.CANCEL_REQUESTED = this.mActionMode == this.kSubscribeMode;
+ if (this.mMainWin) {
+ MailServices.mfn.removeListener(
+ this.FolderListener,
+ this.FOLDER_ACTIONS
+ );
+ }
+ }
+
+ return dismissDialog;
+ },
+
+ refreshSubscriptionView(aSelectFolder, aSelectFeedUrl) {
+ let item = this.mView.currentItem;
+ this.loadSubscriptions();
+ this.mTree.view = this.mView;
+
+ if (aSelectFolder && !aSelectFeedUrl) {
+ this.selectFolder(aSelectFolder);
+ } else if (item) {
+ // If no folder to select, try to select the pre rebuild selection, in
+ // an existing window. For folderpane changes in a feed account.
+ let rootFolder = item.container
+ ? item.folder.rootFolder
+ : item.parentFolder.rootFolder;
+ if (item.container) {
+ if (!this.selectFolder(item.folder, { open: item.open })) {
+ // The item no longer exists, an ancestor folder was deleted or
+ // renamed/moved.
+ this.selectFolder(rootFolder);
+ }
+ } else {
+ let url =
+ item.parentFolder == aSelectFolder ? aSelectFeedUrl : item.url;
+ this.selectFeed({ folder: rootFolder, url }, null);
+ }
+ }
+
+ this.mView.tree.ensureRowIsVisible(this.mView.selection.currentIndex);
+ this.clearStatusInfo();
+ },
+
+ mView: {
+ kRowIndexUndefined: -1,
+
+ get currentItem() {
+ // Get the current selection, if any.
+ let seln = this.selection;
+ let currentSelectionIndex = seln ? seln.currentIndex : null;
+ let item;
+ if (currentSelectionIndex != null) {
+ item = this.getItemAtIndex(currentSelectionIndex);
+ }
+
+ return item;
+ },
+
+ /* nsITreeView */
+ /* eslint-disable no-multi-spaces */
+ tree: null,
+
+ mRowCount: 0,
+ get rowCount() {
+ return this.mRowCount;
+ },
+
+ _selection: null,
+ get selection() {
+ return this._selection;
+ },
+ set selection(val) {
+ this._selection = val;
+ },
+
+ setTree(aTree) {
+ this.tree = aTree;
+ },
+ isSeparator(aRow) {
+ return false;
+ },
+ isSorted() {
+ return false;
+ },
+ isEditable(aRow, aColumn) {
+ return false;
+ },
+
+ getProgressMode(aRow, aCol) {},
+ cycleHeader(aCol) {},
+ cycleCell(aRow, aCol) {},
+ selectionChanged() {},
+ getRowProperties(aRow) {
+ return "";
+ },
+ getColumnProperties(aCol) {
+ return "";
+ },
+ getCellValue(aRow, aColumn) {},
+ setCellValue(aRow, aColumn, aValue) {},
+ setCellText(aRow, aColumn, aValue) {},
+ /* eslint-enable no-multi-spaces */
+
+ getCellProperties(aRow, aColumn) {
+ let item = this.getItemAtIndex(aRow);
+ if (!item) {
+ return "";
+ }
+
+ if (AppConstants.MOZ_APP_NAME != "thunderbird") {
+ if (!item.folder) {
+ return "serverType-rss";
+ } else if (item.folder.isServer) {
+ return "serverType-rss isServer-true";
+ }
+
+ return "livemark";
+ }
+
+ let folder = item.folder;
+ let properties = "folderNameCol";
+ let mainWin = FeedSubscriptions.mMainWin;
+ if (!mainWin) {
+ let hasFeeds = FeedUtils.getFeedUrlsInFolder(folder);
+ if (!folder) {
+ properties += " isFeed-true";
+ } else if (hasFeeds) {
+ properties += " isFeedFolder-true";
+ } else if (folder.isServer) {
+ properties += " serverType-rss isServer-true";
+ }
+ } else {
+ let url = folder ? null : item.url;
+ folder = folder || item.parentFolder;
+ properties = mainWin.FolderUtils.getFolderProperties(folder, item.open);
+ properties += mainWin.FeedUtils.getFolderProperties(folder, url);
+ if (
+ this.selection.currentIndex == aRow &&
+ url &&
+ item.options.updates.enabled &&
+ properties.includes("isPaused")
+ ) {
+ item.options.updates.enabled = false;
+ FeedSubscriptions.updateFeedData(item);
+ }
+ }
+
+ item.properties = properties;
+ return properties;
+ },
+
+ isContainer(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ return item ? item.container : false;
+ },
+
+ isContainerOpen(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ return item ? item.open : false;
+ },
+
+ isContainerEmpty(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ if (!item) {
+ return false;
+ }
+
+ return item.children.length == 0;
+ },
+
+ getItemAtIndex(aRow) {
+ if (aRow < 0 || aRow >= FeedSubscriptions.mFeedContainers.length) {
+ return null;
+ }
+
+ return FeedSubscriptions.mFeedContainers[aRow];
+ },
+
+ getItemInViewIndex(aFolder) {
+ if (!aFolder || !(aFolder instanceof Ci.nsIMsgFolder)) {
+ return null;
+ }
+
+ for (let index = 0; index < this.rowCount; index++) {
+ // Find the visible folder in the view.
+ let item = this.getItemAtIndex(index);
+ if (item && item.container && item.url == aFolder.URI) {
+ return index;
+ }
+ }
+
+ return null;
+ },
+
+ removeItemAtIndex(aRow, aNoSelect) {
+ let itemToRemove = this.getItemAtIndex(aRow);
+ if (!itemToRemove) {
+ return;
+ }
+
+ if (itemToRemove.container && itemToRemove.open) {
+ // Close it, if open container.
+ this.toggleOpenState(aRow);
+ }
+
+ let parentIndex = this.getParentIndex(aRow);
+ let hasNextSibling = this.hasNextSibling(aRow, aRow);
+ if (parentIndex != this.kRowIndexUndefined) {
+ let parent = this.getItemAtIndex(parentIndex);
+ if (parent) {
+ for (let index = 0; index < parent.children.length; index++) {
+ if (parent.children[index] == itemToRemove) {
+ parent.children.splice(index, 1);
+ break;
+ }
+ }
+ }
+ }
+
+ // Now remove it from our view.
+ FeedSubscriptions.mFeedContainers.splice(aRow, 1);
+
+ // Now invalidate the correct tree rows.
+ this.mRowCount--;
+ this.tree.rowCountChanged(aRow, -1);
+
+ // Now update the selection position, unless noSelect (selection is
+ // done later or not at all). If the item is the last child, select the
+ // parent. Otherwise select the next sibling.
+ if (!aNoSelect) {
+ if (aRow <= FeedSubscriptions.mFeedContainers.length) {
+ this.selection.select(hasNextSibling ? aRow : aRow - 1);
+ } else {
+ this.selection.clearSelection();
+ }
+ }
+
+ // Now refocus the tree.
+ FeedSubscriptions.mTree.focus();
+ },
+
+ getCellText(aRow, aColumn) {
+ let item = this.getItemAtIndex(aRow);
+ return item && aColumn.id == "folderNameCol" ? item.name : "";
+ },
+
+ getImageSrc(aRow, aCol) {
+ let item = this.getItemAtIndex(aRow);
+ if ((item.folder && item.folder.isServer) || item.open) {
+ return "";
+ }
+
+ if (
+ !item.open &&
+ (item.properties.includes("hasError") ||
+ item.properties.includes("isBusy"))
+ ) {
+ return "";
+ }
+
+ if (item.favicon != null) {
+ return item.favicon;
+ }
+
+ let callback = iconUrl => {
+ item.favicon = iconUrl;
+ if (item.folder) {
+ for (let child of item.children) {
+ if (!child.container) {
+ child.favicon = iconUrl;
+ break;
+ }
+ }
+ }
+
+ this.selection.tree.invalidateRow(aRow);
+ };
+
+ // A closed non server folder.
+ if (item.folder) {
+ for (let child of item.children) {
+ if (!child.container) {
+ if (child.favicon != null) {
+ return child.favicon;
+ }
+
+ setTimeout(async () => {
+ let iconUrl = await FeedUtils.getFavicon(
+ child.parentFolder,
+ child.url
+ );
+ if (iconUrl) {
+ callback(iconUrl);
+ }
+ }, 0);
+ break;
+ }
+ }
+ } else {
+ // A feed.
+ setTimeout(async () => {
+ let iconUrl = await FeedUtils.getFavicon(item.parentFolder, item.url);
+ if (iconUrl) {
+ callback(iconUrl);
+ }
+ }, 0);
+ }
+
+ // Store empty string to return default while favicons are retrieved.
+ return (item.favicon = "");
+ },
+
+ canDrop(aRow, aOrientation) {
+ let dropResult = this.extractDragData(aRow);
+ return (
+ aOrientation == Ci.nsITreeView.DROP_ON &&
+ dropResult.canDrop &&
+ (dropResult.dropUrl ||
+ dropResult.dropOnIndex != this.kRowIndexUndefined)
+ );
+ },
+
+ drop(aRow, aOrientation) {
+ let win = FeedSubscriptions;
+ let results = this.extractDragData(aRow);
+ if (!results.canDrop) {
+ return;
+ }
+
+ // Preselect the drop folder.
+ this.selection.select(aRow);
+
+ if (results.dropUrl) {
+ // Don't freeze the app that initiated the drop just because we are
+ // in a loop waiting for the user to dimisss the add feed dialog.
+ setTimeout(() => {
+ win.addFeed(results.dropUrl, null, true, null, win.kSubscribeMode);
+ }, 0);
+
+ let folderItem = this.getItemAtIndex(aRow);
+ FeedUtils.log.debug(
+ "drop: folder, url - " +
+ folderItem.folder.name +
+ ", " +
+ results.dropUrl
+ );
+ } else if (results.dropOnIndex != this.kRowIndexUndefined) {
+ win.moveCopyFeed(results.dropOnIndex, aRow, results.dropEffect);
+ }
+ },
+
+ // Helper function for drag and drop.
+ extractDragData(aRow) {
+ let dt = this._currentDataTransfer;
+ let dragDataResults = {
+ canDrop: false,
+ dropUrl: null,
+ dropOnIndex: this.kRowIndexUndefined,
+ dropEffect: dt.dropEffect,
+ };
+
+ if (dt.getData("text/x-moz-feed-index")) {
+ // Dragging a feed in the tree.
+ if (this.selection) {
+ dragDataResults.dropOnIndex = this.selection.currentIndex;
+
+ let curItem = this.getItemAtIndex(this.selection.currentIndex);
+ let newItem = this.getItemAtIndex(aRow);
+ let curServer =
+ curItem && curItem.parentFolder
+ ? curItem.parentFolder.server
+ : null;
+ let newServer =
+ newItem && newItem.folder ? newItem.folder.server : null;
+
+ // No copying within the same account and no moving to the account
+ // folder in the same account.
+ if (
+ !(
+ curServer == newServer &&
+ (dragDataResults.dropEffect == "copy" ||
+ newItem.folder == curItem.parentFolder ||
+ newItem.folder.isServer)
+ )
+ ) {
+ dragDataResults.canDrop = true;
+ }
+ }
+ } else {
+ // Try to get a feed url.
+ let validUri = FeedUtils.getFeedUriFromDataTransfer(dt);
+
+ if (validUri) {
+ dragDataResults.canDrop = true;
+ dragDataResults.dropUrl = validUri.spec;
+ }
+ }
+
+ return dragDataResults;
+ },
+
+ getParentIndex(aRow) {
+ let item = this.getItemAtIndex(aRow);
+
+ if (item) {
+ for (let index = aRow; index >= 0; index--) {
+ if (FeedSubscriptions.mFeedContainers[index].level < item.level) {
+ return index;
+ }
+ }
+ }
+
+ return this.kRowIndexUndefined;
+ },
+
+ isIndexChildOfParentIndex(aRow, aChildRow) {
+ // For visible tree rows, not if items are children of closed folders.
+ let item = this.getItemAtIndex(aRow);
+ if (!item || aChildRow <= aRow) {
+ return false;
+ }
+
+ let targetLevel = this.getItemAtIndex(aRow).level;
+ let rows = FeedSubscriptions.mFeedContainers;
+
+ for (let i = aRow + 1; i < rows.length; i++) {
+ if (this.getItemAtIndex(i).level <= targetLevel) {
+ break;
+ }
+ if (aChildRow == i) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ hasNextSibling(aRow, aAfterIndex) {
+ let targetLevel = this.getItemAtIndex(aRow).level;
+ let rows = FeedSubscriptions.mFeedContainers;
+ for (let i = aAfterIndex + 1; i < rows.length; i++) {
+ if (this.getItemAtIndex(i).level == targetLevel) {
+ return true;
+ }
+ if (this.getItemAtIndex(i).level < targetLevel) {
+ return false;
+ }
+ }
+
+ return false;
+ },
+
+ hasPreviousSibling(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ if (item && aRow) {
+ return this.getItemAtIndex(aRow - 1).level == item.level;
+ }
+
+ return false;
+ },
+
+ getLevel(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ if (!item) {
+ return 0;
+ }
+
+ return item.level;
+ },
+
+ toggleOpenState(aRow) {
+ let item = this.getItemAtIndex(aRow);
+ if (!item) {
+ return;
+ }
+
+ // Save off the current selection item.
+ let seln = this.selection;
+ let currentSelectionIndex = seln.currentIndex;
+
+ let rowsChanged = this.toggle(aRow);
+
+ // Now restore selection, ensuring selection is maintained on toggles.
+ if (currentSelectionIndex > aRow) {
+ seln.currentIndex = currentSelectionIndex + rowsChanged;
+ } else {
+ seln.select(currentSelectionIndex);
+ }
+
+ seln.selectEventsSuppressed = false;
+ },
+
+ toggle(aRow) {
+ // Collapse the row, or build sub rows based on open states in the map.
+ let item = this.getItemAtIndex(aRow);
+ if (!item) {
+ return null;
+ }
+
+ let rows = FeedSubscriptions.mFeedContainers;
+ let rowCount = 0;
+ let multiplier;
+
+ function addDescendants(aItem) {
+ for (let i = 0; i < aItem.children.length; i++) {
+ rowCount++;
+ let child = aItem.children[i];
+ rows.splice(aRow + rowCount, 0, child);
+ if (child.open) {
+ addDescendants(child);
+ }
+ }
+ }
+
+ if (item.open) {
+ // Close the container. Add up all subfolders and their descendants
+ // who may be open.
+ multiplier = -1;
+ let nextRow = aRow + 1;
+ let nextItem = rows[nextRow];
+ while (nextItem && nextItem.level > item.level) {
+ rowCount++;
+ nextItem = rows[++nextRow];
+ }
+
+ rows.splice(aRow + 1, rowCount);
+ } else {
+ // Open the container. Restore the open state of all subfolder and
+ // their descendants.
+ multiplier = 1;
+ addDescendants(item);
+ }
+
+ let delta = multiplier * rowCount;
+ this.mRowCount += delta;
+
+ item.open = !item.open;
+ // Suppress the select event caused by rowCountChanged.
+ this.selection.selectEventsSuppressed = true;
+ // Add or remove the children from our view.
+ this.tree.rowCountChanged(aRow, delta);
+ return delta;
+ },
+ },
+
+ makeFolderObject(aFolder, aCurrentLevel) {
+ let defaultQuickMode = aFolder.server.getBoolValue("quickMode");
+ let optionsAcct = aFolder.isServer
+ ? FeedUtils.getOptionsAcct(aFolder.server)
+ : null;
+ let open =
+ !aFolder.isServer &&
+ aFolder.server == this.mRSSServer &&
+ this.mActionMode == this.kImportingOPML;
+ let folderObject = {
+ children: [],
+ folder: aFolder,
+ name: aFolder.prettyName,
+ level: aCurrentLevel,
+ url: aFolder.URI,
+ quickMode: defaultQuickMode,
+ options: optionsAcct,
+ open,
+ container: true,
+ favicon: null,
+ };
+
+ // If a feed has any sub folders, add them to the list of children.
+ for (let folder of aFolder.subFolders) {
+ if (
+ folder instanceof Ci.nsIMsgFolder &&
+ !folder.getFlag(Ci.nsMsgFolderFlags.Trash) &&
+ !folder.getFlag(Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ folderObject.children.push(
+ this.makeFolderObject(folder, aCurrentLevel + 1)
+ );
+ }
+ }
+
+ let feeds = this.getFeedsInFolder(aFolder);
+ for (let feed of feeds) {
+ // Now add any feed urls for the folder.
+ folderObject.children.push(
+ this.makeFeedObject(feed, aFolder, aCurrentLevel + 1)
+ );
+ }
+
+ // Finally, set the folder's quickMode based on the its first feed's
+ // quickMode, since that is how the view determines summary mode, and now
+ // quickMode is updated to be the same for all feeds in a folder.
+ if (feeds && feeds[0]) {
+ folderObject.quickMode = feeds[0].quickMode;
+ }
+
+ folderObject.children = this.folderItemSorter(folderObject.children);
+
+ return folderObject;
+ },
+
+ folderItemSorter(aArray) {
+ return aArray
+ .sort(function (a, b) {
+ return a.name.toLowerCase() > b.name.toLowerCase();
+ })
+ .sort(function (a, b) {
+ return a.container < b.container;
+ });
+ },
+
+ getFeedsInFolder(aFolder) {
+ let feeds = [];
+ let feedUrlArray = FeedUtils.getFeedUrlsInFolder(aFolder);
+ if (!feedUrlArray) {
+ // No feedUrls in this folder.
+ return feeds;
+ }
+
+ for (let url of feedUrlArray) {
+ let feed = new Feed(url, aFolder);
+ feeds.push(feed);
+ }
+
+ return feeds;
+ },
+
+ makeFeedObject(aFeed, aFolder, aLevel) {
+ // Look inside the data source for the feed properties.
+ let feed = {
+ children: [],
+ parentFolder: aFolder,
+ name: aFeed.title || aFeed.description || aFeed.url,
+ url: aFeed.url,
+ quickMode: aFeed.quickMode,
+ options: aFeed.options || FeedUtils.optionsTemplate,
+ level: aLevel,
+ open: false,
+ container: false,
+ favicon: null,
+ };
+ return feed;
+ },
+
+ loadSubscriptions() {
+ // Put together an array of folders. Each feed account level folder is
+ // included as the root.
+ let numFolders = 0;
+ let feedContainers = [];
+ // Get all the feed account folders.
+ let feedRootFolders = FeedUtils.getAllRssServerRootFolders();
+
+ feedRootFolders.forEach(function (rootFolder) {
+ feedContainers.push(this.makeFolderObject(rootFolder, 0));
+ numFolders++;
+ }, this);
+
+ this.mFeedContainers = feedContainers;
+ this.mView.mRowCount = numFolders;
+
+ FeedSubscriptions.mTree.focus();
+ },
+
+ /**
+ * Find the folder in the tree. The search may be limited to subfolders of
+ * a known folder, or expanded to include the entire tree. This function is
+ * also used to insert/remove folders without rebuilding the tree view cache
+ * (to avoid position/toggle state loss).
+ *
+ * @param {nsIMsgFolder} aFolder - The folder to find.
+ * @param {object} aParms - The params object, containing:
+ *
+ * {Integer} parentIndex - index of folder to start the search; if
+ * null (default), the index of the folder's
+ * rootFolder will be used.
+ * {boolean} select - if true (default) the folder's ancestors
+ * will be opened and the folder selected.
+ * {boolean} open - if true (default) the folder is opened.
+ * {boolean} remove - delete the item from tree row cache if true,
+ * false (default) otherwise.
+ * {nsIMsgFolder} newFolder - if not null (default) the new folder,
+ * for add or rename.
+ *
+ * @returns {Boolean} found - true if found, false if not.
+ */
+ selectFolder(aFolder, aParms) {
+ let folderURI = aFolder.URI;
+ let parentIndex =
+ aParms && "parentIndex" in aParms ? aParms.parentIndex : null;
+ let selectIt = aParms && "select" in aParms ? aParms.select : true;
+ let openIt = aParms && "open" in aParms ? aParms.open : true;
+ let removeIt = aParms && "remove" in aParms ? aParms.remove : false;
+ let newFolder = aParms && "newFolder" in aParms ? aParms.newFolder : null;
+ let startIndex, startItem;
+ let found = false;
+
+ let firstVisRow, curFirstVisRow, curLastVisRow;
+ if (this.mView.tree) {
+ firstVisRow = this.mView.tree.getFirstVisibleRow();
+ }
+
+ if (parentIndex != null) {
+ // Use the parentIndex if given.
+ startIndex = parentIndex;
+ if (aFolder.isServer) {
+ // Fake item for account root folder.
+ startItem = {
+ name: "AccountRoot",
+ children: [this.mView.getItemAtIndex(startIndex)],
+ container: true,
+ open: false,
+ url: null,
+ level: -1,
+ };
+ } else {
+ startItem = this.mView.getItemAtIndex(startIndex);
+ }
+ } else {
+ // Get the folder's root parent index.
+ let index = 0;
+ for (index; index < this.mView.rowCount; index++) {
+ let item = this.mView.getItemAtIndex(index);
+ if (item.url == aFolder.server.rootFolder.URI) {
+ break;
+ }
+ }
+
+ startIndex = index;
+ if (aFolder.isServer) {
+ // Fake item for account root folder.
+ startItem = {
+ name: "AccountRoot",
+ children: [this.mView.getItemAtIndex(startIndex)],
+ container: true,
+ open: false,
+ url: null,
+ level: -1,
+ };
+ } else {
+ startItem = this.mView.getItemAtIndex(startIndex);
+ }
+ }
+
+ function containsFolder(aItem) {
+ // Search for the folder. If it's found, set the open state on all
+ // ancestor folders. A toggle() rebuilds the view rows to match the map.
+ if (aItem.url == folderURI) {
+ return (found = true);
+ }
+
+ for (let i = 0; i < aItem.children.length; i++) {
+ if (aItem.children[i].container && containsFolder(aItem.children[i])) {
+ if (removeIt && aItem.children[i].url == folderURI) {
+ // Get all occurrences in the tree cache arrays.
+ FeedUtils.log.debug(
+ "selectFolder: delete in cache, " +
+ "parent:children:item:index - " +
+ aItem.name +
+ ":" +
+ aItem.children.length +
+ ":" +
+ aItem.children[i].name +
+ ":" +
+ i
+ );
+ aItem.children.splice(i, 1);
+ FeedUtils.log.debug(
+ "selectFolder: deleted in cache, " +
+ "parent:children - " +
+ aItem.name +
+ ":" +
+ aItem.children.length
+ );
+ removeIt = false;
+ return true;
+ }
+ if (newFolder) {
+ let newItem = FeedSubscriptions.makeFolderObject(
+ newFolder,
+ aItem.level + 1
+ );
+ newItem.open = aItem.children[i].open;
+ if (newFolder.isServer) {
+ FeedSubscriptions.mFeedContainers[startIndex] = newItem;
+ } else {
+ aItem.children[i] = newItem;
+ aItem.children = FeedSubscriptions.folderItemSorter(
+ aItem.children
+ );
+ }
+ FeedUtils.log.trace(
+ "selectFolder: parentName:newFolderName:newFolderItem - " +
+ aItem.name +
+ ":" +
+ newItem.name +
+ ":" +
+ newItem.toSource()
+ );
+ newFolder = null;
+ return true;
+ }
+ if (!found) {
+ // For the folder to find.
+ found = true;
+ aItem.children[i].open = openIt;
+ } else if (selectIt || openIt) {
+ // For ancestor folders.
+ aItem.children[i].open = true;
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (startItem) {
+ // Find a folder with a specific parent.
+ containsFolder(startItem);
+ if (!found) {
+ return false;
+ }
+
+ if (!selectIt) {
+ return true;
+ }
+
+ if (startItem.open) {
+ this.mView.toggle(startIndex);
+ }
+
+ this.mView.toggleOpenState(startIndex);
+ }
+
+ for (let index = 0; index < this.mView.rowCount && selectIt; index++) {
+ // The desired folder is now in the view.
+ let item = this.mView.getItemAtIndex(index);
+ if (!item.container) {
+ continue;
+ }
+
+ if (item.url == folderURI) {
+ if (
+ item.children.length &&
+ ((!item.open && openIt) || (item.open && !openIt))
+ ) {
+ this.mView.toggleOpenState(index);
+ }
+
+ this.mView.selection.select(index);
+ found = true;
+ break;
+ }
+ }
+
+ // Ensure tree position does not jump unnecessarily.
+ curFirstVisRow = this.mView.tree.getFirstVisibleRow();
+ curLastVisRow = this.mView.tree.getLastVisibleRow();
+ if (
+ firstVisRow >= 0 &&
+ this.mView.rowCount - curLastVisRow > firstVisRow - curFirstVisRow
+ ) {
+ this.mView.tree.scrollToRow(firstVisRow);
+ } else {
+ this.mView.tree.ensureRowIsVisible(this.mView.rowCount - 1);
+ }
+
+ FeedUtils.log.debug(
+ "selectFolder: curIndex:firstVisRow:" +
+ "curFirstVisRow:curLastVisRow:rowCount - " +
+ this.mView.selection.currentIndex +
+ ":" +
+ firstVisRow +
+ ":" +
+ curFirstVisRow +
+ ":" +
+ curLastVisRow +
+ ":" +
+ this.mView.rowCount
+ );
+ return found;
+ },
+
+ /**
+ * Find the feed in the tree. The search first gets the feed's folder,
+ * then selects the child feed.
+ *
+ * @param {Feed} aFeed - The feed to find.
+ * @param {Integer} aParentIndex - Index to start the folder search.
+ *
+ * @returns {Boolean} found - true if found, false if not.
+ */
+ selectFeed(aFeed, aParentIndex) {
+ let folder = aFeed.folder;
+ let server = aFeed.server || aFeed.folder.server;
+ let found = false;
+
+ if (aFeed.folder.isServer) {
+ // If passed the root folder, the caller wants to get the feed's folder
+ // from the db (for cases of an ancestor folder rename/move).
+ let destFolder = FeedUtils.getSubscriptionAttr(
+ aFeed.url,
+ server,
+ "destFolder"
+ );
+ folder = server.rootFolder.getChildWithURI(destFolder, true, false);
+ }
+
+ if (this.selectFolder(folder, { parentIndex: aParentIndex })) {
+ let seln = this.mView.selection;
+ let item = this.mView.currentItem;
+ if (item) {
+ for (let i = seln.currentIndex + 1; i < this.mView.rowCount; i++) {
+ if (this.mView.getItemAtIndex(i).url == aFeed.url) {
+ this.mView.selection.select(i);
+ this.mView.tree.ensureRowIsVisible(i);
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+
+ return found;
+ },
+
+ updateFeedData(aItem) {
+ if (!aItem) {
+ return;
+ }
+
+ let nameValue = document.getElementById("nameValue");
+ let locationValue = document.getElementById("locationValue");
+ let locationValidate = document.getElementById("locationValidate");
+ let isServer = aItem.folder && aItem.folder.isServer;
+ let isFolder = aItem.folder && !aItem.folder.isServer;
+ let isFeed = !aItem.container;
+ let server, displayFolder;
+
+ if (isFeed) {
+ // A feed item. Set the feed location and title info.
+ nameValue.value = aItem.name;
+ locationValue.value = aItem.url;
+ locationValidate.removeAttribute("collapsed");
+
+ // Root the location picker to the news & blogs server.
+ server = aItem.parentFolder.server;
+ displayFolder = aItem.parentFolder;
+ } else {
+ // A folder/container item.
+ nameValue.value = "";
+ nameValue.disabled = true;
+ locationValue.value = "";
+ locationValidate.setAttribute("collapsed", true);
+
+ server = aItem.folder.server;
+ displayFolder = aItem.folder;
+ }
+
+ // Common to both folder and feed items.
+ nameValue.disabled = aItem.container;
+ this.setFolderPicker(displayFolder, isFeed);
+
+ // Set quick mode value.
+ document.getElementById("quickMode").checked = aItem.quickMode;
+
+ if (isServer) {
+ aItem.options = FeedUtils.getOptionsAcct(server);
+ }
+
+ // Update items.
+ let updateEnabled = document.getElementById("updateEnabled");
+ let updateValue = document.getElementById("updateValue");
+ let biffUnits = document.getElementById("biffUnits");
+ let recommendedUnits = document.getElementById("recommendedUnits");
+ let recommendedUnitsVal = document.getElementById("recommendedUnitsVal");
+ let updates = aItem.options
+ ? aItem.options.updates
+ : FeedUtils._optionsDefault.updates;
+
+ updateEnabled.checked = updates.enabled;
+ updateValue.disabled = !updateEnabled.checked || isFolder;
+ biffUnits.disabled = !updateEnabled.checked || isFolder;
+ biffUnits.value = updates.updateUnits;
+ let minutes =
+ updates.updateUnits == FeedUtils.kBiffUnitsMinutes
+ ? updates.updateMinutes
+ : updates.updateMinutes / (24 * 60);
+ updateValue.value = Number(minutes);
+ if (isFeed) {
+ recommendedUnitsVal.value = this.getUpdateMinutesRec(updates);
+ } else {
+ recommendedUnitsVal.value = "";
+ }
+
+ let hideRec = recommendedUnitsVal.value == "";
+ recommendedUnits.hidden = hideRec;
+ recommendedUnitsVal.hidden = hideRec;
+
+ // Autotag items.
+ let autotagEnable = document.getElementById("autotagEnable");
+ let autotagUsePrefix = document.getElementById("autotagUsePrefix");
+ let autotagPrefix = document.getElementById("autotagPrefix");
+ let category = aItem.options ? aItem.options.category : null;
+
+ autotagEnable.checked = category && category.enabled;
+ autotagUsePrefix.checked = category && category.prefixEnabled;
+ autotagUsePrefix.disabled = !autotagEnable.checked;
+ autotagPrefix.disabled =
+ autotagUsePrefix.disabled || !autotagUsePrefix.checked;
+ autotagPrefix.value = category && category.prefix ? category.prefix : "";
+ },
+
+ setFolderPicker(aFolder, aIsFeed) {
+ let folderPrettyPath = FeedUtils.getFolderPrettyPath(aFolder);
+ if (!folderPrettyPath) {
+ return;
+ }
+
+ let selectFolder = document.getElementById("selectFolder");
+ let selectFolderPopup = document.getElementById("selectFolderPopup");
+ let selectFolderValue = document.getElementById("selectFolderValue");
+
+ selectFolder.setAttribute("hidden", !aIsFeed);
+ selectFolder._folder = aFolder;
+ selectFolderValue.toggleAttribute("hidden", aIsFeed);
+ selectFolderValue.setAttribute("showfilepath", false);
+
+ if (aIsFeed) {
+ selectFolderPopup._ensureInitialized();
+ selectFolderPopup.selectFolder(aFolder);
+ selectFolder.setAttribute("label", folderPrettyPath);
+ selectFolder.setAttribute("uri", aFolder.URI);
+ } else {
+ selectFolderValue.value = folderPrettyPath;
+ selectFolderValue.setAttribute("prettypath", folderPrettyPath);
+ selectFolderValue.setAttribute("filepath", aFolder.filePath.path);
+ }
+ },
+
+ onClickSelectFolderValue(aEvent) {
+ let target = aEvent.target;
+ if (
+ ("button" in aEvent &&
+ (aEvent.button != 0 ||
+ aEvent.target.localName != "div" ||
+ target.selectionStart != target.selectionEnd)) ||
+ (aEvent.keyCode && aEvent.keyCode != aEvent.DOM_VK_RETURN)
+ ) {
+ return;
+ }
+
+ // Toggle between showing prettyPath and absolute filePath.
+ if (target.getAttribute("showfilepath") == "true") {
+ target.setAttribute("showfilepath", false);
+ target.value = target.getAttribute("prettypath");
+ } else {
+ target.setAttribute("showfilepath", true);
+ target.value = target.getAttribute("filepath");
+ }
+ },
+
+ /**
+ * The user changed the folder for storing the feed.
+ *
+ * @param {Event} aEvent - Event.
+ * @returns {void}
+ */
+ setNewFolder(aEvent) {
+ aEvent.stopPropagation();
+ this.setFolderPicker(aEvent.target._folder, true);
+
+ let seln = this.mView.selection;
+ if (seln.count != 1) {
+ return;
+ }
+
+ let item = this.mView.getItemAtIndex(seln.currentIndex);
+ if (!item || item.container || !item.parentFolder) {
+ return;
+ }
+
+ let selectFolder = document.getElementById("selectFolder");
+ let editFolderURI = selectFolder.getAttribute("uri");
+ if (item.parentFolder.URI == editFolderURI) {
+ return;
+ }
+
+ let feed = new Feed(item.url, item.parentFolder);
+
+ // Make sure the new folderpicked folder is visible.
+ this.selectFolder(selectFolder._folder);
+ // Now go back to the feed item.
+ this.selectFeed(feed, null);
+ // We need to find the index of the new parent folder.
+ let newParentIndex = this.mView.kRowIndexUndefined;
+ for (let index = 0; index < this.mView.rowCount; index++) {
+ let item = this.mView.getItemAtIndex(index);
+ if (item && item.container && item.url == editFolderURI) {
+ newParentIndex = index;
+ break;
+ }
+ }
+
+ if (newParentIndex != this.mView.kRowIndexUndefined) {
+ this.moveCopyFeed(seln.currentIndex, newParentIndex, "move");
+ }
+ },
+
+ setSummary(aChecked) {
+ let item = this.mView.currentItem;
+ if (!item || !item.folder) {
+ // Not a folder.
+ return;
+ }
+
+ if (item.folder.isServer) {
+ if (document.getElementById("locationValue").value) {
+ // Intent is to add a feed/folder to the account, so return.
+ return;
+ }
+
+ // An account folder. If it changes, all non feed containing subfolders
+ // need to be updated with the new default.
+ item.folder.server.setBoolValue("quickMode", aChecked);
+ this.FolderListener.folderAdded(item.folder);
+ } else if (!FeedUtils.getFeedUrlsInFolder(item.folder)) {
+ // Not a folder with feeds.
+ return;
+ } else {
+ let feedsInFolder = this.getFeedsInFolder(item.folder);
+ // Update the feeds database, for each feed in the folder.
+ feedsInFolder.forEach(function (feed) {
+ feed.quickMode = aChecked;
+ });
+ // Update the folder's feeds properties in the tree map.
+ item.children.forEach(function (feed) {
+ feed.quickMode = aChecked;
+ });
+ }
+
+ // Update the folder in the tree map.
+ item.quickMode = aChecked;
+ let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated");
+ this.updateStatusItem("statusText", message);
+ },
+
+ setPrefs(aNode) {
+ let item = this.mView.currentItem;
+ if (!item) {
+ return;
+ }
+
+ let isServer = item.folder && item.folder.isServer;
+ let isFolder = item.folder && !item.folder.isServer;
+ let updateEnabled = document.getElementById("updateEnabled");
+ let updateValue = document.getElementById("updateValue");
+ let biffUnits = document.getElementById("biffUnits");
+ let autotagEnable = document.getElementById("autotagEnable");
+ let autotagUsePrefix = document.getElementById("autotagUsePrefix");
+ let autotagPrefix = document.getElementById("autotagPrefix");
+ if (
+ isFolder ||
+ (isServer && document.getElementById("locationValue").value)
+ ) {
+ // Intend to subscribe a feed to a folder, a value must be in the url
+ // field. Update states for addFeed() and return.
+ updateValue.disabled = !updateEnabled.checked;
+ biffUnits.disabled = !updateEnabled.checked;
+ autotagUsePrefix.disabled = !autotagEnable.checked;
+ autotagPrefix.disabled =
+ autotagUsePrefix.disabled || !autotagUsePrefix.checked;
+ return;
+ }
+
+ switch (aNode.id) {
+ case "nameValue":
+ // Check to see if the title value changed, no blank title allowed.
+ if (!aNode.value) {
+ aNode.value = item.name;
+ return;
+ }
+
+ item.name = aNode.value;
+ let seln = this.mView.selection;
+ seln.tree.invalidateRow(seln.currentIndex);
+ break;
+ case "locationValue":
+ let updateFeedButton = document.getElementById("updateFeed");
+ // Change label based on whether feed url has beed edited.
+ updateFeedButton.label =
+ aNode.value == item.url
+ ? updateFeedButton.getAttribute("verifylabel")
+ : updateFeedButton.getAttribute("updatelabel");
+ updateFeedButton.setAttribute(
+ "accesskey",
+ aNode.value == item.url
+ ? updateFeedButton.getAttribute("verifyaccesskey")
+ : updateFeedButton.getAttribute("updateaccesskey")
+ );
+ // Disable the Update button if no feed url value is entered.
+ updateFeedButton.disabled = !aNode.value;
+ return;
+ case "updateEnabled":
+ case "updateValue":
+ case "biffUnits":
+ item.options.updates.enabled = updateEnabled.checked;
+ let minutes =
+ biffUnits.value == FeedUtils.kBiffUnitsMinutes
+ ? updateValue.value
+ : updateValue.value * 24 * 60;
+ item.options.updates.updateMinutes = Number(minutes);
+ item.options.updates.updateUnits = biffUnits.value;
+ break;
+ case "autotagEnable":
+ item.options.category.enabled = aNode.checked;
+ break;
+ case "autotagUsePrefix":
+ item.options.category.prefixEnabled = aNode.checked;
+ item.options.category.prefix = autotagPrefix.value;
+ break;
+ case "autotagPrefix":
+ item.options.category.prefix = aNode.value;
+ break;
+ }
+
+ if (isServer) {
+ FeedUtils.setOptionsAcct(item.folder.server, item.options);
+ } else {
+ let feed = new Feed(item.url, item.parentFolder);
+ feed.title = item.name;
+ feed.options = item.options;
+
+ if (aNode.id == "updateEnabled") {
+ FeedUtils.setStatus(
+ item.parentFolder,
+ item.url,
+ "enabled",
+ aNode.checked
+ );
+ this.mView.selection.tree.invalidateRow(
+ this.mView.selection.currentIndex
+ );
+ }
+ if (aNode.id == "updateValue") {
+ FeedUtils.setStatus(
+ item.parentFolder,
+ item.url,
+ "updateMinutes",
+ item.options.updates.updateMinutes
+ );
+ }
+ }
+
+ this.updateFeedData(item);
+ let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated");
+ this.updateStatusItem("statusText", message);
+ },
+
+ getUpdateMinutesRec(aUpdates) {
+ // Assume the parser has stored correct/valid values for the spec. If the
+ // feed doesn't use any of these tags, updatePeriod will be null.
+ if (aUpdates.updatePeriod == null) {
+ return "";
+ }
+
+ let biffUnits = document.getElementById("biffUnits").value;
+ let units = biffUnits == FeedUtils.kBiffUnitsDays ? 1 : 24 * 60;
+ let frequency = aUpdates.updateFrequency;
+ let val;
+ switch (aUpdates.updatePeriod) {
+ case "hourly":
+ val =
+ biffUnits == FeedUtils.kBiffUnitsDays
+ ? 1 / frequency / 24
+ : 60 / frequency;
+ break;
+ case "daily":
+ val = units / frequency;
+ break;
+ case "weekly":
+ val = (7 * units) / frequency;
+ break;
+ case "monthly":
+ val = (30 * units) / frequency;
+ break;
+ case "yearly":
+ val = (365 * units) / frequency;
+ break;
+ }
+
+ return val ? Math.round(val * 1000) / 1000 : "";
+ },
+
+ onKeyPress(aEvent) {
+ if (
+ aEvent.keyCode == aEvent.DOM_VK_DELETE &&
+ aEvent.target.id == "rssSubscriptionsList"
+ ) {
+ this.removeFeed(true);
+ }
+
+ this.clearStatusInfo();
+ },
+
+ onSelect() {
+ let item = this.mView.currentItem;
+ this.updateFeedData(item);
+ this.setFocus();
+ this.updateButtons(item);
+ },
+
+ updateButtons(aSelectedItem) {
+ let item = aSelectedItem;
+ let isServer = item && item.folder && item.folder.isServer;
+ let isFeed = item && !item.container;
+ document.getElementById("addFeed").hidden = !item || isFeed;
+ document.getElementById("updateFeed").hidden = !isFeed;
+ document.getElementById("removeFeed").hidden = !isFeed;
+ document.getElementById("importOPML").hidden = !isServer;
+ document.getElementById("exportOPML").hidden = !isServer;
+
+ document.getElementById("importOPML").disabled = document.getElementById(
+ "exportOPML"
+ ).disabled = this.mActionMode == this.kImportingOPML;
+ },
+
+ onMouseDown(aEvent) {
+ if (
+ aEvent.button != 0 ||
+ aEvent.target.id == "validationText" ||
+ aEvent.target.id == "addCertException"
+ ) {
+ return;
+ }
+
+ this.clearStatusInfo();
+ },
+
+ onFocusChange() {
+ setTimeout(() => {
+ this.setFocus();
+ }, 0);
+ },
+
+ setFocus() {
+ let item = this.mView.currentItem;
+ if (!item || this.mActionMode == this.kImportingOPML) {
+ return;
+ }
+
+ let locationValue = document.getElementById("locationValue");
+ let updateEnabled = document.getElementById("updateEnabled");
+
+ let quickMode = document.getElementById("quickMode");
+ let autotagEnable = document.getElementById("autotagEnable");
+ let autotagUsePrefix = document.getElementById("autotagUsePrefix");
+ let autotagPrefix = document.getElementById("autotagPrefix");
+
+ let addFeedButton = document.getElementById("addFeed");
+ let updateFeedButton = document.getElementById("updateFeed");
+
+ let isServer = item.folder && item.folder.isServer;
+ let isFolder = item.folder && !item.folder.isServer;
+
+ // Enabled by default.
+ updateEnabled.disabled =
+ quickMode.disabled =
+ autotagEnable.disabled =
+ false;
+
+ updateEnabled.parentNode
+ .querySelectorAll("input,radio,label")
+ .forEach(item => {
+ item.disabled = !updateEnabled.checked;
+ });
+
+ autotagUsePrefix.disabled = !autotagEnable.checked;
+ autotagPrefix.disabled =
+ autotagUsePrefix.disabled || !autotagUsePrefix.checked;
+
+ let focusedElement = window.document.commandDispatcher.focusedElement;
+
+ if (isServer) {
+ addFeedButton.disabled =
+ addFeedButton != focusedElement &&
+ locationValue != document.activeElement &&
+ !locationValue.value;
+ } else if (isFolder) {
+ let disable =
+ locationValue != document.activeElement && !locationValue.value;
+ // Summary is enabled for a folder with feeds or if adding a feed.
+ quickMode.disabled =
+ disable && !FeedUtils.getFeedUrlsInFolder(item.folder);
+ // All other options disabled unless intent is to add a feed.
+ updateEnabled.disabled = disable;
+ updateEnabled.parentNode
+ .querySelectorAll("input,radio,label")
+ .forEach(item => {
+ item.disabled = disable;
+ });
+
+ autotagEnable.disabled = disable;
+
+ addFeedButton.disabled =
+ addFeedButton != focusedElement &&
+ locationValue != document.activeElement &&
+ !locationValue.value;
+ } else {
+ // Summary is disabled; applied per folder to apply to all feeds in it.
+ quickMode.disabled = true;
+ // Ensure the current feed url is restored if the user did not update.
+ if (
+ locationValue.value != item.url &&
+ locationValue != document.activeElement &&
+ focusedElement != updateFeedButton &&
+ focusedElement.id != "addCertException"
+ ) {
+ locationValue.value = item.url;
+ }
+ this.setPrefs(locationValue);
+ // Set button state.
+ updateFeedButton.disabled = !locationValue.value;
+ }
+ },
+
+ removeFeed(aPrompt) {
+ let seln = this.mView.selection;
+ if (seln.count != 1) {
+ return;
+ }
+
+ let itemToRemove = this.mView.getItemAtIndex(seln.currentIndex);
+
+ if (!itemToRemove || itemToRemove.container) {
+ return;
+ }
+
+ if (aPrompt) {
+ // Confirm unsubscribe prompt.
+ let pTitle = FeedUtils.strings.GetStringFromName(
+ "subscribe-confirmFeedDeletionTitle"
+ );
+ let pMessage = FeedUtils.strings.formatStringFromName(
+ "subscribe-confirmFeedDeletion",
+ [itemToRemove.name]
+ );
+ if (
+ Services.prompt.confirmEx(
+ window,
+ pTitle,
+ pMessage,
+ Ci.nsIPromptService.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ )
+ ) {
+ return;
+ }
+ }
+
+ let feed = new Feed(itemToRemove.url, itemToRemove.parentFolder);
+ FeedUtils.deleteFeed(feed);
+
+ // Now that we have removed the feed from the datasource, it is time to
+ // update our view layer. Update parent folder's quickMode if necessary
+ // and remove the child from its parent folder object.
+ let parentIndex = this.mView.getParentIndex(seln.currentIndex);
+ let parentItem = this.mView.getItemAtIndex(parentIndex);
+ this.updateFolderQuickModeInView(itemToRemove, parentItem, true);
+ this.mView.removeItemAtIndex(seln.currentIndex, false);
+ let message = FeedUtils.strings.GetStringFromName("subscribe-feedRemoved");
+ this.updateStatusItem("statusText", message);
+ },
+
+ /**
+ * This addFeed is used by 1) Add button, 1) Update button, 3) Drop of a
+ * feed url on a folder (which can be an add or move). If Update, the new
+ * url is added and the old removed; thus aParse is false and no new messages
+ * are downloaded, the feed is only validated and stored in the db. If dnd,
+ * the drop folder is selected and the url is prefilled, so proceed just as
+ * though the url were entered manually. This allows a user to see the dnd
+ * url better in case of errors.
+ *
+ * @param {String} aFeedLocation - the feed url; get the url from the
+ * input field if null.
+ * @param {nsIMsgFolder} aFolder - folder to subscribe, current selected
+ * folder if null.
+ * @param {Boolean} aParse - if true (default) parse and download
+ * the feed's articles.
+ * @param {Object} aParams - additional params.
+ * @param {Integer} aMode - action mode (default is kSubscribeMode)
+ * of the add.
+ *
+ * @returns {Boolean} success - true if edit checks passed and an
+ * async download has been initiated.
+ */
+ addFeed(aFeedLocation, aFolder, aParse, aParams, aMode) {
+ let message;
+ let parse = aParse == null ? true : aParse;
+ let mode = aMode == null ? this.kSubscribeMode : aMode;
+ let locationValue = document.getElementById("locationValue");
+ let quickMode =
+ aParams && "quickMode" in aParams
+ ? aParams.quickMode
+ : document.getElementById("quickMode").checked;
+ let name =
+ aParams && "name" in aParams
+ ? aParams.name
+ : document.getElementById("nameValue").value;
+ let options = aParams && "options" in aParams ? aParams.options : null;
+
+ if (aFeedLocation) {
+ locationValue.value = aFeedLocation;
+ }
+ let feedLocation = locationValue.value.trim();
+
+ if (!feedLocation) {
+ locationValue.focus();
+ message = locationValue.getAttribute("placeholder");
+ this.updateStatusItem("statusText", message);
+ return false;
+ }
+
+ if (!FeedUtils.isValidScheme(feedLocation)) {
+ locationValue.focus();
+ message = FeedUtils.strings.GetStringFromName("subscribe-feedNotValid");
+ this.updateStatusItem("statusText", message);
+ return false;
+ }
+
+ let addFolder;
+ if (aFolder) {
+ // For Update or if passed a folder.
+ if (aFolder instanceof Ci.nsIMsgFolder) {
+ addFolder = aFolder;
+ }
+ } else {
+ // A folder must be selected for Add and Drop.
+ let index = this.mView.selection.currentIndex;
+ let item = this.mView.getItemAtIndex(index);
+ if (item && item.container) {
+ addFolder = item.folder;
+ }
+ }
+
+ // Shouldn't happen. Or else not passed an nsIMsgFolder.
+ if (!addFolder) {
+ return false;
+ }
+
+ // Before we go any further, make sure the user is not already subscribed
+ // to this feed.
+ if (FeedUtils.feedAlreadyExists(feedLocation, addFolder.server)) {
+ locationValue.focus();
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedAlreadySubscribed"
+ );
+ this.updateStatusItem("statusText", message);
+ return false;
+ }
+
+ if (!options) {
+ // Not passed a param, get values from the ui.
+ options = FeedUtils.optionsTemplate;
+ options.updates.enabled =
+ document.getElementById("updateEnabled").checked;
+ let biffUnits = document.getElementById("biffUnits").value;
+ let units = document.getElementById("updateValue").value;
+ let minutes =
+ biffUnits == FeedUtils.kBiffUnitsMinutes ? units : units * 24 * 60;
+ options.updates.updateUnits = biffUnits;
+ options.updates.updateMinutes = Number(minutes);
+ options.category.enabled =
+ document.getElementById("autotagEnable").checked;
+ options.category.prefixEnabled =
+ document.getElementById("autotagUsePrefix").checked;
+ options.category.prefix = document.getElementById("autotagPrefix").value;
+ }
+
+ let feedProperties = {
+ feedName: name,
+ feedLocation,
+ feedFolder: addFolder,
+ quickMode,
+ options,
+ };
+
+ let feed = this.storeFeed(feedProperties);
+ if (!feed) {
+ return false;
+ }
+
+ // Now validate and start downloading the feed.
+ message = FeedUtils.strings.GetStringFromName("subscribe-validating-feed");
+ this.updateStatusItem("statusText", message);
+ this.updateStatusItem("progressMeter", "?");
+ document.getElementById("addFeed").disabled = true;
+ this.mActionMode = mode;
+ feed.download(parse, this.mFeedDownloadCallback);
+ return true;
+ },
+
+ // Helper routine used by addFeed and importOPMLFile.
+ storeFeed(feedProperties) {
+ let feed = new Feed(feedProperties.feedLocation, feedProperties.feedFolder);
+ feed.title = feedProperties.feedName;
+ feed.quickMode = feedProperties.quickMode;
+ feed.options = feedProperties.options;
+ return feed;
+ },
+
+ /**
+ * When a feed item is selected, the Update button is used to verify the
+ * existing feed url, or to verify and update the feed url if the field
+ * has been edited. This is the only use of the Update button.
+ *
+ * @returns {void}
+ */
+ updateFeed() {
+ let seln = this.mView.selection;
+ if (seln.count != 1) {
+ return;
+ }
+
+ let item = this.mView.getItemAtIndex(seln.currentIndex);
+ if (!item || item.container || !item.parentFolder) {
+ return;
+ }
+
+ let feed = new Feed(item.url, item.parentFolder);
+
+ // Disable the button.
+ document.getElementById("updateFeed").disabled = true;
+
+ let feedLocation = document.getElementById("locationValue").value.trim();
+ if (feed.url != feedLocation) {
+ // Updating a url. We need to add the new url and delete the old, to
+ // ensure everything is cleaned up correctly.
+ this.addFeed(null, item.parentFolder, false, null, this.kUpdateMode);
+ return;
+ }
+
+ // Now we want to verify if the stored feed url still works. If it
+ // doesn't, show the error.
+ let message = FeedUtils.strings.GetStringFromName(
+ "subscribe-validating-feed"
+ );
+ this.mActionMode = this.kVerifyUrlMode;
+ this.updateStatusItem("statusText", message);
+ this.updateStatusItem("progressMeter", "?");
+ feed.download(false, this.mFeedDownloadCallback);
+ },
+
+ /**
+ * Moves or copies a feed to another folder or account.
+ *
+ * @param {Integer} aOldFeedIndex - Index in tree of target feed item.
+ * @param {Integer} aNewParentIndex - Index in tree of target parent folder item.
+ * @param {String} aMoveCopy - Either "move" or "copy".
+ *
+ * @returns {void}
+ */
+ moveCopyFeed(aOldFeedIndex, aNewParentIndex, aMoveCopy) {
+ let moveFeed = aMoveCopy == "move";
+ let currentItem = this.mView.getItemAtIndex(aOldFeedIndex);
+ if (
+ !currentItem ||
+ this.mView.getParentIndex(aOldFeedIndex) == aNewParentIndex
+ ) {
+ // If the new parent is the same as the current parent, then do nothing.
+ return;
+ }
+
+ let currentParentIndex = this.mView.getParentIndex(aOldFeedIndex);
+ let currentParentItem = this.mView.getItemAtIndex(currentParentIndex);
+ let currentFolder = currentParentItem.folder;
+
+ let newParentItem = this.mView.getItemAtIndex(aNewParentIndex);
+ let newFolder = newParentItem.folder;
+
+ let accountMoveCopy = false;
+ if (currentFolder.rootFolder.URI == newFolder.rootFolder.URI) {
+ // Moving within the same account/feeds db.
+ if (newFolder.isServer || !moveFeed) {
+ // No moving to account folder if already in the account; can only move,
+ // not copy, to folder in the same account.
+ return;
+ }
+
+ // Update the destFolder for this feed's subscription.
+ FeedUtils.setSubscriptionAttr(
+ currentItem.url,
+ currentItem.parentFolder.server,
+ "destFolder",
+ newFolder.URI
+ );
+
+ // Update folderpane favicons.
+ Services.obs.notifyObservers(currentFolder, "folder-properties-changed");
+ Services.obs.notifyObservers(newFolder, "folder-properties-changed");
+ } else {
+ // Moving/copying to a new account. If dropping on the account folder,
+ // a new subfolder is created if necessary.
+ accountMoveCopy = true;
+ let mode = moveFeed ? this.kMoveMode : this.kCopyMode;
+ let params = {
+ quickMode: currentItem.quickMode,
+ name: currentItem.name,
+ options: currentItem.options,
+ };
+ // Subscribe to the new folder first. If it already exists in the
+ // account or on error, return.
+ if (!this.addFeed(currentItem.url, newFolder, false, params, mode)) {
+ return;
+ }
+ // Unsubscribe the feed from the old folder, if add to the new folder
+ // is successful, and doing a move.
+ if (moveFeed) {
+ let feed = new Feed(currentItem.url, currentItem.parentFolder);
+ FeedUtils.deleteFeed(feed);
+ }
+ }
+
+ // Update local favicons.
+ currentParentItem.favicon = newParentItem.favicon = null;
+
+ // Finally, update our view layer. Update old parent folder's quickMode
+ // and remove the old row, if move. Otherwise no change to the view.
+ if (moveFeed) {
+ this.updateFolderQuickModeInView(currentItem, currentParentItem, true);
+ this.mView.removeItemAtIndex(aOldFeedIndex, true);
+ if (aNewParentIndex > aOldFeedIndex) {
+ aNewParentIndex--;
+ }
+ }
+
+ if (accountMoveCopy) {
+ // If a cross account move/copy, download callback will update the view
+ // with the new location. Preselect folder/mode for callback.
+ this.selectFolder(newFolder, { parentIndex: aNewParentIndex });
+ return;
+ }
+
+ // Add the new row location to the view.
+ currentItem.level = newParentItem.level + 1;
+ currentItem.parentFolder = newFolder;
+ this.updateFolderQuickModeInView(currentItem, newParentItem, false);
+ newParentItem.children.push(currentItem);
+
+ if (newParentItem.open) {
+ // Close the container, selecting the feed will rebuild the view rows.
+ this.mView.toggle(aNewParentIndex);
+ }
+
+ this.selectFeed(
+ { folder: newParentItem.folder, url: currentItem.url },
+ aNewParentIndex
+ );
+
+ let message = FeedUtils.strings.GetStringFromName("subscribe-feedMoved");
+ this.updateStatusItem("statusText", message);
+ },
+
+ updateFolderQuickModeInView(aFeedItem, aParentItem, aRemove) {
+ let feedItem = aFeedItem;
+ let parentItem = aParentItem;
+ let feedUrlArray = FeedUtils.getFeedUrlsInFolder(feedItem.parentFolder);
+ let feedsInFolder = feedUrlArray ? feedUrlArray.length : 0;
+
+ if (aRemove && feedsInFolder < 1) {
+ // Removed only feed in folder; set quickMode to server default.
+ parentItem.quickMode = parentItem.folder.server.getBoolValue("quickMode");
+ }
+
+ if (!aRemove) {
+ // Just added a feed to a folder. If there are already feeds in the
+ // folder, the feed must reflect the parent's quickMode. If it is the
+ // only feed, update the parent folder to the feed's quickMode.
+ if (feedsInFolder > 1) {
+ let feed = new Feed(feedItem.url, feedItem.parentFolder);
+ feed.quickMode = parentItem.quickMode;
+ feedItem.quickMode = parentItem.quickMode;
+ } else {
+ parentItem.quickMode = feedItem.quickMode;
+ }
+ }
+ },
+
+ onDragStart(aEvent) {
+ // Get the selected feed article (if there is one).
+ let seln = this.mView.selection;
+ if (seln.count != 1) {
+ return;
+ }
+
+ // Only initiate a drag if the item is a feed (ignore folders/containers).
+ let item = this.mView.getItemAtIndex(seln.currentIndex);
+ if (!item || item.container) {
+ return;
+ }
+
+ aEvent.dataTransfer.setData("text/x-moz-feed-index", seln.currentIndex);
+ aEvent.dataTransfer.effectAllowed = "copyMove";
+ },
+
+ onDragOver(aEvent) {
+ this.mView._currentDataTransfer = aEvent.dataTransfer;
+ },
+
+ mFeedDownloadCallback: {
+ mSubscribeMode: true,
+ async downloaded(feed, aErrorCode) {
+ // Offline check is done in the context of 3pane, return to the subscribe
+ // window once the modal prompt is dispatched.
+ window.focus();
+ // Feed is null if our attempt to parse the feed failed.
+ let message = "";
+ let win = FeedSubscriptions;
+ if (
+ aErrorCode == FeedUtils.kNewsBlogSuccess ||
+ aErrorCode == FeedUtils.kNewsBlogNoNewItems
+ ) {
+ win.updateStatusItem("progressMeter", 100);
+
+ if (win.mActionMode == win.kVerifyUrlMode) {
+ // Just checking for errors, if none bye. The (non error) code
+ // kNewsBlogNoNewItems can only happen in verify mode.
+ win.mActionMode = null;
+ win.clearStatusInfo();
+ if (Services.io.offline) {
+ return;
+ }
+
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedVerified"
+ );
+ win.updateStatusItem("statusText", message);
+ return;
+ }
+
+ // Update lastUpdateTime if successful.
+ let options = feed.options;
+ options.updates.lastUpdateTime = Date.now();
+ feed.options = options;
+
+ // Add the feed to the databases.
+ FeedUtils.addFeed(feed);
+
+ // Set isBusy status, and clear it after getting favicon. This makes
+ // sure the folder icon is redrawn to reflect what we got.
+ FeedUtils.setStatus(
+ feed.folder,
+ feed.url,
+ "code",
+ FeedUtils.kNewsBlogFeedIsBusy
+ );
+ await FeedUtils.getFavicon(feed.folder, feed.url);
+ FeedUtils.setStatus(feed.folder, feed.url, "code", aErrorCode);
+
+ // Now add the feed to our view. If adding, the current selection will
+ // be a folder; if updating it will be a feed. No need to rebuild the
+ // entire view, that is too jarring.
+ let curIndex = win.mView.selection.currentIndex;
+ let curItem = win.mView.getItemAtIndex(curIndex);
+ if (curItem) {
+ let parentIndex, parentItem, newItem, level;
+ if (curItem.container) {
+ // Open the container, if it exists.
+ let folderExists = win.selectFolder(feed.folder, {
+ parentIndex: curIndex,
+ });
+ if (!folderExists) {
+ // This means a new folder was created.
+ parentIndex = curIndex;
+ parentItem = curItem;
+ level = curItem.level + 1;
+ newItem = win.makeFolderObject(feed.folder, level);
+ } else {
+ // If a folder happens to exist which matches one that would
+ // have been created, the feed system reuses it. Get the
+ // current item again if reusing a previously unselected folder.
+ curIndex = win.mView.selection.currentIndex;
+ curItem = win.mView.getItemAtIndex(curIndex);
+ parentIndex = curIndex;
+ parentItem = curItem;
+ level = curItem.level + 1;
+ newItem = win.makeFeedObject(feed, feed.folder, level);
+ }
+ } else {
+ // Adding a feed.
+ parentIndex = win.mView.getParentIndex(curIndex);
+ parentItem = win.mView.getItemAtIndex(parentIndex);
+ level = curItem.level;
+ newItem = win.makeFeedObject(feed, feed.folder, level);
+ }
+
+ if (!newItem.container) {
+ win.updateFolderQuickModeInView(newItem, parentItem, false);
+ }
+
+ parentItem.children.push(newItem);
+ parentItem.children = win.folderItemSorter(parentItem.children);
+ parentItem.favicon = null;
+
+ if (win.mActionMode == win.kSubscribeMode) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedAdded"
+ );
+ }
+ if (win.mActionMode == win.kUpdateMode) {
+ win.removeFeed(false);
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedUpdated"
+ );
+ }
+ if (win.mActionMode == win.kMoveMode) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedMoved"
+ );
+ }
+ if (win.mActionMode == win.kCopyMode) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedCopied"
+ );
+ }
+
+ win.selectFeed(feed, parentIndex);
+ }
+ } else {
+ // Non success. Remove intermediate traces from the feeds database.
+ // But only if we're not in verify mode.
+ if (
+ win.mActionMode != win.kVerifyUrlMode &&
+ feed &&
+ feed.url &&
+ feed.server
+ ) {
+ FeedUtils.deleteFeed(feed);
+ }
+
+ if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-feedNotValid"
+ );
+ }
+ if (aErrorCode == FeedUtils.kNewsBlogRequestFailure) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-networkError"
+ );
+ }
+ if (aErrorCode == FeedUtils.kNewsBlogFileError) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-errorOpeningFile"
+ );
+ }
+ if (aErrorCode == FeedUtils.kNewsBlogBadCertError) {
+ let host = Services.io.newURI(feed.url).host;
+ message = FeedUtils.strings.formatStringFromName(
+ "newsblog-badCertError",
+ [host]
+ );
+ }
+ if (aErrorCode == FeedUtils.kNewsBlogNoAuthError) {
+ message = FeedUtils.strings.GetStringFromName(
+ "subscribe-noAuthError"
+ );
+ }
+
+ // Focus the url if verify/update failed.
+ if (
+ win.mActionMode == win.kUpdateMode ||
+ win.mActionMode == win.kVerifyUrlMode
+ ) {
+ document.getElementById("locationValue").focus();
+ }
+ }
+
+ win.mActionMode = null;
+ win.clearStatusInfo();
+ let code = feed.url.startsWith("http") ? aErrorCode : null;
+ win.updateStatusItem("statusText", message, code);
+ },
+
+ // This gets called after the RSS parser finishes storing a feed item to
+ // disk. aCurrentFeedItems is an integer corresponding to how many feed
+ // items have been downloaded so far. aMaxFeedItems is an integer
+ // corresponding to the total number of feed items to download.
+ onFeedItemStored(feed, aCurrentFeedItems, aMaxFeedItems) {
+ window.focus();
+ let message = FeedUtils.strings.formatStringFromName(
+ "subscribe-gettingFeedItems",
+ [aCurrentFeedItems, aMaxFeedItems]
+ );
+ FeedSubscriptions.updateStatusItem("statusText", message);
+ this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems);
+ },
+
+ onProgress(feed, aProgress, aProgressMax, aLengthComputable) {
+ FeedSubscriptions.updateStatusItem(
+ "progressMeter",
+ (aProgress * 100) / (aProgressMax || 100)
+ );
+ },
+ },
+
+ // Status routines.
+ updateStatusItem(aID, aValue, aErrorCode) {
+ let el = document.getElementById(aID);
+ if (el.getAttribute("collapsed")) {
+ el.removeAttribute("collapsed");
+ }
+ if (el.hidden) {
+ el.hidden = false;
+ }
+
+ if (aID == "progressMeter") {
+ if (aValue == "?") {
+ el.removeAttribute("value");
+ } else {
+ el.value = aValue;
+ }
+ } else if (aID == "statusText") {
+ el.textContent = aValue;
+ }
+
+ el = document.getElementById("validationText");
+ if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) {
+ el.removeAttribute("collapsed");
+ } else {
+ el.setAttribute("collapsed", true);
+ }
+
+ el = document.getElementById("addCertException");
+ if (aErrorCode == FeedUtils.kNewsBlogBadCertError) {
+ el.removeAttribute("collapsed");
+ } else {
+ el.setAttribute("collapsed", true);
+ }
+ },
+
+ clearStatusInfo() {
+ document.getElementById("statusText").textContent = "";
+ document.getElementById("progressMeter").hidden = true;
+ document.getElementById("validationText").collapsed = true;
+ document.getElementById("addCertException").collapsed = true;
+ },
+
+ checkValidation(aEvent) {
+ if (aEvent.button != 0) {
+ return;
+ }
+
+ let validationQuery = "http://validator.w3.org/feed/check.cgi?url=";
+
+ if (this.mMainWin) {
+ let tabmail = this.mMainWin.document.getElementById("tabmail");
+ if (tabmail) {
+ let feedLocation = document.getElementById("locationValue").value;
+ let url = validationQuery + encodeURIComponent(feedLocation);
+
+ this.mMainWin.focus();
+ this.mMainWin.openContentTab(url);
+ FeedUtils.log.debug("checkValidation: query url - " + url);
+ }
+ }
+ aEvent.stopPropagation();
+ },
+
+ addCertExceptionDialog() {
+ let locationValue = document.getElementById("locationValue");
+ let feedURL = locationValue.value.trim();
+ let params = {
+ exceptionAdded: false,
+ location: feedURL,
+ prefetchCert: true,
+ };
+ window.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ if (params.exceptionAdded) {
+ this.clearStatusInfo();
+ }
+
+ locationValue.focus();
+ },
+
+ // Listener for folder pane changes.
+ FolderListener: {
+ get feedWindow() {
+ let subscriptionsWindow = Services.wm.getMostRecentWindow(
+ "Mail:News-BlogSubscriptions"
+ );
+ return subscriptionsWindow ? subscriptionsWindow.FeedSubscriptions : null;
+ },
+
+ get currentSelectedIndex() {
+ return this.feedWindow
+ ? this.feedWindow.mView.selection.currentIndex
+ : -1;
+ },
+
+ get currentSelectedItem() {
+ return this.feedWindow ? this.feedWindow.mView.currentItem : null;
+ },
+
+ folderAdded(aFolder) {
+ if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) {
+ return;
+ }
+
+ let parentFolder = aFolder.isServer ? aFolder : aFolder.parent;
+ FeedUtils.log.debug(
+ "folderAdded: folder:parent - " +
+ aFolder.name +
+ ":" +
+ (parentFolder ? parentFolder.filePath.path : "(null)")
+ );
+
+ if (!parentFolder || !this.feedWindow) {
+ return;
+ }
+
+ let feedWindow = this.feedWindow;
+ let curSelItem = this.currentSelectedItem;
+ let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow();
+ let indexInView = feedWindow.mView.getItemInViewIndex(parentFolder);
+ let open = indexInView != null;
+
+ if (aFolder.isServer) {
+ if (indexInView != null) {
+ // Existing account root folder in the view.
+ open = feedWindow.mView.getItemAtIndex(indexInView).open;
+ } else {
+ // Add the account root folder to the view.
+ feedWindow.mFeedContainers.push(
+ feedWindow.makeFolderObject(parentFolder, 0)
+ );
+ feedWindow.mView.mRowCount++;
+ feedWindow.mTree.view = feedWindow.mView;
+ feedWindow.mView.tree.scrollToRow(firstVisRow);
+ return;
+ }
+ }
+
+ // Rebuild the added folder's parent item in the tree row cache.
+ feedWindow.selectFolder(parentFolder, {
+ select: false,
+ open,
+ newFolder: parentFolder,
+ });
+
+ if (indexInView == null || !curSelItem) {
+ // Folder isn't in the tree view, no need to update the view.
+ return;
+ }
+
+ let parentIndex = feedWindow.mView.getParentIndex(indexInView);
+ if (parentIndex == feedWindow.mView.kRowIndexUndefined) {
+ // Root folder is its own parent.
+ parentIndex = indexInView;
+ }
+
+ if (open) {
+ // Close an open parent (or root) folder.
+ feedWindow.mView.toggle(parentIndex);
+ feedWindow.mView.toggleOpenState(parentIndex);
+ }
+
+ feedWindow.mView.tree.scrollToRow(firstVisRow);
+
+ if (curSelItem.container) {
+ feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open });
+ } else {
+ feedWindow.selectFeed(
+ { folder: curSelItem.parentFolder, url: curSelItem.url },
+ parentIndex
+ );
+ }
+ },
+
+ folderDeleted(aFolder) {
+ if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) {
+ return;
+ }
+
+ FeedUtils.log.debug("folderDeleted: folder - " + aFolder.name);
+ if (!this.feedWindow) {
+ return;
+ }
+
+ let feedWindow = this.feedWindow;
+ let curSelIndex = this.currentSelectedIndex;
+ let indexInView = feedWindow.mView.getItemInViewIndex(aFolder);
+ let open = indexInView != null;
+
+ // Delete the folder from the tree row cache.
+ feedWindow.selectFolder(aFolder, {
+ select: false,
+ open: false,
+ remove: true,
+ });
+
+ if (!open || curSelIndex < 0) {
+ // Folder isn't in the tree view, no need to update the view.
+ return;
+ }
+
+ let select =
+ indexInView == curSelIndex ||
+ feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex);
+
+ feedWindow.mView.removeItemAtIndex(indexInView, !select);
+ },
+
+ folderRenamed(aOrigFolder, aNewFolder) {
+ if (aNewFolder.server.type != "rss" || FeedUtils.isInTrash(aNewFolder)) {
+ return;
+ }
+
+ FeedUtils.log.debug(
+ "folderRenamed: old:new - " + aOrigFolder.name + ":" + aNewFolder.name
+ );
+ if (!this.feedWindow) {
+ return;
+ }
+
+ let feedWindow = this.feedWindow;
+ let curSelIndex = this.currentSelectedIndex;
+ let curSelItem = this.currentSelectedItem;
+ let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow();
+ let indexInView = feedWindow.mView.getItemInViewIndex(aOrigFolder);
+ let open = indexInView != null;
+
+ // Rebuild the renamed folder's item in the tree row cache.
+ feedWindow.selectFolder(aOrigFolder, {
+ select: false,
+ open,
+ newFolder: aNewFolder,
+ });
+
+ if (!open || !curSelItem) {
+ // Folder isn't in the tree view, no need to update the view.
+ return;
+ }
+
+ let select =
+ indexInView == curSelIndex ||
+ feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex);
+
+ let parentIndex = feedWindow.mView.getParentIndex(indexInView);
+ if (parentIndex == feedWindow.mView.kRowIndexUndefined) {
+ // Root folder is its own parent.
+ parentIndex = indexInView;
+ }
+
+ feedWindow.mView.toggle(parentIndex);
+ feedWindow.mView.toggleOpenState(parentIndex);
+ feedWindow.mView.tree.scrollToRow(firstVisRow);
+
+ if (curSelItem.container) {
+ if (curSelItem.folder == aOrigFolder) {
+ feedWindow.selectFolder(aNewFolder, { open: curSelItem.open });
+ } else if (select) {
+ feedWindow.mView.selection.select(indexInView);
+ } else {
+ feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open });
+ }
+ } else {
+ feedWindow.selectFeed(
+ { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url },
+ parentIndex
+ );
+ }
+ },
+
+ folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) {
+ if (aDestFolder.server.type != "rss") {
+ return;
+ }
+
+ FeedUtils.log.debug(
+ "folderMoveCopyCompleted: move:src:dest - " +
+ aMove +
+ ":" +
+ aSrcFolder.name +
+ ":" +
+ aDestFolder.name
+ );
+ if (!this.feedWindow) {
+ return;
+ }
+
+ let feedWindow = this.feedWindow;
+ let curSelIndex = this.currentSelectedIndex;
+ let curSelItem = this.currentSelectedItem;
+ let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow();
+ let indexInView = feedWindow.mView.getItemInViewIndex(aSrcFolder);
+ let destIndexInView = feedWindow.mView.getItemInViewIndex(aDestFolder);
+ let open = indexInView != null || destIndexInView != null;
+ let parentIndex = feedWindow.mView.getItemInViewIndex(
+ aDestFolder.parent || aDestFolder
+ );
+ let select =
+ indexInView == curSelIndex ||
+ feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex);
+
+ if (aMove) {
+ this.folderDeleted(aSrcFolder);
+ if (aDestFolder.getFlag(Ci.nsMsgFolderFlags.Trash)) {
+ return;
+ }
+ }
+
+ setTimeout(() => {
+ // State on disk needs to settle before a folder object can be rebuilt.
+ feedWindow.selectFolder(aDestFolder, {
+ select: false,
+ open: open || select,
+ newFolder: aDestFolder,
+ });
+
+ if (!open || !curSelItem) {
+ // Folder isn't in the tree view, no need to update the view.
+ return;
+ }
+
+ feedWindow.mView.toggle(parentIndex);
+ feedWindow.mView.toggleOpenState(parentIndex);
+ feedWindow.mView.tree.scrollToRow(firstVisRow);
+
+ if (curSelItem.container) {
+ if (curSelItem.folder == aSrcFolder || select) {
+ feedWindow.selectFolder(aDestFolder, { open: true });
+ } else {
+ feedWindow.selectFolder(curSelItem.folder, {
+ open: curSelItem.open,
+ });
+ }
+ } else {
+ feedWindow.selectFeed(
+ { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url },
+ null
+ );
+ }
+ }, 50);
+ },
+ },
+
+ /* *************************************************************** */
+ /* OPML Functions */
+ /* *************************************************************** */
+
+ get brandShortName() {
+ let brandBundle = document.getElementById("bundle_brand");
+ return brandBundle ? brandBundle.getString("brandShortName") : "";
+ },
+
+ /**
+ * Export feeds as opml file Save As filepicker function.
+ *
+ * @param {Boolean} aList - If true, exporting as list; if false (default)
+ * exporting feeds in folder structure - used for title.
+ * @returns {Promise} nsIFile or null.
+ */
+ opmlPickSaveAsFile(aList) {
+ let accountName = this.mRSSServer.rootFolder.prettyName;
+ let fileName = FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLExportDefaultFileName",
+ [this.brandShortName, accountName]
+ );
+ let title = aList
+ ? FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLExportTitleList",
+ [accountName]
+ )
+ : FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLExportTitleStruct",
+ [accountName]
+ );
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ fp.defaultString = fileName;
+ fp.defaultExtension = "opml";
+ if (
+ this.opmlLastSaveAsDir &&
+ this.opmlLastSaveAsDir instanceof Ci.nsIFile
+ ) {
+ fp.displayDirectory = this.opmlLastSaveAsDir;
+ }
+
+ let opmlFilterText = FeedUtils.strings.GetStringFromName(
+ "subscribe-OPMLExportOPMLFilesFilterText"
+ );
+ fp.appendFilter(opmlFilterText, "*.opml");
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.filterIndex = 0;
+ fp.init(window, title, Ci.nsIFilePicker.modeSave);
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ if (
+ (rv != Ci.nsIFilePicker.returnOK &&
+ rv != Ci.nsIFilePicker.returnReplace) ||
+ !fp.file
+ ) {
+ resolve(null);
+ return;
+ }
+
+ this.opmlLastSaveAsDir = fp.file.parent;
+ resolve(fp.file);
+ });
+ });
+ },
+
+ /**
+ * Import feeds opml file Open filepicker function.
+ *
+ * @returns {Promise} [{nsIFile} file, {String} fileUrl] or null.
+ */
+ opmlPickOpenFile() {
+ let title = FeedUtils.strings.GetStringFromName(
+ "subscribe-OPMLImportTitle"
+ );
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ fp.defaultString = "";
+ if (this.opmlLastOpenDir && this.opmlLastOpenDir instanceof Ci.nsIFile) {
+ fp.displayDirectory = this.opmlLastOpenDir;
+ }
+
+ let opmlFilterText = FeedUtils.strings.GetStringFromName(
+ "subscribe-OPMLExportOPMLFilesFilterText"
+ );
+ fp.appendFilter(opmlFilterText, "*.opml");
+ fp.appendFilters(Ci.nsIFilePicker.filterXML);
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.init(window, title, Ci.nsIFilePicker.modeOpen);
+
+ return new Promise(resolve => {
+ fp.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !fp.file) {
+ resolve(null);
+ return;
+ }
+
+ this.opmlLastOpenDir = fp.file.parent;
+ resolve([fp.file, fp.fileURL.spec]);
+ });
+ });
+ },
+
+ async exportOPML(aEvent) {
+ // Account folder must be selected.
+ let item = this.mView.currentItem;
+ if (!item || !item.folder || !item.folder.isServer) {
+ return;
+ }
+
+ this.mRSSServer = item.folder.server;
+ let rootFolder = this.mRSSServer.rootFolder;
+ let exportAsList = aEvent.ctrlKey;
+ let SPACES2 = " ";
+ let SPACES4 = " ";
+
+ if (this.mRSSServer.rootFolder.hasSubFolders) {
+ let opmlDoc = document.implementation.createDocument("", "opml", null);
+ let opmlRoot = opmlDoc.documentElement;
+ opmlRoot.setAttribute("version", "1.0");
+ opmlRoot.setAttribute("xmlns:fz", "urn:forumzilla:");
+
+ this.generatePPSpace(opmlRoot, SPACES2);
+
+ // Make the <head> element.
+ let head = opmlDoc.createElement("head");
+ this.generatePPSpace(head, SPACES4);
+ let titleText = FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLExportFileDialogTitle",
+ [this.brandShortName, rootFolder.prettyName]
+ );
+ let title = opmlDoc.createElement("title");
+ title.appendChild(opmlDoc.createTextNode(titleText));
+ head.appendChild(title);
+ this.generatePPSpace(head, SPACES4);
+ let dt = opmlDoc.createElement("dateCreated");
+ dt.appendChild(opmlDoc.createTextNode(new Date().toUTCString()));
+ head.appendChild(dt);
+ this.generatePPSpace(head, SPACES2);
+ opmlRoot.appendChild(head);
+
+ this.generatePPSpace(opmlRoot, SPACES2);
+
+ // Add <outline>s to the <body>.
+ let body = opmlDoc.createElement("body");
+ if (exportAsList) {
+ this.generateOutlineList(rootFolder, body, SPACES4.length + 2);
+ } else {
+ this.generateOutlineStruct(rootFolder, body, SPACES4.length);
+ }
+
+ this.generatePPSpace(body, SPACES2);
+
+ if (!body.childElementCount) {
+ // No folders/feeds.
+ return;
+ }
+
+ opmlRoot.appendChild(body);
+ this.generatePPSpace(opmlRoot, "");
+
+ // Get file to save from filepicker.
+ let saveAsFile = await this.opmlPickSaveAsFile(exportAsList);
+ if (!saveAsFile) {
+ return;
+ }
+
+ let fos = FileUtils.openSafeFileOutputStream(saveAsFile);
+ let serializer = new XMLSerializer();
+ serializer.serializeToStream(opmlDoc, fos, "utf-8");
+ FileUtils.closeSafeFileOutputStream(fos);
+
+ let statusReport = FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLExportDone",
+ [saveAsFile.path]
+ );
+ this.updateStatusItem("statusText", statusReport);
+ FeedUtils.log.info("exportOPML: " + statusReport);
+ }
+ },
+
+ generatePPSpace(aNode, indentString) {
+ aNode.appendChild(aNode.ownerDocument.createTextNode("\n"));
+ aNode.appendChild(aNode.ownerDocument.createTextNode(indentString));
+ },
+
+ generateOutlineList(baseFolder, parent, indentLevel) {
+ // Pretty printing.
+ let indentString = " ".repeat(indentLevel - 2);
+
+ let feedOutline;
+ for (let folder of baseFolder.subFolders) {
+ FeedUtils.log.debug(
+ "generateOutlineList: folder - " + folder.filePath.path
+ );
+ if (
+ !(folder instanceof Ci.nsIMsgFolder) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Trash) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ continue;
+ }
+
+ FeedUtils.log.debug(
+ "generateOutlineList: CONTINUE folderName - " + folder.name
+ );
+
+ if (folder.hasSubFolders) {
+ FeedUtils.log.debug(
+ "generateOutlineList: has subfolders - " + folder.name
+ );
+ // Recurse.
+ this.generateOutlineList(folder, parent, indentLevel);
+ }
+
+ // Add outline elements with xmlUrls.
+ let feeds = this.getFeedsInFolder(folder);
+ for (let feed of feeds) {
+ FeedUtils.log.debug(
+ "generateOutlineList: folder has FEED url - " +
+ folder.name +
+ " : " +
+ feed.url
+ );
+ feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument);
+ this.generatePPSpace(parent, indentString);
+ parent.appendChild(feedOutline);
+ }
+ }
+ },
+
+ generateOutlineStruct(baseFolder, parent, indentLevel) {
+ // Pretty printing.
+ function indentString(len) {
+ return " ".repeat(len - 2);
+ }
+
+ let folderOutline, feedOutline;
+ for (let folder of baseFolder.subFolders) {
+ FeedUtils.log.debug(
+ "generateOutlineStruct: folder - " + folder.filePath.path
+ );
+ if (
+ !(folder instanceof Ci.nsIMsgFolder) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Trash) ||
+ folder.getFlag(Ci.nsMsgFolderFlags.Virtual)
+ ) {
+ continue;
+ }
+
+ FeedUtils.log.debug(
+ "generateOutlineStruct: CONTINUE folderName - " + folder.name
+ );
+
+ // Make a folder outline element.
+ folderOutline = parent.ownerDocument.createElement("outline");
+ folderOutline.setAttribute("title", folder.prettyName);
+ this.generatePPSpace(parent, indentString(indentLevel + 2));
+
+ if (folder.hasSubFolders) {
+ FeedUtils.log.debug(
+ "generateOutlineStruct: has subfolders - " + folder.name
+ );
+ // Recurse.
+ this.generateOutlineStruct(folder, folderOutline, indentLevel + 2);
+ }
+
+ let feeds = this.getFeedsInFolder(folder);
+ for (let feed of feeds) {
+ // Add feed outline elements with xmlUrls.
+ FeedUtils.log.debug(
+ "generateOutlineStruct: folder has FEED url - " +
+ folder.name +
+ " : " +
+ feed.url
+ );
+ feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument);
+ this.generatePPSpace(folderOutline, indentString(indentLevel + 4));
+ folderOutline.appendChild(feedOutline);
+ }
+
+ parent.appendChild(folderOutline);
+ }
+ },
+
+ exportOPMLOutline(aFeed, aDoc) {
+ let outRv = aDoc.createElement("outline");
+ outRv.setAttribute("type", "rss");
+ outRv.setAttribute("title", aFeed.title);
+ outRv.setAttribute("text", aFeed.title);
+ outRv.setAttribute("version", "RSS");
+ outRv.setAttribute("fz:quickMode", aFeed.quickMode);
+ outRv.setAttribute("fz:options", JSON.stringify(aFeed.options));
+ outRv.setAttribute("xmlUrl", aFeed.url);
+ outRv.setAttribute("htmlUrl", aFeed.link);
+ return outRv;
+ },
+
+ async importOPML() {
+ // Account folder must be selected in subscribe dialog.
+ let item = this.mView ? this.mView.currentItem : null;
+ if (!item || !item.folder || !item.folder.isServer) {
+ return;
+ }
+
+ let server = item.folder.server;
+ // Get file and file url to open from filepicker.
+ let [openFile, openFileUrl] = await this.opmlPickOpenFile();
+
+ this.mActionMode = this.kImportingOPML;
+ this.updateButtons(null);
+ this.selectFolder(item.folder, { select: false, open: true });
+ let statusReport = FeedUtils.strings.GetStringFromName("subscribe-loading");
+ this.updateStatusItem("statusText", statusReport);
+ // If there were a getElementsByAttribute in html, we could go determined...
+ this.updateStatusItem("progressMeter", "?");
+
+ if (
+ !(await this.importOPMLFile(
+ openFile,
+ openFileUrl,
+ server,
+ this.importOPMLFinished
+ ))
+ ) {
+ this.mActionMode = null;
+ this.updateButtons(item);
+ this.clearStatusInfo();
+ }
+ },
+
+ /**
+ * Import opml file into a feed account. Used by the Subscribe dialog and
+ * the Import wizard.
+ *
+ * @param {nsIFile} aFile - The opml file.
+ * @param {string} aFileUrl - The opml file url.
+ * @param {nsIMsgIncomingServer} aServer - The account server.
+ * @param {Function} aCallback - Callback function.
+ *
+ * @returns {Boolean} - false if error.
+ */
+ async importOPMLFile(aFile, aFileUrl, aServer, aCallback) {
+ if (aServer && aServer instanceof Ci.nsIMsgIncomingServer) {
+ this.mRSSServer = aServer;
+ }
+
+ if (!aFile || !aFileUrl || !this.mRSSServer) {
+ return false;
+ }
+
+ let opmlDom, statusReport;
+ FeedUtils.log.debug(
+ "importOPMLFile: fileName:fileUrl - " + aFile.leafName + ":" + aFileUrl
+ );
+ let request = new Request(aFileUrl);
+ await fetch(request)
+ .then(function (response) {
+ if (!response.ok) {
+ // If the OPML file is not readable/accessible.
+ statusReport = FeedUtils.strings.GetStringFromName(
+ "subscribe-errorOpeningFile"
+ );
+ return null;
+ }
+
+ return response.text();
+ })
+ .then(function (responseText) {
+ if (responseText != null) {
+ opmlDom = new DOMParser().parseFromString(
+ responseText,
+ "application/xml"
+ );
+ if (
+ !XMLDocument.isInstance(opmlDom) ||
+ opmlDom.documentElement.namespaceURI ==
+ FeedUtils.MOZ_PARSERERROR_NS ||
+ opmlDom.documentElement.tagName != "opml" ||
+ !(
+ opmlDom.querySelector("body") &&
+ opmlDom.querySelector("body").childElementCount
+ )
+ ) {
+ // If the OPML file is invalid or empty.
+ statusReport = FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLImportInvalidFile",
+ [aFile.leafName]
+ );
+ }
+ }
+ })
+ .catch(function (error) {
+ statusReport = FeedUtils.strings.GetStringFromName(
+ "subscribe-errorOpeningFile"
+ );
+ FeedUtils.log.error("importOPMLFile: error - " + error.message);
+ });
+
+ if (statusReport) {
+ FeedUtils.log.error("importOPMLFile: status - " + statusReport);
+ Services.prompt.alert(window, null, statusReport);
+ return false;
+ }
+
+ let body = opmlDom.querySelector("body");
+ this.importOPMLOutlines(body, this.mRSSServer, aCallback);
+ return true;
+ },
+
+ importOPMLOutlines(aBody, aRSSServer, aCallback) {
+ let win = this;
+ let rssServer = aRSSServer;
+ let callback = aCallback;
+ let outline, feedFolder;
+ let badTag = false;
+ let firstFeedInFolderQuickMode = null;
+ let lastFolder;
+ let feedsAdded = 0;
+ let rssOutlines = 0;
+
+ function processor(aParentNode, aParentFolder) {
+ FeedUtils.log.trace(
+ "importOPMLOutlines: PROCESSOR tag:name:children - " +
+ aParentNode.tagName +
+ ":" +
+ aParentNode.getAttribute("text") +
+ ":" +
+ aParentNode.childElementCount
+ );
+ while (true) {
+ if (aParentNode.tagName == "body" && !aParentNode.childElementCount) {
+ // Finished.
+ let statusReport = win.importOPMLStatus(feedsAdded, rssOutlines);
+ callback(statusReport, lastFolder, win);
+ return;
+ }
+
+ outline = aParentNode.firstElementChild;
+ if (outline.tagName != "outline") {
+ FeedUtils.log.info(
+ "importOPMLOutlines: skipping, node is not an " +
+ "<outline> - <" +
+ outline.tagName +
+ ">"
+ );
+ badTag = true;
+ break;
+ }
+
+ let outlineName =
+ outline.getAttribute("text") ||
+ outline.getAttribute("title") ||
+ outline.getAttribute("xmlUrl");
+ let feedUrl, folder;
+
+ if (outline.getAttribute("type") == "rss") {
+ // A feed outline.
+ feedUrl =
+ outline.getAttribute("xmlUrl") || outline.getAttribute("url");
+ if (!feedUrl) {
+ FeedUtils.log.info(
+ "importOPMLOutlines: skipping, type=rss <outline> " +
+ "has no url - " +
+ outlineName
+ );
+ break;
+ }
+
+ rssOutlines++;
+ feedFolder = aParentFolder;
+
+ if (FeedUtils.feedAlreadyExists(feedUrl, rssServer)) {
+ FeedUtils.log.info(
+ "importOPMLOutlines: feed already subscribed in account " +
+ rssServer.prettyName +
+ ", url - " +
+ feedUrl
+ );
+ break;
+ }
+
+ if (
+ aParentNode.tagName == "outline" &&
+ aParentNode.getAttribute("type") != "rss"
+ ) {
+ // Parent is a folder, already created.
+ folder = feedFolder;
+ } else {
+ // Parent is not a folder outline, likely the <body> in a flat list.
+ // Create feed's folder with feed's name and account rootFolder as
+ // parent of feed's folder.
+ // NOTE: Assume a type=rss outline must be a leaf and is not a
+ // direct parent of another type=rss outline; such a structure
+ // may lead to unintended nesting and inaccurate counts.
+ folder = rssServer.rootFolder;
+ }
+
+ // Create the feed.
+ let quickMode = outline.hasAttribute("fz:quickMode")
+ ? outline.getAttribute("fz:quickMode") == "true"
+ : rssServer.getBoolValue("quickMode");
+ let options = outline.getAttribute("fz:options");
+ options = options ? JSON.parse(options) : null;
+
+ if (firstFeedInFolderQuickMode === null) {
+ // The summary/web page pref applies to all feeds in a folder,
+ // though it is a property of an individual feed. This can be
+ // set (and is obvious) in the subscribe dialog; ensure import
+ // doesn't leave mismatches if mismatched in the opml file.
+ firstFeedInFolderQuickMode = quickMode;
+ } else {
+ quickMode = firstFeedInFolderQuickMode;
+ }
+
+ let feedProperties = {
+ feedName: outlineName,
+ feedLocation: feedUrl,
+ feedFolder: folder,
+ quickMode,
+ options,
+ };
+
+ FeedUtils.log.info(
+ "importOPMLOutlines: importing feed: name, url - " +
+ outlineName +
+ ", " +
+ feedUrl
+ );
+
+ let feed = win.storeFeed(feedProperties);
+ if (outline.hasAttribute("htmlUrl")) {
+ feed.link = outline.getAttribute("htmlUrl");
+ }
+
+ feed.createFolder();
+ if (!feed.folder) {
+ // Non success. Remove intermediate traces from the feeds database.
+ if (feed && feed.url && feed.server) {
+ FeedUtils.deleteFeed(feed);
+ }
+
+ FeedUtils.log.info(
+ "importOPMLOutlines: skipping, error creating folder - '" +
+ feed.folderName +
+ "' from outlineName - '" +
+ outlineName +
+ "' in parent folder " +
+ aParentFolder.filePath.path
+ );
+ badTag = true;
+ break;
+ }
+
+ // Add the feed to the databases.
+ FeedUtils.addFeed(feed);
+ // Feed correctly added.
+ feedsAdded++;
+ lastFolder = feed.folder;
+ } else {
+ // A folder outline. If a folder exists in the account structure at
+ // the same level as in the opml structure, feeds are placed into the
+ // existing folder.
+ let folderName = outlineName;
+ try {
+ feedFolder = aParentFolder.getChildNamed(folderName);
+ } catch (ex) {
+ // Folder not found, create it.
+ FeedUtils.log.info(
+ "importOPMLOutlines: creating folder - '" +
+ folderName +
+ "' from outlineName - '" +
+ outlineName +
+ "' in parent folder " +
+ aParentFolder.filePath.path
+ );
+ firstFeedInFolderQuickMode = null;
+ try {
+ feedFolder = aParentFolder
+ .QueryInterface(Ci.nsIMsgLocalMailFolder)
+ .createLocalSubfolder(folderName);
+ } catch (ex) {
+ // An error creating. Skip it.
+ FeedUtils.log.info(
+ "importOPMLOutlines: skipping, error creating folder - '" +
+ folderName +
+ "' from outlineName - '" +
+ outlineName +
+ "' in parent folder " +
+ aParentFolder.filePath.path
+ );
+ let xfolder = aParentFolder.getChildNamed(folderName);
+ aParentFolder.propagateDelete(xfolder, true);
+ badTag = true;
+ break;
+ }
+ }
+ }
+
+ break;
+ }
+
+ if (!outline.childElementCount || badTag) {
+ // Remove leaf nodes that are processed or bad tags from the opml dom,
+ // and go back to reparse. This method lets us use setTimeout to
+ // prevent UI hang, in situations of both deep and shallow trees.
+ // A yield/generator.next() method is fine for shallow trees, but not
+ // the true recursion required for deeper trees; both the shallow loop
+ // and the recurse should give it up.
+ outline.remove();
+ badTag = false;
+ outline = aBody;
+ feedFolder = rssServer.rootFolder;
+ }
+
+ setTimeout(() => {
+ processor(outline, feedFolder);
+ }, 0);
+ }
+
+ processor(aBody, rssServer.rootFolder);
+ },
+
+ importOPMLStatus(aFeedsAdded, aRssOutlines, aFolderOutlines) {
+ let statusReport;
+ if (aRssOutlines > aFeedsAdded) {
+ statusReport = FeedUtils.strings.formatStringFromName(
+ "subscribe-OPMLImportStatus",
+ [
+ PluralForm.get(
+ aFeedsAdded,
+ FeedUtils.strings.GetStringFromName(
+ "subscribe-OPMLImportUniqueFeeds"
+ )
+ ).replace("#1", aFeedsAdded),
+ PluralForm.get(
+ aRssOutlines,
+ FeedUtils.strings.GetStringFromName(
+ "subscribe-OPMLImportFoundFeeds"
+ )
+ ).replace("#1", aRssOutlines),
+ ],
+ 2
+ );
+ } else {
+ statusReport = PluralForm.get(
+ aFeedsAdded,
+ FeedUtils.strings.GetStringFromName("subscribe-OPMLImportFeedCount")
+ ).replace("#1", aFeedsAdded);
+ }
+
+ return statusReport;
+ },
+
+ importOPMLFinished(aStatusReport, aLastFolder, aWin) {
+ if (aLastFolder) {
+ aWin.selectFolder(aLastFolder, { select: false, newFolder: aLastFolder });
+ aWin.selectFolder(aLastFolder.parent);
+ }
+ aWin.mActionMode = null;
+ aWin.updateButtons(aWin.mView.currentItem);
+ aWin.clearStatusInfo();
+ aWin.updateStatusItem("statusText", aStatusReport);
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml b/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml
new file mode 100644
index 0000000000..d2f8cba2bb
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml
@@ -0,0 +1,373 @@
+<?xml version="1.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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderPane.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger-newsblog/skin/feed-subscriptions.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % feedDTD SYSTEM "chrome://messenger-newsblog/locale/feed-subscriptions.dtd">
+%feedDTD;
+<!ENTITY % newsblogDTD SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd">
+%newsblogDTD; ]>
+
+<html
+ id="feedSubscriptions"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+ windowtype="Mail:News-BlogSubscriptions"
+ persist="width height screenX screenY sizemode"
+ lightweightthemes="true"
+>
+ <head>
+ <title>&feedSubscriptions.label;</title>
+ <link rel="localization" href="security/certificates/certManager.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/specialTabs.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/dialogShadowDom.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger-newsblog/content/feed-subscriptions.js"
+ ></script>
+ <script>
+ window.addEventListener("load", event => {
+ FeedSubscriptions.onLoad();
+ });
+ window.addEventListener("keypress", event => {
+ FeedSubscriptions.onKeyPress(event);
+ });
+ window.addEventListener("mousedown", event => {
+ FeedSubscriptions.onMouseDown(event);
+ });
+ </script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <dialog
+ id="subscriptionsDialog"
+ buttons="accept"
+ buttonlabelaccept="&button.close.label;"
+ >
+ <keyset id="extensionsKeys">
+ <key
+ id="key_close"
+ key="&cmd.close.commandKey;"
+ modifiers="accel"
+ oncommand="window.close();"
+ />
+ <key id="key_close2" keycode="VK_ESCAPE" oncommand="window.close();" />
+ </keyset>
+
+ <stringbundle
+ id="bundle_newsblog"
+ src="chrome://messenger-newsblog/locale/newsblog.properties"
+ />
+ <stringbundle
+ id="bundle_brand"
+ src="chrome://branding/locale/brand.properties"
+ />
+
+ <vbox flex="1" id="contentPane">
+ <hbox pack="end">
+ <label
+ is="text-link"
+ id="learnMore"
+ crop="end"
+ value="&learnMore.label;"
+ href="https://support.mozilla.org/kb/how-subscribe-news-feeds-and-blogs"
+ />
+ </hbox>
+
+ <tree
+ id="rssSubscriptionsList"
+ treelines="true"
+ flex="1"
+ hidecolumnpicker="true"
+ onselect="FeedSubscriptions.onSelect();"
+ seltype="single"
+ >
+ <treecols>
+ <treecol id="folderNameCol" primary="true" hideheader="true" />
+ </treecols>
+ <treechildren
+ id="subscriptionChildren"
+ ondragstart="FeedSubscriptions.onDragStart(event);"
+ ondragover="FeedSubscriptions.onDragOver(event);"
+ />
+ </tree>
+
+ <hbox id="rssFeedInfoBox">
+ <vbox flex="1">
+ <hbox flex="1">
+ <vbox pack="end">
+ <hbox flex="1" align="center">
+ <label
+ id="nameLabel"
+ accesskey="&feedTitle.accesskey;"
+ control="nameValue"
+ value="&feedTitle.label;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="locationLabel"
+ accesskey="&feedLocation.accesskey;"
+ control="locationValue"
+ value="&feedLocation.label;"
+ />
+ </hbox>
+ <hbox flex="1" align="center">
+ <label
+ id="feedFolderLabel"
+ value="&feedFolder.label;"
+ accesskey="&feedFolder.accesskey;"
+ control="selectFolder"
+ />
+ </hbox>
+ </vbox>
+ <vbox flex="1">
+ <html:input
+ id="nameValue"
+ type="text"
+ class="input-inline"
+ aria-labelledby="nameLabel"
+ onchange="FeedSubscriptions.setPrefs(this);"
+ />
+ <hbox class="input-container">
+ <html:input
+ id="locationValue"
+ type="url"
+ class="uri-element input-inline"
+ aria-labelledby="locationLabel"
+ placeholder="&feedLocation2.placeholder;"
+ onchange="FeedSubscriptions.setPrefs(this);"
+ onfocus="FeedSubscriptions.onFocusChange();"
+ onblur="FeedSubscriptions.onFocusChange();"
+ />
+ <hbox align="center">
+ <label
+ is="text-link"
+ id="locationValidate"
+ collapsed="true"
+ crop="end"
+ value="&locationValidate.label;"
+ onclick="FeedSubscriptions.checkValidation(event);"
+ />
+ </hbox>
+ </hbox>
+ <hbox class="input-container">
+ <menulist
+ id="selectFolder"
+ flex="1"
+ class="folderMenuItem"
+ hidden="true"
+ >
+ <menupopup
+ is="folder-menupopup"
+ id="selectFolderPopup"
+ class="menulist-menupopup"
+ mode="feeds"
+ showFileHereLabel="true"
+ showAccountsFileHere="true"
+ oncommand="FeedSubscriptions.setNewFolder(event);"
+ />
+ </menulist>
+ <html:input
+ id="selectFolderValue"
+ class="input-inline"
+ readonly="readonly"
+ aria-labelledby="feedFolderLabel"
+ onkeypress="FeedSubscriptions.onClickSelectFolderValue(event);"
+ onclick="FeedSubscriptions.onClickSelectFolderValue(event);"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+
+ <hbox align="center">
+ <checkbox
+ id="updateEnabled"
+ label="&biffStart.label;"
+ accesskey="&biffStart.accesskey;"
+ oncommand="FeedSubscriptions.setPrefs(this);"
+ />
+ <html:input
+ id="updateValue"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ aria-labelledby="updateEnabled updateValue biffMinutes biffDays recommendedUnits recommendedUnitsVal"
+ oninput="FeedSubscriptions.setPrefs(this);"
+ onchange="FeedSubscriptions.setPrefs(this);"
+ />
+ <radiogroup
+ id="biffUnits"
+ orient="horizontal"
+ oncommand="FeedSubscriptions.setPrefs(this);"
+ >
+ <radio
+ id="biffMinutes"
+ value="min"
+ label="&biffMinutes.label;"
+ accesskey="&biffMinutes.accesskey;"
+ />
+ <radio
+ id="biffDays"
+ value="d"
+ label="&biffDays.label;"
+ accesskey="&biffDays.accesskey;"
+ />
+ </radiogroup>
+ <hbox id="recommendedBox">
+ <label
+ id="recommendedUnits"
+ value="&recommendedUnits.label;"
+ hidden="true"
+ control="updateMinutes"
+ />
+ <label
+ id="recommendedUnitsVal"
+ value=""
+ hidden="true"
+ control="updateMinutes"
+ />
+ </hbox>
+ </hbox>
+ <checkbox
+ id="quickMode"
+ accesskey="&quickMode.accesskey;"
+ label="&quickMode.label;"
+ oncommand="FeedSubscriptions.setSummary(this.checked);"
+ />
+ <checkbox
+ id="autotagEnable"
+ accesskey="&autotagEnable.accesskey;"
+ label="&autotagEnable.label;"
+ oncommand="FeedSubscriptions.setPrefs(this);"
+ />
+ <hbox class="input-container">
+ <checkbox
+ id="autotagUsePrefix"
+ class="indent"
+ accesskey="&autotagUsePrefix.accesskey;"
+ label="&autotagUsePrefix.label;"
+ oncommand="FeedSubscriptions.setPrefs(this);"
+ />
+ <html:input
+ id="autotagPrefix"
+ type="text"
+ class="input-inline"
+ placeholder="&autoTagPrefix.placeholder;"
+ onchange="FeedSubscriptions.setPrefs(this);"
+ />
+ </hbox>
+ <separator class="thin" />
+ </vbox>
+ </hbox>
+
+ <hbox id="statusContainerBox" align="center">
+ <vbox flex="1">
+ <description id="statusText" />
+ </vbox>
+ <spacer flex="1" />
+ <label
+ id="validationText"
+ collapsed="true"
+ class="text-link"
+ crop="end"
+ value="&validateText.label;"
+ onclick="FeedSubscriptions.checkValidation(event);"
+ />
+ <button
+ id="addCertException"
+ collapsed="true"
+ data-l10n-id="certmgr-add-exception"
+ oncommand="FeedSubscriptions.addCertExceptionDialog();"
+ />
+ <html:progress
+ id="progressMeter"
+ hidden="hidden"
+ value="0"
+ max="100"
+ />
+ </hbox>
+
+ <hbox align="end">
+ <hbox class="actionButtons" flex="1">
+ <button
+ id="addFeed"
+ hidden="true"
+ disabled="true"
+ label="&button.addFeed.label;"
+ accesskey="&button.addFeed.accesskey;"
+ oncommand="FeedSubscriptions.addFeed();"
+ />
+
+ <button
+ id="updateFeed"
+ hidden="true"
+ disabled="true"
+ label="&button.verifyFeed.label;"
+ accesskey="&button.verifyFeed.accesskey;"
+ verifylabel="&button.verifyFeed.label;"
+ verifyaccesskey="&button.verifyFeed.accesskey;"
+ updatelabel="&button.updateFeed.label;"
+ updateaccesskey="&button.updateFeed.accesskey;"
+ oncommand="FeedSubscriptions.updateFeed();"
+ />
+
+ <button
+ id="removeFeed"
+ hidden="true"
+ label="&button.removeFeed.label;"
+ accesskey="&button.removeFeed.accesskey;"
+ oncommand="FeedSubscriptions.removeFeed(true);"
+ />
+
+ <spacer flex="1" />
+
+ <button
+ id="importOPML"
+ hidden="true"
+ label="&button.importOPML.label;"
+ accesskey="&button.importOPML.accesskey;"
+ oncommand="FeedSubscriptions.importOPML();"
+ />
+
+ <button
+ id="exportOPML"
+ hidden="true"
+ label="&button.exportOPML.label;"
+ accesskey="&button.exportOPML.accesskey;"
+ tooltiptext="&button.exportOPML.tooltip;"
+ oncommand="FeedSubscriptions.exportOPML(event);"
+ />
+ </hbox>
+ </hbox>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/mailnews/extensions/newsblog/feedAccountWizard.js b/comm/mailnews/extensions/newsblog/feedAccountWizard.js
new file mode 100644
index 0000000000..686caff3a3
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/feedAccountWizard.js
@@ -0,0 +1,56 @@
+/* -*- Mode: JavaScript; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* 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/. */
+
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+
+window.addEventListener("DOMContentLoaded", () => {
+ FeedAccountWizard.onLoad();
+});
+
+/** Feed account standalone wizard functions. */
+var FeedAccountWizard = {
+ accountName: "",
+
+ onLoad() {
+ document
+ .querySelector("wizard")
+ .addEventListener("wizardfinish", this.onFinish.bind(this));
+ let accountSetupPage = document.getElementById("accountsetuppage");
+ accountSetupPage.addEventListener(
+ "pageshow",
+ this.accountSetupPageValidate.bind(this)
+ );
+ accountSetupPage.addEventListener(
+ "pagehide",
+ this.accountSetupPageValidate.bind(this)
+ );
+ let donePage = document.getElementById("done");
+ donePage.addEventListener("pageshow", this.donePageInit.bind(this));
+ },
+
+ accountSetupPageValidate() {
+ this.accountName = document.getElementById("prettyName").value.trim();
+ document.querySelector("wizard").canAdvance = this.accountName;
+ },
+
+ donePageInit() {
+ document.getElementById("account.name.text").value = this.accountName;
+ },
+
+ onFinish() {
+ let account = FeedUtils.createRssAccount(this.accountName);
+ let openerWindow = window.opener.top;
+ // The following block is the same as in AccountWizard.js.
+ if ("selectServer" in openerWindow) {
+ // Opened from Account Settings.
+ openerWindow.selectServer(account.incomingServer);
+ }
+
+ // Post a message to the main window on successful account setup.
+ openerWindow.postMessage("account-created", "*");
+
+ window.close();
+ },
+};
diff --git a/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml b/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml
new file mode 100644
index 0000000000..ef1be03dd7
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml
@@ -0,0 +1,95 @@
+<?xml version="1.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/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/accountWizard.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % accountDTD SYSTEM "chrome://messenger/locale/AccountWizard.dtd">
+%accountDTD;
+<!ENTITY % newsblogDTD SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd" >
+%newsblogDTD;
+<!ENTITY % imDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd" >
+%imDTD; ]>
+
+<html
+ id="FeedAccountWizard"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&feedWindowTitle.label;</title>
+ <link rel="localization" href="toolkit/global/wizard.ftl" />
+ <script
+ defer="defer"
+ src="chrome://messenger/content/globalOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://global/content/editMenuOverlay.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger-newsblog/content/feedAccountWizard.js"
+ ></script>
+ </head>
+ <html:body
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ >
+ <wizard>
+ <!-- Account setup page : User gets a choice to enter a name for the account -->
+ <!-- Defaults : Feed account name -> default string -->
+ <wizardpage
+ id="accountsetuppage"
+ pageid="accountsetuppage"
+ label="&accnameTitle.label;"
+ >
+ <vbox flex="1">
+ <description>&accnameDesc.label;</description>
+ <separator class="thin" />
+ <hbox align="center" class="input-container">
+ <label
+ id="prettyNameLabel"
+ class="label"
+ value="&accnameLabel.label;"
+ accesskey="&accnameLabel.accesskey;"
+ control="prettyName"
+ />
+ <html:input
+ id="prettyName"
+ type="text"
+ class="input-inline"
+ value="&feeds.accountName;"
+ aria-labelledby="prettyNameLabel"
+ oninput="FeedAccountWizard.accountSetupPageValidate();"
+ />
+ </hbox>
+ </vbox>
+ </wizardpage>
+
+ <!-- Done page : Summarizes information collected to create a feed account -->
+ <wizardpage id="done" pageid="done" label="&accountSummaryTitle.label;">
+ <vbox flex="1">
+ <description>&accountSummaryInfo.label;</description>
+ <separator class="thin" />
+ <hbox id="account.name" align="center">
+ <label
+ id="account.name.label"
+ class="label"
+ value="&accnameLabel.label;"
+ />
+ <label id="account.name.text" class="label" />
+ </hbox>
+ <separator />
+ <spacer flex="1" />
+ </vbox>
+ </wizardpage>
+ </wizard>
+ </html:body>
+</html>
diff --git a/comm/mailnews/extensions/newsblog/jar.mn b/comm/mailnews/extensions/newsblog/jar.mn
new file mode 100644
index 0000000000..46d1a700cd
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/jar.mn
@@ -0,0 +1,13 @@
+# 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/.
+
+newsblog.jar:
+% content messenger-newsblog %content/messenger-newsblog/
+ content/messenger-newsblog/newsblogOverlay.js (newsblogOverlay.js)
+ content/messenger-newsblog/feed-subscriptions.js (feed-subscriptions.js)
+ content/messenger-newsblog/feed-subscriptions.xhtml (feed-subscriptions.xhtml)
+ content/messenger-newsblog/am-newsblog.js (am-newsblog.js)
+ content/messenger-newsblog/am-newsblog.xhtml (am-newsblog.xhtml)
+ content/messenger-newsblog/feedAccountWizard.js (feedAccountWizard.js)
+ content/messenger-newsblog/feedAccountWizard.xhtml (feedAccountWizard.xhtml)
diff --git a/comm/mailnews/extensions/newsblog/moz.build b/comm/mailnews/extensions/newsblog/moz.build
new file mode 100644
index 0000000000..f4a9d0dd9f
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/moz.build
@@ -0,0 +1,25 @@
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES += [
+ "Feed.jsm",
+ "FeedItem.jsm",
+ "FeedParser.jsm",
+ "FeedUtils.jsm",
+ "NewsBlog.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.ini",
+]
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
diff --git a/comm/mailnews/extensions/newsblog/newsblogOverlay.js b/comm/mailnews/extensions/newsblog/newsblogOverlay.js
new file mode 100644
index 0000000000..9145ad0dab
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/newsblogOverlay.js
@@ -0,0 +1,416 @@
+/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * 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/. */
+
+/* globals ReloadMessage, getMessagePaneBrowser, openContentTab,
+ GetNumSelectedMessages, gMessageNotificationBar */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+var { MailE10SUtils } = ChromeUtils.import(
+ "resource:///modules/MailE10SUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm",
+});
+
+// This global is for SeaMonkey compatibility.
+var gShowFeedSummary;
+
+var FeedMessageHandler = {
+ gShowSummary: true,
+ gToggle: false,
+ kSelectOverrideWebPage: 0,
+ kSelectOverrideSummary: 1,
+ kSelectFeedDefault: 2,
+ kOpenWebPage: 0,
+ kOpenSummary: 1,
+ kOpenToggleInMessagePane: 2,
+ kOpenLoadInBrowser: 3,
+
+ FeedAccountTypes: ["rss"],
+
+ /**
+ * How to load message on threadpane select.
+ */
+ get onSelectPref() {
+ return Services.prefs.getIntPref("rss.show.summary");
+ },
+
+ set onSelectPref(val) {
+ Services.prefs.setIntPref("rss.show.summary", val);
+ ReloadMessage();
+ },
+
+ /**
+ * Load web page on threadpane select.
+ */
+ get loadWebPageOnSelectPref() {
+ return Services.prefs.getIntPref("rss.message.loadWebPageOnSelect");
+ },
+
+ /**
+ * How to load message on open (enter/dbl click in threadpane, contextmenu).
+ */
+ get onOpenPref() {
+ return Services.prefs.getIntPref("rss.show.content-base");
+ },
+
+ set onOpenPref(val) {
+ Services.prefs.setIntPref("rss.show.content-base", val);
+ },
+
+ /**
+ * Determine whether to show a feed message summary or load a web page in the
+ * message pane.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message.
+ * @param {boolean} aToggle - true if in toggle mode, false otherwise.
+ *
+ * @returns {Boolean} - true if summary is to be displayed, false if web page.
+ */
+ shouldShowSummary(aMsgHdr, aToggle) {
+ // Not a feed message, always show summary (the message).
+ if (!FeedUtils.isFeedMessage(aMsgHdr)) {
+ return true;
+ }
+
+ // Notified of a summary reload when toggling, reset toggle and return.
+ if (!aToggle && this.gToggle) {
+ return !(this.gToggle = false);
+ }
+
+ let showSummary = true;
+ this.gToggle = aToggle;
+
+ // Thunderbird 2 rss messages with 'Show article summary' not selected,
+ // ie message body constructed to show web page in an iframe, can't show
+ // a summary - notify user.
+ let browser = getMessagePaneBrowser();
+ let contentDoc = browser ? browser.contentDocument : null;
+ let rssIframe = contentDoc
+ ? contentDoc.getElementById("_mailrssiframe")
+ : null;
+ if (rssIframe) {
+ if (this.gToggle || this.onSelectPref == this.kSelectOverrideSummary) {
+ this.gToggle = false;
+ }
+
+ return false;
+ }
+
+ if (aToggle) {
+ // Toggle mode, flip value.
+ return (gShowFeedSummary = this.gShowSummary = !this.gShowSummary);
+ }
+
+ let wintype = document.documentElement.getAttribute("windowtype");
+ let tabMail = document.getElementById("tabmail");
+ let messageTab = tabMail && tabMail.currentTabInfo.mode.type == "message";
+ let messageWindow = wintype == "mail:messageWindow";
+
+ switch (this.onSelectPref) {
+ case this.kSelectOverrideWebPage:
+ showSummary = false;
+ break;
+ case this.kSelectOverrideSummary:
+ showSummary = true;
+ break;
+ case this.kSelectFeedDefault:
+ // Get quickmode per feed folder pref from feed subscriptions. If the feed
+ // message is not in a feed account folder (hence the folder is not in
+ // the feeds database), err on the side of showing the summary.
+ // For the former, toggle or global override is necessary; for the
+ // latter, a show summary checkbox toggle in Subscribe dialog will set
+ // one on the path to bliss.
+ let folder = aMsgHdr.folder;
+ showSummary = true;
+ const ds = FeedUtils.getSubscriptionsDS(folder.server);
+ for (let sub of ds.data) {
+ if (sub.destFolder == folder.URI) {
+ showSummary = sub.quickMode;
+ break;
+ }
+ }
+ break;
+ }
+
+ gShowFeedSummary = this.gShowSummary = showSummary;
+
+ if (messageWindow || messageTab) {
+ // Message opened in either standalone window or tab, due to either
+ // message open pref (we are here only if the pref is 0 or 1) or
+ // contextmenu open.
+ switch (this.onOpenPref) {
+ case this.kOpenToggleInMessagePane:
+ // Opened by contextmenu, use the value derived above.
+ // XXX: allow a toggle via crtl?
+ break;
+ case this.kOpenWebPage:
+ showSummary = false;
+ break;
+ case this.kOpenSummary:
+ showSummary = true;
+ break;
+ }
+ }
+
+ // Auto load web page in browser on select, per pref; shouldShowSummary() is
+ // always called first to 1)test if feed, 2)get summary pref, so do it here.
+ if (this.loadWebPageOnSelectPref) {
+ setTimeout(FeedMessageHandler.loadWebPage, 20, aMsgHdr, {
+ browser: true,
+ });
+ }
+
+ return showSummary;
+ },
+
+ /**
+ * Load a web page for feed messages. Use MsgHdrToMimeMessage() to get
+ * the content-base url from the message headers. We cannot rely on
+ * currentHeaderData; it has not yet been streamed at our entry point in
+ * displayMessageChanged(), and in the case of a collapsed message pane it
+ * is not streamed.
+ *
+ * @param {nsIMsgDBHdr} aMessageHdr - The message.
+ * @param {Object} aWhere - name value=true pair, where name is in:
+ * 'messagepane', 'browser', 'tab', 'window'.
+ * @returns {void}
+ */
+ loadWebPage(aMessageHdr, aWhere) {
+ MsgHdrToMimeMessage(aMessageHdr, null, function (aMsgHdr, aMimeMsg) {
+ if (
+ aMimeMsg &&
+ aMimeMsg.headers["content-base"] &&
+ aMimeMsg.headers["content-base"][0]
+ ) {
+ let url = aMimeMsg.headers["content-base"],
+ uri;
+ try {
+ // The message and headers are stored as a string of UTF-8 bytes
+ // and we need to convert that cpp |string| to js UTF-16 explicitly
+ // for idn and non-ascii urls with this api.
+ url = decodeURIComponent(escape(url));
+ uri = Services.io.newURI(url);
+ } catch (ex) {
+ FeedUtils.log.info(
+ "FeedMessageHandler.loadWebPage: " +
+ "invalid Content-Base header url - " +
+ url
+ );
+ return;
+ }
+ if (aWhere.browser) {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(uri);
+ } else if (aWhere.messagepane) {
+ let browser = getMessagePaneBrowser();
+ // Load about:blank in the browser before (potentially) switching
+ // to a remote process. This prevents sandbox flags being carried
+ // over to the web document.
+ MailE10SUtils.loadAboutBlank(browser);
+ MailE10SUtils.loadURI(browser, url);
+ } else if (aWhere.tab) {
+ openContentTab(url, "tab", null);
+ } else if (aWhere.window) {
+ openContentTab(url, "window", null);
+ }
+ } else {
+ FeedUtils.log.info(
+ "FeedMessageHandler.loadWebPage: could not get " +
+ "Content-Base header url for this message"
+ );
+ }
+ });
+ },
+
+ /**
+ * Display summary or load web page for feed messages. Caller should already
+ * know if the message is a feed message.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message.
+ * @param {Boolean} aShowSummary - true if summary is to be displayed,
+ * false if web page.
+ * @returns {void}
+ */
+ setContent(aMsgHdr, aShowSummary) {
+ if (aShowSummary) {
+ // Only here if toggling to summary in 3pane.
+ if (this.gToggle && window.gDBView && GetNumSelectedMessages() == 1) {
+ ReloadMessage();
+ }
+ } else {
+ let browser = getMessagePaneBrowser();
+ if (browser && browser.contentDocument && browser.contentDocument.body) {
+ browser.contentDocument.body.hidden = true;
+ }
+ // If in a non rss folder, hide possible remote content bar on a web
+ // page load, as it doesn't apply.
+ gMessageNotificationBar.clearMsgNotifications();
+
+ this.loadWebPage(aMsgHdr, { messagepane: true });
+ this.gToggle = false;
+ }
+ },
+};
+
+function openSubscriptionsDialog(aFolder) {
+ // Check for an existing feed subscriptions window and focus it.
+ let subscriptionsWindow = Services.wm.getMostRecentWindow(
+ "Mail:News-BlogSubscriptions"
+ );
+
+ if (subscriptionsWindow) {
+ if (aFolder) {
+ subscriptionsWindow.FeedSubscriptions.selectFolder(aFolder);
+ subscriptionsWindow.FeedSubscriptions.mView.tree.ensureRowIsVisible(
+ subscriptionsWindow.FeedSubscriptions.mView.selection.currentIndex
+ );
+ }
+
+ subscriptionsWindow.focus();
+ } else {
+ window.browsingContext.topChromeWindow.openDialog(
+ "chrome://messenger-newsblog/content/feed-subscriptions.xhtml",
+ "",
+ "centerscreen,chrome,dialog=no,resizable",
+ { folder: aFolder }
+ );
+ }
+}
+
+// Special case attempts to reply/forward/edit as new RSS articles. For
+// messages stored prior to Tb15, we are here only if the message's folder's
+// account server is rss and feed messages moved to other types will have their
+// summaries loaded, as viewing web pages only happened in an rss account.
+// The user may choose whether to load a summary or web page link by ensuring
+// the current feed message is being viewed as either a summary or web page.
+function openComposeWindowForRSSArticle(
+ aMsgComposeWindow,
+ aMsgHdr,
+ aMessageUri,
+ aType,
+ aFormat,
+ aIdentity,
+ aMsgWindow
+) {
+ // Ensure right content is handled for web pages in window/tab.
+ let tabmail = document.getElementById("tabmail");
+ let is3pane =
+ tabmail && tabmail.selectedTab && tabmail.selectedTab.mode
+ ? tabmail.selectedTab.mode.type == "folder"
+ : false;
+ let showingwebpage =
+ "FeedMessageHandler" in window &&
+ !is3pane &&
+ FeedMessageHandler.onOpenPref == FeedMessageHandler.kOpenWebPage;
+
+ if (gShowFeedSummary && !showingwebpage) {
+ // The user is viewing the summary.
+ MailServices.compose.OpenComposeWindow(
+ aMsgComposeWindow,
+ aMsgHdr,
+ aMessageUri,
+ aType,
+ aFormat,
+ aIdentity,
+ null,
+ aMsgWindow
+ );
+ } else {
+ // Set up the compose message and get the feed message's web page link.
+ let msgHdr = aMsgHdr;
+ let type = aType;
+ let msgComposeType = Ci.nsIMsgCompType;
+ let subject = msgHdr.mime2DecodedSubject;
+ let fwdPrefix = Services.prefs.getCharPref("mail.forward_subject_prefix");
+ fwdPrefix = fwdPrefix ? fwdPrefix + ": " : "";
+
+ let params = Cc[
+ "@mozilla.org/messengercompose/composeparams;1"
+ ].createInstance(Ci.nsIMsgComposeParams);
+
+ let composeFields = Cc[
+ "@mozilla.org/messengercompose/composefields;1"
+ ].createInstance(Ci.nsIMsgCompFields);
+
+ if (
+ type == msgComposeType.Reply ||
+ type == msgComposeType.ReplyAll ||
+ type == msgComposeType.ReplyToSender ||
+ type == msgComposeType.ReplyToGroup ||
+ type == msgComposeType.ReplyToSenderAndGroup ||
+ type == msgComposeType.ReplyToList
+ ) {
+ subject = "Re: " + subject;
+ } else if (
+ type == msgComposeType.ForwardInline ||
+ type == msgComposeType.ForwardAsAttachment
+ ) {
+ subject = fwdPrefix + subject;
+ }
+
+ params.composeFields = composeFields;
+ params.composeFields.subject = subject;
+ params.composeFields.body = "";
+ params.bodyIsLink = false;
+ params.identity = aIdentity;
+
+ try {
+ // The feed's web page url is stored in the Content-Base header.
+ MsgHdrToMimeMessage(
+ msgHdr,
+ null,
+ function (aMsgHdr, aMimeMsg) {
+ if (
+ aMimeMsg &&
+ aMimeMsg.headers["content-base"] &&
+ aMimeMsg.headers["content-base"][0]
+ ) {
+ let url = decodeURIComponent(
+ escape(aMimeMsg.headers["content-base"])
+ );
+ params.composeFields.body = url;
+ params.bodyIsLink = true;
+ MailServices.compose.OpenComposeWindowWithParams(null, params);
+ } else {
+ // No content-base url, use the summary.
+ MailServices.compose.OpenComposeWindow(
+ aMsgComposeWindow,
+ aMsgHdr,
+ aMessageUri,
+ aType,
+ aFormat,
+ aIdentity,
+ null,
+ aMsgWindow
+ );
+ }
+ },
+ false,
+ { saneBodySize: true }
+ );
+ } catch (ex) {
+ // Error getting header, use the summary.
+ MailServices.compose.OpenComposeWindow(
+ aMsgComposeWindow,
+ aMsgHdr,
+ aMessageUri,
+ aType,
+ aFormat,
+ aIdentity,
+ null,
+ aMsgWindow
+ );
+ }
+ }
+}
diff --git a/comm/mailnews/extensions/newsblog/test/browser/browser.ini b/comm/mailnews/extensions/newsblog/test/browser/browser.ini
new file mode 100644
index 0000000000..1abed389c7
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/browser/browser.ini
@@ -0,0 +1,20 @@
+[DEFAULT]
+prefs =
+ datareporting.policy.dataSubmissionPolicyBypassNotification=true
+ mail.account.account1.server=server1
+ mail.accountmanager.accounts=account1
+ mail.provider.suppress_dialog_on_startup=true
+ mail.server.server1.hostname=Feeds
+ mail.server.server1.name=Feeds
+ mail.server.server1.type=rss
+ mail.server.server1.userName=nobody
+ mail.spellcheck.inline=false
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.database.global.indexer.enabled=false
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_feedDisplay.js]
diff --git a/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js b/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js
new file mode 100644
index 0000000000..98fa755c46
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js
@@ -0,0 +1,228 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+add_task(async () => {
+ function folderTreeClick(row, event = {}) {
+ EventUtils.synthesizeMouseAtCenter(
+ folderTree.rows[row].querySelector(".name"),
+ event,
+ about3Pane
+ );
+ }
+ function threadTreeClick(row, event = {}) {
+ EventUtils.synthesizeMouseAtCenter(
+ threadTree.getRowAtIndex(row),
+ event,
+ about3Pane
+ );
+ }
+
+ /** @implements {nsIExternalProtocolService} */
+ let mockExternalProtocolService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]),
+ _loadedURLs: [],
+ loadURI(uri, windowContext) {
+ this._loadedURLs.push(uri.spec);
+ },
+ isExposedProtocol(scheme) {
+ return true;
+ },
+ urlLoaded(url) {
+ return this._loadedURLs.includes(url);
+ },
+ };
+
+ let mockExternalProtocolServiceCID = MockRegistrar.register(
+ "@mozilla.org/uriloader/external-protocol-service;1",
+ mockExternalProtocolService
+ );
+
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(mockExternalProtocolServiceCID);
+
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = about3Pane;
+ });
+
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ let { folderTree, threadTree, messageBrowser } = about3Pane;
+ let menu = about3Pane.document.getElementById("folderPaneContext");
+ let menuItem = about3Pane.document.getElementById(
+ "folderPaneContext-subscribe"
+ );
+ // Not `currentAboutMessage` as that's null right now.
+ let aboutMessage = messageBrowser.contentWindow;
+ let messagePane = aboutMessage.getMessagePaneBrowser();
+
+ let account = MailServices.accounts.getAccount("account1");
+ let rootFolder = account.incomingServer.rootFolder;
+ about3Pane.displayFolder(rootFolder.URI);
+ let index = about3Pane.folderTree.selectedIndex;
+ Assert.equal(index, 0);
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ folderTreeClick(index, { type: "contextmenu" });
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://messenger-newsblog/content/feed-subscriptions.xhtml",
+ {
+ async callback(dialogWindow) {
+ let dialogDocument = dialogWindow.document;
+
+ let list = dialogDocument.getElementById("rssSubscriptionsList");
+ let locationInput = dialogDocument.getElementById("locationValue");
+ let addFeedButton = dialogDocument.getElementById("addFeed");
+
+ await BrowserTestUtils.waitForEvent(list, "select");
+
+ EventUtils.synthesizeMouseAtCenter(locationInput, {}, dialogWindow);
+ await TestUtils.waitForCondition(() => !addFeedButton.disabled);
+ EventUtils.sendString(
+ "https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml",
+ dialogWindow
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, dialogWindow);
+
+ // There's no good way to know if we're ready to continue.
+ await new Promise(r => dialogWindow.setTimeout(r, 250));
+
+ let hiddenPromise = BrowserTestUtils.waitForAttribute(
+ "hidden",
+ addFeedButton,
+ "true"
+ );
+ EventUtils.synthesizeMouseAtCenter(addFeedButton, {}, dialogWindow);
+ await hiddenPromise;
+
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("dialog").getButton("accept"),
+ {},
+ dialogWindow
+ );
+ },
+ }
+ );
+ menu.activateItem(menuItem);
+ await Promise.all([hiddenPromise, dialogPromise]);
+
+ let folder = rootFolder.subFolders.find(f => f.name == "Test Feed");
+ Assert.ok(folder);
+
+ about3Pane.displayFolder(folder.URI);
+ index = folderTree.selectedIndex;
+ Assert.equal(about3Pane.threadTree.view.rowCount, 1);
+
+ // Description mode.
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(messagePane);
+ threadTreeClick(0);
+ await loadedPromise;
+
+ Assert.notEqual(messagePane.currentURI.spec, "about:blank");
+ await SpecialPowers.spawn(messagePane, [], () => {
+ let doc = content.document;
+
+ let p = doc.querySelector("p");
+ Assert.equal(p.textContent, "This is the description.");
+
+ let style = content.getComputedStyle(doc.body);
+ Assert.equal(style.backgroundColor, "rgba(0, 0, 0, 0)");
+
+ let noscript = doc.querySelector("noscript");
+ style = content.getComputedStyle(noscript);
+ Assert.equal(style.display, "inline");
+ });
+
+ Assert.ok(
+ aboutMessage.document.getElementById("expandedtoRow").hidden,
+ "The To field is not visible"
+ );
+ Assert.equal(
+ aboutMessage.document.getElementById("dateLabel").textContent,
+ aboutMessage.document.getElementById("dateLabelSubject").textContent,
+ "The regular date label and the subject date have the same value"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ aboutMessage.document.getElementById("dateLabel"),
+ "The regular date label is not visible"
+ )
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ aboutMessage.document.getElementById("dateLabelSubject")
+ ),
+ "The date label on the subject line is visible"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, messagePane);
+ Assert.deepEqual(mockExternalProtocolService._loadedURLs, [
+ "https://example.org/link/from/description",
+ ]);
+ mockExternalProtocolService._loadedURLs.length = 0;
+
+ // Web mode.
+
+ loadedPromise = BrowserTestUtils.browserLoaded(
+ messagePane,
+ false,
+ "https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/article.html"
+ );
+ window.FeedMessageHandler.onSelectPref = 0;
+ await loadedPromise;
+
+ await SpecialPowers.spawn(messagePane, [], () => {
+ let doc = content.document;
+
+ let p = doc.querySelector("p");
+ Assert.equal(p.textContent, "This is the article.");
+
+ let style = content.getComputedStyle(doc.body);
+ Assert.equal(style.backgroundColor, "rgb(0, 128, 0)");
+
+ let noscript = doc.querySelector("noscript");
+ style = content.getComputedStyle(noscript);
+ Assert.equal(style.display, "none");
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, messagePane);
+ Assert.deepEqual(mockExternalProtocolService._loadedURLs, [
+ "https://example.org/link/from/article",
+ ]);
+ mockExternalProtocolService._loadedURLs.length = 0;
+
+ // Clean up.
+
+ shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ about3Pane.folderTree.selectedRow,
+ { type: "contextmenu" },
+ about3Pane
+ );
+ await shownPromise;
+
+ hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("accept");
+ menuItem = about3Pane.document.getElementById("folderPaneContext-remove");
+ menu.activateItem(menuItem);
+ await Promise.all([hiddenPromise, promptPromise]);
+
+ window.FeedMessageHandler.onSelectPref = 1;
+
+ folderTree.selectedIndex = 0;
+});
diff --git a/comm/mailnews/extensions/newsblog/test/browser/data/article.html b/comm/mailnews/extensions/newsblog/test/browser/data/article.html
new file mode 100644
index 0000000000..4c6b780c41
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/browser/data/article.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title></title>
+ </head>
+ <body>
+ <p>This is the article.</p>
+ <p><a href="https://example.org/link/from/article">Here's a link.</a></p>
+ <script>
+ document.body.style.backgroundColor = "green";
+ </script>
+ <noscript>
+ <p>This noscript should not display.</p>
+ </noscript>
+ </body>
+</html>
diff --git a/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml b/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml
new file mode 100644
index 0000000000..ec6aebccc1
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+ <channel>
+ <title>Test Feed</title>
+ <link>https://example.org/</link>
+ <description></description>
+ <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate>
+ <language>en-US</language>
+
+ <item>
+ <title>Test Article</title>
+ <link>https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/article.html</link>
+ <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate>
+
+ <description><![CDATA[
+ <p>This is the description.</p>
+ <p><a href="https://example.org/link/from/description">Here's a link.</a></p>
+ <script>
+ document.body.style.backgroundColor = "red";
+ </script>
+ <noscript>
+ <p>This noscript should display.</p>
+ </noscript>
+ ]]></description>
+ </item>
+ </channel>
+</rss>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js b/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js
new file mode 100644
index 0000000000..004e1acf68
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js
@@ -0,0 +1,35 @@
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+var { localAccountUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/LocalAccountUtils.jsm"
+);
+
+let { FeedParser } = ChromeUtils.import("resource:///modules/FeedParser.jsm");
+let { Feed } = ChromeUtils.import("resource:///modules/Feed.jsm");
+let { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm");
+let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+// Set up local web server to serve up test files.
+// We run it on a random port so that other tests can run concurrently
+// even if they also run a web server.
+let httpServer = new HttpServer();
+httpServer.registerDirectory("/", do_get_file("resources"));
+httpServer.start(-1);
+const SERVER_PORT = httpServer.identity.primaryPort;
+
+// Ensure the profile directory is set up
+do_get_profile();
+
+var gDEPTH = "../../../../../";
+
+registerCleanupFunction(async () => {
+ await httpServer.stop();
+ load(gDEPTH + "mailnews/resources/mailShutdown.js");
+});
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/README.md b/comm/mailnews/extensions/newsblog/test/unit/resources/README.md
new file mode 100644
index 0000000000..df655769b0
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/README.md
@@ -0,0 +1,24 @@
+Data files for unit testing the feeds code.
+
+- `rss_7_1.rdf`
+ Simple RSS1.0 feed example, from:
+ https://www.w3.org/2000/10/rdf-tests/RSS_1.0/rss_7_1.rdf
+
+- `rss_7_1_BORKED.rdf`
+ Sabotaged version of `rss_7_1.rdf` with a bad
+ <items> list, pointing to all sorts of URLs not
+ represented as <item>s in the feed (see Bug 476641).
+
+- `rss2_example.xml`
+ RSS2.0 example from wikipedia, but with
+ Japanese text in the title, with leading/trailing
+ whitespace.
+
+- `rss2_guid.xml`
+ RSS2.0 feed where two items have the same link but different guid.
+ (they should both appear in the feed).
+
+- `feeds-*/`
+ Test cases for migrating legacy .rdf config files to
+ the new json ones. The .rdf files are the old data,
+ and the .json files are the expected results.
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json
@@ -0,0 +1 @@
+{}
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf
new file mode 100644
index 0000000000..81a5a62ec5
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json
@@ -0,0 +1 @@
+[]
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf
new file mode 100644
index 0000000000..f7e6b400e2
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf
@@ -0,0 +1,17 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <RDF:Description RDF:about="urn:forumzilla:root">
+ <fz:feeds RDF:resource="rdf:#$cvA6q"/>
+ </RDF:Description>
+ <fz:feed RDF:about="https://example.com/feed/"
+ fz:quickMode="false"
+ dc:title="A feed with no dc:identifier, and thus no url. Should be ditched.">
+ </fz:feed>
+ <RDF:Seq RDF:about="rdf:#$cvA6q">
+ <RDF:li RDF:resource="https://example.com/feed/"/>
+ </RDF:Seq>
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json
@@ -0,0 +1 @@
+{}
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf
new file mode 100644
index 0000000000..81a5a62ec5
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json
@@ -0,0 +1 @@
+[]
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf
new file mode 100644
index 0000000000..a5f9c997e8
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf
@@ -0,0 +1,12 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <RDF:Description RDF:about="urn:forumzilla:root">
+ <fz:feeds RDF:resource="rdf:#$cvA6q"/>
+ </RDF:Description>
+ <RDF:Seq RDF:about="rdf:#$cvA6q">
+ </RDF:Seq>
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json
@@ -0,0 +1 @@
+{}
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf
new file mode 100644
index 0000000000..81a5a62ec5
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json
new file mode 100644
index 0000000000..e20f7a2f00
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json
@@ -0,0 +1,23 @@
+[
+ {
+ "title": "Government Digital Service",
+ "url": "https://gds.blog.gov.uk/feed/",
+ "quickMode": false,
+ "options": {
+ "version": 2,
+ "updates": {
+ "enabled": true,
+ "updateMinutes": 100,
+ "updateUnits": "min",
+ "lastUpdateTime": 1568784489107,
+ "lastDownloadTime": null,
+ "updatePeriod": "",
+ "updateFrequency": "",
+ "updateBase": ""
+ },
+ "category": { "enabled": false, "prefixEnabled": false, "prefix": "" }
+ },
+ "destFolder": "mailbox://nobody@Feeds/Government%20Digital%20Service",
+ "link": "https://gds.blog.gov.uk/feed/"
+ }
+]
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf
new file mode 100644
index 0000000000..3b6b6b2df8
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf
@@ -0,0 +1,21 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <RDF:Description RDF:about="urn:forumzilla:root">
+ <fz:feeds RDF:resource="rdf:#$cvA6q"/>
+ </RDF:Description>
+ <fz:feed RDF:about="https://gds.blog.gov.uk/feed/"
+ fz:quickMode="false"
+ dc:title="Government Digital Service"
+ NS1:link="https://gds.blog.gov.uk/feed/"
+ fz:options="{&quot;version&quot;:2,&quot;updates&quot;:{&quot;enabled&quot;:true,&quot;updateMinutes&quot;:100,&quot;updateUnits&quot;:&quot;min&quot;,&quot;lastUpdateTime&quot;:1568784489107,&quot;lastDownloadTime&quot;:null,&quot;updatePeriod&quot;:&quot;&quot;,&quot;updateFrequency&quot;:&quot;&quot;,&quot;updateBase&quot;:&quot;&quot;},&quot;category&quot;:{&quot;enabled&quot;:false,&quot;prefixEnabled&quot;:false,&quot;prefix&quot;:&quot;&quot;}}"
+ dc:identifier="https://gds.blog.gov.uk/feed/">
+ <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Government%20Digital%20Service"/>
+ </fz:feed>
+ <RDF:Seq RDF:about="rdf:#$cvA6q">
+ <RDF:li RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Seq>
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json
new file mode 100644
index 0000000000..a16c68f0c0
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json
@@ -0,0 +1,122 @@
+{
+ "https://gds.blog.gov.uk/?p=32978": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488792,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32944": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488971,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=33011": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488610,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=33020": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488551,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16464": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520041,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32951": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488909,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32963": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488851,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16431": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520152,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16477": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784519983,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32939": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784489030,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16453": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520096,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16418": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520209,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16507": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784519869,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16490": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784519926,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16378": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520323,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32927": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784489089,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=33001": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488670,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16393": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784520265,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://civilservice.blog.gov.uk/?p=16514": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784519812,
+ "feedURLs": ["https://civilservice.blog.gov.uk/feed/"]
+ },
+ "https://gds.blog.gov.uk/?p=32988": {
+ "stored": true,
+ "valid": true,
+ "lastSeenTime": 1568784488731,
+ "feedURLs": ["https://gds.blog.gov.uk/feed/"]
+ }
+}
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf
new file mode 100644
index 0000000000..f0941b5f6b
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf
@@ -0,0 +1,126 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32978"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488792"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32944"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488971"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33011"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488610"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33020"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488551"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16464"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520041"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32951"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488909"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32963"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488851"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16431"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520152"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16477"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784519983"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32939"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784489030"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16453"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520096"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16418"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520209"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16507"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784519869"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16490"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784519926"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16378"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520323"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32927"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784489089"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33001"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488670"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16393"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784520265"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16514"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784519812"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Description>
+ <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32988"
+ fz:stored="true"
+ fz:last-seen-timestamp="1568784488731"
+ fz:valid="true">
+ <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ </RDF:Description>
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json
new file mode 100644
index 0000000000..2ab7d4f780
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json
@@ -0,0 +1,46 @@
+[
+ {
+ "title": "Government Digital Service",
+ "lastModified": "Wed, 11 Sep 2019 15:47:49 GMT",
+ "url": "https://gds.blog.gov.uk/feed/",
+ "quickMode": false,
+ "options": {
+ "version": 2,
+ "updates": {
+ "enabled": true,
+ "updateMinutes": 100,
+ "updateUnits": "min",
+ "lastUpdateTime": 1568784489107,
+ "lastDownloadTime": null,
+ "updatePeriod": "",
+ "updateFrequency": "",
+ "updateBase": ""
+ },
+ "category": { "enabled": false, "prefixEnabled": false, "prefix": "" }
+ },
+ "destFolder": "mailbox://nobody@Feeds/Government%20Digital%20Service",
+ "link": "https://gds.blog.gov.uk/feed/"
+ },
+ {
+ "title": "Civil Service",
+ "lastModified": "Tue, 17 Sep 2019 16:21:00 GMT",
+ "url": "https://civilservice.blog.gov.uk/feed/",
+ "quickMode": false,
+ "options": {
+ "version": 2,
+ "updates": {
+ "enabled": true,
+ "updateMinutes": 100,
+ "updateUnits": "min",
+ "lastUpdateTime": 1568784520338,
+ "lastDownloadTime": null,
+ "updatePeriod": "",
+ "updateFrequency": "",
+ "updateBase": ""
+ },
+ "category": { "enabled": false, "prefixEnabled": false, "prefix": "" }
+ },
+ "destFolder": "mailbox://nobody@Feeds/Civil%20Service",
+ "link": "https://civilservice.blog.gov.uk/feed/"
+ }
+]
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf
new file mode 100644
index 0000000000..5c2fb72c74
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf
@@ -0,0 +1,32 @@
+<?xml version="1.0"?>
+<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/"
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:fz="urn:forumzilla:"
+ xmlns:NC="http://home.netscape.com/NC-rdf#"
+ xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <RDF:Description RDF:about="urn:forumzilla:root">
+ <fz:feeds RDF:resource="rdf:#$cvA6q"/>
+ </RDF:Description>
+ <fz:feed RDF:about="https://gds.blog.gov.uk/feed/"
+ fz:quickMode="false"
+ dc:title="Government Digital Service"
+ NS1:link="https://gds.blog.gov.uk/feed/"
+ dc:lastModified="Wed, 11 Sep 2019 15:47:49 GMT"
+ fz:options="{&quot;version&quot;:2,&quot;updates&quot;:{&quot;enabled&quot;:true,&quot;updateMinutes&quot;:100,&quot;updateUnits&quot;:&quot;min&quot;,&quot;lastUpdateTime&quot;:1568784489107,&quot;lastDownloadTime&quot;:null,&quot;updatePeriod&quot;:&quot;&quot;,&quot;updateFrequency&quot;:&quot;&quot;,&quot;updateBase&quot;:&quot;&quot;},&quot;category&quot;:{&quot;enabled&quot;:false,&quot;prefixEnabled&quot;:false,&quot;prefix&quot;:&quot;&quot;}}"
+ dc:identifier="https://gds.blog.gov.uk/feed/">
+ <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Government%20Digital%20Service"/>
+ </fz:feed>
+ <fz:feed RDF:about="https://civilservice.blog.gov.uk/feed/"
+ fz:quickMode="false"
+ dc:title="Civil Service"
+ NS1:link="https://civilservice.blog.gov.uk/feed/"
+ dc:lastModified="Tue, 17 Sep 2019 16:21:00 GMT"
+ fz:options="{&quot;version&quot;:2,&quot;updates&quot;:{&quot;enabled&quot;:true,&quot;updateMinutes&quot;:100,&quot;updateUnits&quot;:&quot;min&quot;,&quot;lastUpdateTime&quot;:1568784520338,&quot;lastDownloadTime&quot;:null,&quot;updatePeriod&quot;:&quot;&quot;,&quot;updateFrequency&quot;:&quot;&quot;,&quot;updateBase&quot;:&quot;&quot;},&quot;category&quot;:{&quot;enabled&quot;:false,&quot;prefixEnabled&quot;:false,&quot;prefix&quot;:&quot;&quot;}}"
+ dc:identifier="https://civilservice.blog.gov.uk/feed/">
+ <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Civil%20Service"/>
+ </fz:feed>
+ <RDF:Seq RDF:about="rdf:#$cvA6q">
+ <RDF:li RDF:resource="https://gds.blog.gov.uk/feed/"/>
+ <RDF:li RDF:resource="https://civilservice.blog.gov.uk/feed/"/>
+ </RDF:Seq>
+</RDF:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml
new file mode 100644
index 0000000000..0fe6268ec1
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0">
+<channel>
+ <title>
+
+ 本当に簡単なシンジケーションの例
+
+ </title>
+ <description>This is an example of an RSS feed</description>
+ <link>http://www.example.com/main.html</link>
+ <lastBuildDate>Mon, 06 Sep 2010 00:01:00 +0000 </lastBuildDate>
+ <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>
+ <ttl>1800</ttl>
+
+ <item>
+ <title>Example entry</title>
+ <description>Here is some text containing an interesting description.</description>
+ <link>http://www.example.com/blog/post/1</link>
+ <guid isPermaLink="false">7bd204c6-1655-4c27-aeee-53f933c5395f</guid>
+ <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>
+ </item>
+
+</channel>
+</rss>
+
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml
new file mode 100644
index 0000000000..9f1efea9cc
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<rss version="2.0">
+<channel>
+ <title>GUID test</title>
+ <description>This RSS feed has multiple items with the same link, but with different guids. They should be treated as separate items (see Bug 1656090)</description>
+ <link>http://www.example.com/main.html</link>
+ <lastBuildDate>Mon, 06 Sep 2020 00:01:00 +0000 </lastBuildDate>
+ <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate>
+ <ttl>1800</ttl>
+
+ <item>
+ <title>Entry One</title>
+ <description>Blah blah blah.</description>
+ <link>http://www.example.com/blog/post/1</link>
+ <guid isPermaLink="false">0524a046-df56-11ea-8bc9-47d63411283f</guid>
+ <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate>
+ </item>
+ <item>
+ <title>Entry One Again(with same link but different guid!)</title>
+ <description>Blah blah blah.</description>
+ <link>http://www.example.com/blog/post/1</link>
+ <guid isPermaLink="false">0524a35c-df56-11ea-8bca-43f820f3bd93</guid>
+ <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate>
+ </item>
+ <item>
+ <title>Entry Two</title>
+ <description>Blah blah blah.</description>
+ <link>http://www.example.com/blog/post/2</link>
+ <guid isPermaLink="false">0524a442-df56-11ea-8bcb-035a71f5f71a</guid>
+ <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate>
+ </item>
+ <item>
+ <title>Entry Three</title>
+ <description>Blah blah blah.</description>
+ <link>http://www.example.com/blog/post/3</link>
+ <guid isPermaLink="false">0524a53c-df56-11ea-8bcc-8f0977d58351</guid>
+ <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate>
+ </item>
+
+</channel>
+</rss>
+
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf
new file mode 100644
index 0000000000..179965e7de
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf
@@ -0,0 +1,66 @@
+<?xml version="1.0"?>
+
+<!-- RDF Site Summary (RSS) 1.0
+ http://groups.yahoo.com/group/rss-dev/files/specification.html
+ Section 7
+ -->
+
+<rdf:RDF
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns="http://purl.org/rss/1.0/">
+
+ <channel rdf:about="http://www.xml.com/xml/news.rss">
+ <title>XML.com</title>
+ <link>http://xml.com/pub</link>
+ <description>
+ XML.com features a rich mix of information and services
+ for the XML community.
+ </description>
+
+ <image rdf:resource="http://xml.com/universal/images/xml_tiny.gif" />
+
+ <items>
+ <rdf:Seq>
+ <rdf:li resource="http://xml.com/pub/2000/08/09/xslt/xslt.html" />
+ <rdf:li resource="http://xml.com/pub/2000/08/09/rdfdb/index.html" />
+ </rdf:Seq>
+ </items>
+
+ <textinput rdf:resource="http://search.xml.com" />
+
+ </channel>
+
+ <image rdf:about="http://xml.com/universal/images/xml_tiny.gif">
+ <title>XML.com</title>
+ <link>http://www.xml.com</link>
+ <url>http://xml.com/universal/images/xml_tiny.gif</url>
+ </image>
+
+ <item rdf:about="http://xml.com/pub/2000/08/09/xslt/xslt.html">
+ <title>Processing Inclusions with XSLT</title>
+ <link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>
+ <description>
+ Processing document inclusions with general XML tools can be
+ problematic. This article proposes a way of preserving inclusion
+ information through SAX-based processing.
+ </description>
+ </item>
+
+ <item rdf:about="http://xml.com/pub/2000/08/09/rdfdb/index.html">
+ <title>Putting RDF to Work</title>
+ <link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link>
+ <description>
+ Tool and API support for the Resource Description Framework
+ is slowly coming of age. Edd Dumbill takes a look at RDFDB,
+ one of the most exciting new RDF toolkits.
+ </description>
+ </item>
+
+ <textinput rdf:about="http://search.xml.com">
+ <title>Search XML.com</title>
+ <description>Search XML.com's XML collection</description>
+ <name>s</name>
+ <link>http://search.xml.com</link>
+ </textinput>
+
+</rdf:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf
new file mode 100644
index 0000000000..e2c6e0b109
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf
@@ -0,0 +1,66 @@
+<?xml version="1.0"?>
+
+<!-- RDF Site Summary (RSS) 1.0
+ http://groups.yahoo.com/group/rss-dev/files/specification.html
+ Section 7
+ -->
+
+<rdf:RDF
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns="http://purl.org/rss/1.0/">
+
+ <channel rdf:about="http://www.xml.com/xml/news.rss">
+ <title>XML.com</title>
+ <link>http://xml.com/pub</link>
+ <description>
+ XML.com features a rich mix of information and services
+ for the XML community.
+ </description>
+
+ <image rdf:resource="http://xml.com/universal/images/xml_tiny.gif" />
+
+ <items>
+ <rdf:Seq>
+ <rdf:li resource="http://OBVIOUSLY_WRONG_URL.com/blah/blah.html" />
+ <rdf:li resource="http://ANOTHER_OBVIOUSLY_WRONG_URL.com/foo/bar/wibble.html" />
+ </rdf:Seq>
+ </items>
+
+ <textinput rdf:resource="http://search.xml.com" />
+
+ </channel>
+
+ <image rdf:about="http://xml.com/universal/images/xml_tiny.gif">
+ <title>XML.com</title>
+ <link>http://www.xml.com</link>
+ <url>http://xml.com/universal/images/xml_tiny.gif</url>
+ </image>
+
+ <item rdf:about="http://xml.com/pub/2000/08/09/xslt/xslt.html">
+ <title>Processing Inclusions with XSLT</title>
+ <link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>
+ <description>
+ Processing document inclusions with general XML tools can be
+ problematic. This article proposes a way of preserving inclusion
+ information through SAX-based processing.
+ </description>
+ </item>
+
+ <item rdf:about="http://xml.com/pub/2000/08/09/rdfdb/index.html">
+ <title>Putting RDF to Work</title>
+ <link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link>
+ <description>
+ Tool and API support for the Resource Description Framework
+ is slowly coming of age. Edd Dumbill takes a look at RDFDB,
+ one of the most exciting new RDF toolkits.
+ </description>
+ </item>
+
+ <textinput rdf:about="http://search.xml.com">
+ <title>Search XML.com</title>
+ <description>Search XML.com's XML collection</description>
+ <name>s</name>
+ <link>http://search.xml.com</link>
+ </textinput>
+
+</rdf:RDF>
diff --git a/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js b/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js
new file mode 100644
index 0000000000..6577280356
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js
@@ -0,0 +1,146 @@
+// see https://www.w3.org/2000/10/rdf-tests/ for test files (including rss1)
+// - test examples for all feed types
+// - test items in test_download()
+// - test rss1 feed with itunes `new-feed-url` redirect
+// - test rss1 feed with RSS syndication extension tags (updatePeriod et al)
+// - test multiple/missing authors (with fallback to feed title)
+// - test missing dates
+// - test content formatting
+
+// Some RSS1 feeds in the wild:
+// https://www.livejournal.com/stats/latest-rss.bml
+// https://journals.sagepub.com/action/showFeed?ui=0&mi=ehikzz&ai=2b4&jc=acrc&type=etoc&feed=rss
+// https://www.revolutionspodcast.com/index.rdf
+// https://www.tandfonline.com/feed/rss/uasa20
+// http://export.arxiv.org/rss/astro-ph
+// - uses html formatting in <dc:creator>
+
+// Helper to compare feeditems.
+function assertItemsEqual(got, expected) {
+ Assert.equal(got.length, expected.length);
+ for (let i = 0; i < expected.length; i++) {
+ // Only check fields in expected. Means testdata can exclude "description" and other bulky fields.
+ for (let k of Object.keys(expected[i])) {
+ Assert.equal(got[i][k], expected[i][k]);
+ }
+ }
+}
+
+// Test the rss1 feed parser
+add_task(async function test_rss1() {
+ // Boilerplate.
+ let account = FeedUtils.createRssAccount("test_rss1");
+ let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ let folder = rootFolder.createLocalSubfolder("folderofeeds");
+
+ // These two files yield the same feed, but the second one has a sabotaged
+ // <items> to simulate badly-encoded feeds seen in the wild.
+ for (let testFile of [
+ "resources/rss_7_1.rdf",
+ "resources/rss_7_1_BORKED.rdf",
+ ]) {
+ dump(`checking ${testFile}\n`);
+ // Would be nicer to use the test http server to fetch the file, but that
+ // would involve XMLHTTPRequest. This is more concise.
+ let doc = await do_parse_document(testFile, "application/xml");
+ let feed = new Feed(
+ "https://www.w3.org/2000/10/rdf-tests/RSS_1.0/rss_7_1.rdf",
+ folder
+ );
+ feed.parseItems = true; // We want items too, not just the feed details.
+ feed.onParseError = function (f) {
+ throw new Error("PARSE ERROR");
+ };
+ let parser = new FeedParser();
+ let items = parser.parseAsRSS1(feed, doc);
+
+ // Check some channel details.
+ Assert.equal(feed.title, "XML.com");
+ Assert.equal(feed.link, "http://xml.com/pub");
+
+ // Check the items (the titles and links at least!).
+ assertItemsEqual(items, [
+ {
+ url: "http://xml.com/pub/2000/08/09/xslt/xslt.html",
+ title: "Processing Inclusions with XSLT",
+ },
+ {
+ url: "http://xml.com/pub/2000/08/09/rdfdb/index.html",
+ title: "Putting RDF to Work",
+ },
+ ]);
+ }
+});
+
+// Test feed downloading.
+// Mainly checking that it doesn't crash and that the right feed parser is used.
+add_task(async function test_download() {
+ // Boilerplate
+ let account = FeedUtils.createRssAccount("test_feed_download");
+ let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+
+ // load & parse example rss feed
+ // Feed object rejects anything other than http and https, so we're
+ // running a local http server for testing (see head_feeds.js for it).
+ let feedTests = [
+ {
+ url: "http://localhost:" + SERVER_PORT + "/rss_7_1.rdf",
+ feedType: "RSS_1.xRDF",
+ title: "XML.com",
+ expectedItems: 2,
+ },
+ {
+ // Has Japanese title with leading/trailing whitespace.
+ url: "http://localhost:" + SERVER_PORT + "/rss2_example.xml",
+ feedType: "RSS_2.0",
+ title: "本当に簡単なシンジケーションの例",
+ expectedItems: 1,
+ },
+ {
+ // Has two items with same link but different guid (Bug 1656090).
+ url: "http://localhost:" + SERVER_PORT + "/rss2_guid.xml",
+ feedType: "RSS_2.0",
+ title: "GUID test",
+ expectedItems: 4,
+ },
+ // TODO: examples for the other feed types!
+ ];
+
+ let n = 1;
+ for (let test of feedTests) {
+ let folder = rootFolder.createLocalSubfolder("feed" + n);
+ n++;
+ let feed = new Feed(test.url, folder);
+
+ let dl = new Promise(function (resolve, reject) {
+ let cb = {
+ downloaded(f, error, disable) {
+ if (error != FeedUtils.kNewsBlogSuccess) {
+ reject(
+ new Error(`download failed (url=${feed.url} error=${error})`)
+ );
+ return;
+ }
+ // Feed has downloaded - make sure the right type was detected.
+ Assert.equal(feed.mFeedType, test.feedType, "feed type matching");
+ Assert.equal(feed.title, test.title, "title matching");
+ // Make sure we're got the expected number of messages in the folder.
+ let cnt = [...folder.messages].length;
+ Assert.equal(cnt, test.expectedItems, "itemcount matching");
+
+ resolve();
+ },
+ onProgress(f, loaded, total, lengthComputable) {},
+ };
+
+ feed.download(true, cb);
+ });
+
+ // Wait for this feed to complete downloading.
+ await dl;
+ }
+});
diff --git a/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js b/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js
new file mode 100644
index 0000000000..a2bc2cf702
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js
@@ -0,0 +1,61 @@
+/* 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/. */
+
+const { MailMigrator } = ChromeUtils.import(
+ "resource:///modules/MailMigrator.jsm"
+);
+
+/**
+ * Tests migration of old .rdf feed config files to the new .json files.
+ *
+ * @param {String} testDataDir - A directory containing legacy feeds.rdf and
+ * feeditems.rdf files, along with coressponding
+ * .json files containing the expected results
+ * of the migration.
+ * @returns {void}
+ */
+async function migrationTest(testDataDir) {
+ // Set up an RSS account/server.
+ let account = FeedUtils.createRssAccount("rss_migration_test");
+ let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface(
+ Ci.nsIMsgLocalMailFolder
+ );
+ // Note, we don't create any folders to hold downloaded feed items,
+ // that's OK here, because we're only migrating the config files, not
+ // downloading feeds. The migration doesn't check destFolder existence.
+ let rootDir = rootFolder.filePath.path;
+
+ // Install legacy feeds.rdf/feeditems.rdf
+ for (let f of ["feeds.rdf", "feeditems.rdf"]) {
+ await IOUtils.copy(
+ PathUtils.join(testDataDir, f),
+ PathUtils.join(rootDir, f)
+ );
+ }
+
+ // Perform the migration
+ await MailMigrator._migrateRSSServer(account.incomingServer);
+
+ // Check actual results against expectations.
+ for (let f of ["feeds.json", "feeditems.json"]) {
+ let got = await IOUtils.readJSON(PathUtils.join(rootDir, f));
+ let expected = await IOUtils.readJSON(PathUtils.join(testDataDir, f));
+ Assert.deepEqual(got, expected, `match ${testDataDir}/${f}`);
+ }
+
+ // Delete the account and all it's files.
+ MailServices.accounts.removeAccount(account, true);
+}
+
+add_task(async function test_rdfmigration() {
+ let testDataDirs = [
+ "feeds-simple",
+ "feeds-empty",
+ "feeds-missing-timestamp",
+ "feeds-bad",
+ ];
+ for (let d of testDataDirs) {
+ await migrationTest(do_get_file("resources/" + d).path);
+ }
+});
diff --git a/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini b/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..3b7fc2869c
--- /dev/null
+++ b/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+head = head_feeds.js
+tail =
+support-files = resources/*
+
+[test_feedparser.js]
+[test_rdfmigration.js]
+