From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/mailnews/extensions/newsblog/.eslintrc.js | 18 + comm/mailnews/extensions/newsblog/Feed.jsm | 700 +++++ comm/mailnews/extensions/newsblog/FeedItem.jsm | 490 +++ comm/mailnews/extensions/newsblog/FeedParser.jsm | 1496 ++++++++++ comm/mailnews/extensions/newsblog/FeedUtils.jsm | 2136 ++++++++++++++ comm/mailnews/extensions/newsblog/NewsBlog.jsm | 28 + comm/mailnews/extensions/newsblog/am-newsblog.js | 128 + .../mailnews/extensions/newsblog/am-newsblog.xhtml | 233 ++ comm/mailnews/extensions/newsblog/components.conf | 21 + .../extensions/newsblog/feed-subscriptions.js | 3120 ++++++++++++++++++++ .../extensions/newsblog/feed-subscriptions.xhtml | 373 +++ .../extensions/newsblog/feedAccountWizard.js | 56 + .../extensions/newsblog/feedAccountWizard.xhtml | 95 + comm/mailnews/extensions/newsblog/jar.mn | 13 + comm/mailnews/extensions/newsblog/moz.build | 25 + .../extensions/newsblog/newsblogOverlay.js | 416 +++ .../extensions/newsblog/test/browser/browser.ini | 20 + .../newsblog/test/browser/browser_feedDisplay.js | 228 ++ .../newsblog/test/browser/data/article.html | 17 + .../extensions/newsblog/test/browser/data/rss.xml | 27 + .../extensions/newsblog/test/unit/head_feeds.js | 35 + .../newsblog/test/unit/resources/README.md | 24 + .../test/unit/resources/feeds-bad/feeditems.json | 1 + .../test/unit/resources/feeds-bad/feeditems.rdf | 6 + .../test/unit/resources/feeds-bad/feeds.json | 1 + .../test/unit/resources/feeds-bad/feeds.rdf | 17 + .../test/unit/resources/feeds-empty/feeditems.json | 1 + .../test/unit/resources/feeds-empty/feeditems.rdf | 6 + .../test/unit/resources/feeds-empty/feeds.json | 1 + .../test/unit/resources/feeds-empty/feeds.rdf | 12 + .../feeds-missing-timestamp/feeditems.json | 1 + .../feeds-missing-timestamp/feeditems.rdf | 6 + .../resources/feeds-missing-timestamp/feeds.json | 23 + .../resources/feeds-missing-timestamp/feeds.rdf | 21 + .../unit/resources/feeds-simple/feeditems.json | 122 + .../test/unit/resources/feeds-simple/feeditems.rdf | 126 + .../test/unit/resources/feeds-simple/feeds.json | 46 + .../test/unit/resources/feeds-simple/feeds.rdf | 32 + .../newsblog/test/unit/resources/rss2_example.xml | 25 + .../newsblog/test/unit/resources/rss2_guid.xml | 42 + .../newsblog/test/unit/resources/rss_7_1.rdf | 66 + .../test/unit/resources/rss_7_1_BORKED.rdf | 66 + .../newsblog/test/unit/test_feedparser.js | 146 + .../newsblog/test/unit/test_rdfmigration.js | 61 + .../extensions/newsblog/test/unit/xpcshell.ini | 8 + 45 files changed, 10535 insertions(+) create mode 100644 comm/mailnews/extensions/newsblog/.eslintrc.js create mode 100644 comm/mailnews/extensions/newsblog/Feed.jsm create mode 100644 comm/mailnews/extensions/newsblog/FeedItem.jsm create mode 100644 comm/mailnews/extensions/newsblog/FeedParser.jsm create mode 100644 comm/mailnews/extensions/newsblog/FeedUtils.jsm create mode 100644 comm/mailnews/extensions/newsblog/NewsBlog.jsm create mode 100644 comm/mailnews/extensions/newsblog/am-newsblog.js create mode 100644 comm/mailnews/extensions/newsblog/am-newsblog.xhtml create mode 100644 comm/mailnews/extensions/newsblog/components.conf create mode 100644 comm/mailnews/extensions/newsblog/feed-subscriptions.js create mode 100644 comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml create mode 100644 comm/mailnews/extensions/newsblog/feedAccountWizard.js create mode 100644 comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml create mode 100644 comm/mailnews/extensions/newsblog/jar.mn create mode 100644 comm/mailnews/extensions/newsblog/moz.build create mode 100644 comm/mailnews/extensions/newsblog/newsblogOverlay.js create mode 100644 comm/mailnews/extensions/newsblog/test/browser/browser.ini create mode 100644 comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js create mode 100644 comm/mailnews/extensions/newsblog/test/browser/data/article.html create mode 100644 comm/mailnews/extensions/newsblog/test/browser/data/rss.xml create mode 100644 comm/mailnews/extensions/newsblog/test/unit/head_feeds.js create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/README.md create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf create mode 100644 comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js create mode 100644 comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js create mode 100644 comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini (limited to 'comm/mailnews/extensions/newsblog') 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: "", + inReplyTo: "", + keywords: [], + mURL: null, + characterSet: "UTF-8", + + ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes + ENCLOSURE_HEADER_BOUNDARY_PREFIX: "------------", // 12 dashes + MESSAGE_TEMPLATE: + "\n" + + "\n" + + "\n" + + " \n" + + " %TITLE%\n" + + ' \n' + + " \n" + + ' \n' + + " %CONTENT%\n" + + " \n" + + "\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, "%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. & 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, "&"); + s = s.replace(/>/g, ">"); + s = s.replace(/ and " + ); + // The RSS2 spec requires a 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 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: missing mandatory " + + "element, either 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(/</g, "<"); + content = content.replace(/>/g, ">"); + content = content.replace(/&/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(/</g, "<"); + s = s.replace(/>/g, ">"); + s = s.replace(/&/g, "&"); + return s; + }, + + xmlEscape(s) { + s = s.replace(/&/g, "&"); + s = s.replace(/>/g, ">"); + s = s.replace(/</g, "<"); + 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; + + + + + + + + + + + + + + + &accountSettingsDesc.label; + + + + + + + + + &serverSettings.label; + + + + + + + + + &newFeedSettings.label; + + + + + + + + + + + + + + + + + + + + + + + +