diff options
Diffstat (limited to 'comm/mailnews/extensions/newsblog')
45 files changed, 10535 insertions, 0 deletions
diff --git a/comm/mailnews/extensions/newsblog/.eslintrc.js b/comm/mailnews/extensions/newsblog/.eslintrc.js new file mode 100644 index 0000000000..8254a84aaa --- /dev/null +++ b/comm/mailnews/extensions/newsblog/.eslintrc.js @@ -0,0 +1,18 @@ +"use strict"; + +module.exports = { + rules: { + // Enforce valid JSDoc comments. + "valid-jsdoc": [ + "error", + { + prefer: { return: "returns" }, + preferType: { + map: "Map", + set: "Set", + date: "Date", + }, + }, + ], + }, +}; diff --git a/comm/mailnews/extensions/newsblog/Feed.jsm b/comm/mailnews/extensions/newsblog/Feed.jsm new file mode 100644 index 0000000000..8b899308d5 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/Feed.jsm @@ -0,0 +1,700 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["Feed"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "FeedParser", + "resource:///modules/FeedParser.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "FeedUtils", + "resource:///modules/FeedUtils.jsm" +); + +// Cache for all of the feeds currently being downloaded, indexed by URL, +// so the load event listener can access the Feed objects after it finishes +// downloading the feed. +var FeedCache = { + mFeeds: {}, + + putFeed(aFeed) { + this.mFeeds[this.normalizeHost(aFeed.url)] = aFeed; + }, + + getFeed(aUrl) { + let index = this.normalizeHost(aUrl); + if (index in this.mFeeds) { + return this.mFeeds[index]; + } + + return null; + }, + + removeFeed(aUrl) { + let index = this.normalizeHost(aUrl); + if (index in this.mFeeds) { + delete this.mFeeds[index]; + } + }, + + normalizeHost(aUrl) { + try { + let normalizedUrl = Services.io.newURI(aUrl); + let newHost = normalizedUrl.host.toLowerCase(); + normalizedUrl = normalizedUrl.mutate().setHost(newHost).finalize(); + return normalizedUrl.spec; + } catch (ex) { + return aUrl; + } + }, +}; + +/** + * A Feed object. If aFolder is the account root folder, a new subfolder + * for the feed url is created otherwise the url will be subscribed to the + * existing aFolder, upon successful download() completion. + * + * @class + * @param {string} aFeedUrl - feed url. + * @param {nsIMsgFolder} aFolder - folder containing or to contain the feed + * subscription. + */ +function Feed(aFeedUrl, aFolder) { + this.url = aFeedUrl; + this.server = aFolder.server; + if (!aFolder.isServer) { + this.mFolder = aFolder; + } +} + +Feed.prototype = { + url: null, + description: null, + author: null, + request: null, + server: null, + downloadCallback: null, + resource: null, + itemsToStore: [], + itemsStored: 0, + fileSize: 0, + mFolder: null, + mInvalidFeed: false, + mFeedType: null, + mLastModified: null, + + get folder() { + return this.mFolder; + }, + + set folder(aFolder) { + this.mFolder = aFolder; + }, + + get name() { + // Used for the feed's title in Subscribe dialog and opml export. + let name = this.title || this.description || this.url; + /* eslint-disable-next-line no-control-regex */ + return name.replace(/[\n\r\t]+/g, " ").replace(/[\x00-\x1F]+/g, ""); + }, + + get folderName() { + if (this.mFolderName) { + return this.mFolderName; + } + + // Get a unique sanitized name. Use title or description as a base; + // these are mandatory by spec. Length of 80 is plenty. + let folderName = (this.title || this.description || "").substr(0, 80); + let defaultName = + lazy.FeedUtils.strings.GetStringFromName("ImportFeedsNew"); + return (this.mFolderName = lazy.FeedUtils.getSanitizedFolderName( + this.server.rootMsgFolder, + folderName, + defaultName, + true + )); + }, + + download(aParseItems, aCallback) { + // May be null. + this.downloadCallback = aCallback; + + // Whether or not to parse items when downloading and parsing the feed. + // Defaults to true, but setting to false is useful for obtaining + // just the title of the feed when the user subscribes to it. + this.parseItems = aParseItems == null || aParseItems; + + // Before we do anything, make sure the url is an http url. This is just + // a sanity check so we don't try opening mailto urls, imap urls, etc. that + // the user may have tried to subscribe to as an rss feed. + if (!lazy.FeedUtils.isValidScheme(this.url)) { + // Simulate an invalid feed error. + lazy.FeedUtils.log.info( + "Feed.download: invalid protocol for - " + this.url + ); + this.onParseError(this); + return; + } + + // Before we try to download the feed, make sure we aren't already + // processing the feed by looking up the url in our feed cache. + if (FeedCache.getFeed(this.url)) { + if (this.downloadCallback) { + this.downloadCallback.downloaded( + this, + lazy.FeedUtils.kNewsBlogFeedIsBusy + ); + } + + // Return, the feed is already in use. + return; + } + + if (Services.io.offline) { + // If offline and don't want to go online, just add the feed subscription; + // it can be verified later (the folder name will be the url if not adding + // to an existing folder). Only for subscribe actions; passive biff and + // active get new messages are handled prior to getting here. + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if (!win.MailOfflineMgr.getNewMail()) { + this.storeNextItem(); + return; + } + } + + this.request = new XMLHttpRequest(); + // Must set onProgress before calling open. + this.request.onprogress = this.onProgress; + this.request.open("GET", this.url, true); + this.request.channel.loadFlags |= + Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING; + + // Some servers, if sent If-Modified-Since, will send 304 if subsequently + // not sent If-Modified-Since, as in the case of an unsubscribe and new + // subscribe. Send start of century date to force a download; some servers + // will 304 on older dates (such as epoch 1970). + let lastModified = this.lastModified || "Sat, 01 Jan 2000 00:00:00 GMT"; + this.request.setRequestHeader("If-Modified-Since", lastModified); + + // Only order what you're going to eat... + this.request.responseType = "document"; + this.request.overrideMimeType("text/xml"); + this.request.setRequestHeader("Accept", lazy.FeedUtils.REQUEST_ACCEPT); + this.request.timeout = lazy.FeedUtils.REQUEST_TIMEOUT; + this.request.onload = this.onDownloaded; + this.request.onreadystatechange = this.onReadyStateChange; + this.request.onerror = this.onDownloadError; + this.request.ontimeout = this.onDownloadError; + FeedCache.putFeed(this); + this.request.send(null); + }, + + onReadyStateChange(aEvent) { + // Once a server responds with data, reset the timeout to allow potentially + // large files to complete the download. + let request = aEvent.target; + if (request.timeout && request.readyState == request.LOADING) { + request.timeout = 0; + } + }, + + onDownloaded(aEvent) { + let request = aEvent.target; + let isHttp = request.channel.originalURI.scheme.startsWith("http"); + let url = request.channel.originalURI.spec; + if (isHttp && (request.status < 200 || request.status >= 300)) { + Feed.prototype.onDownloadError(aEvent); + return; + } + + lazy.FeedUtils.log.debug( + "Feed.onDownloaded: got a download, fileSize:url - " + + aEvent.loaded + + " : " + + url + ); + let feed = FeedCache.getFeed(url); + if (!feed) { + throw new Error( + "Feed.onDownloaded: error - couldn't retrieve feed from cache" + ); + } + + // If the server sends a Last-Modified header, store the property on the + // feed so we can use it when making future requests, to avoid downloading + // and parsing feeds that have not changed. Don't update if merely checking + // the url, as for subscribe move/copy, as a subsequent refresh may get a 304. + // Save the response and persist it only upon successful completion of the + // refresh cycle (i.e. not if the request is cancelled). + let lastModifiedHeader = request.getResponseHeader("Last-Modified"); + feed.mLastModified = + lastModifiedHeader && feed.parseItems ? lastModifiedHeader : null; + + feed.fileSize = aEvent.loaded; + + // The download callback is called asynchronously when parse() is done. + feed.parse(); + }, + + onProgress(aEvent) { + let request = aEvent.target; + let url = request.channel.originalURI.spec; + let feed = FeedCache.getFeed(url); + + if (feed.downloadCallback) { + feed.downloadCallback.onProgress( + feed, + aEvent.loaded, + aEvent.total, + aEvent.lengthComputable + ); + } + }, + + onDownloadError(aEvent) { + let request = aEvent.target; + let url = request.channel.originalURI.spec; + let feed = FeedCache.getFeed(url); + if (feed.downloadCallback) { + // Generic network or 'not found' error initially. + let error = lazy.FeedUtils.kNewsBlogRequestFailure; + // Certain errors should disable the feed. + let disable = false; + + if (request.status == 304) { + // If the http status code is 304, the feed has not been modified + // since we last downloaded it and does not need to be parsed. + error = lazy.FeedUtils.kNewsBlogNoNewItems; + } else { + let [errType, errName] = + lazy.FeedUtils.createTCPErrorFromFailedXHR(request); + lazy.FeedUtils.log.info( + "Feed.onDownloaded: request errType:errName:statusCode - " + + errType + + ":" + + errName + + ":" + + request.status + ); + if (errType == "SecurityCertificate") { + // This is the code for nsINSSErrorsService.ERROR_CLASS_BAD_CERT + // overridable security certificate errors. + error = lazy.FeedUtils.kNewsBlogBadCertError; + } + + if (request.status == 401 || request.status == 403) { + // Unauthorized or Forbidden. + error = lazy.FeedUtils.kNewsBlogNoAuthError; + } + + if ( + request.status != 0 || + error == lazy.FeedUtils.kNewsBlogBadCertError || + errName == "DomainNotFoundError" + ) { + disable = true; + } + } + + feed.downloadCallback.downloaded(feed, error, disable); + } + + FeedCache.removeFeed(url); + }, + + onParseError(aFeed) { + if (!aFeed) { + return; + } + + aFeed.mInvalidFeed = true; + if (aFeed.downloadCallback) { + aFeed.downloadCallback.downloaded( + aFeed, + lazy.FeedUtils.kNewsBlogInvalidFeed, + true + ); + } + + FeedCache.removeFeed(aFeed.url); + }, + + onUrlChange(aFeed, aOldUrl) { + if (!aFeed) { + return; + } + + // Simulate a cancel after a url update; next cycle will check the new url. + aFeed.mInvalidFeed = true; + if (aFeed.downloadCallback) { + aFeed.downloadCallback.downloaded(aFeed, lazy.FeedUtils.kNewsBlogCancel); + } + + FeedCache.removeFeed(aOldUrl); + }, + + // nsIUrlListener methods for getDatabaseWithReparse(). + OnStartRunningUrl(aUrl) {}, + OnStopRunningUrl(aUrl, aExitCode) { + if (Components.isSuccessCode(aExitCode)) { + lazy.FeedUtils.log.debug( + "Feed.OnStopRunningUrl: rebuilt msgDatabase for " + + this.folder.name + + " - " + + this.folder.filePath.path + ); + } else { + lazy.FeedUtils.log.error( + "Feed.OnStopRunningUrl: rebuild msgDatabase failed, " + + "error " + + aExitCode + + ", for " + + this.folder.name + + " - " + + this.folder.filePath.path + ); + } + // Continue. + this.storeNextItem(); + }, + + get title() { + return lazy.FeedUtils.getSubscriptionAttr( + this.url, + this.server, + "title", + "" + ); + }, + + set title(aNewTitle) { + if (!aNewTitle) { + return; + } + lazy.FeedUtils.setSubscriptionAttr( + this.url, + this.server, + "title", + aNewTitle + ); + }, + + get lastModified() { + return lazy.FeedUtils.getSubscriptionAttr( + this.url, + this.server, + "lastModified", + "" + ); + }, + + set lastModified(aLastModified) { + lazy.FeedUtils.setSubscriptionAttr( + this.url, + this.server, + "lastModified", + aLastModified + ); + }, + + get quickMode() { + let defaultValue = this.server.getBoolValue("quickMode"); + return lazy.FeedUtils.getSubscriptionAttr( + this.url, + this.server, + "quickMode", + defaultValue + ); + }, + + set quickMode(aNewQuickMode) { + lazy.FeedUtils.setSubscriptionAttr( + this.url, + this.server, + "quickMode", + aNewQuickMode + ); + }, + + get options() { + let options = lazy.FeedUtils.getSubscriptionAttr( + this.url, + this.server, + "options", + null + ); + if (options && options.version == lazy.FeedUtils._optionsDefault.version) { + return options; + } + + let newOptions = lazy.FeedUtils.newOptions(options); + this.options = newOptions; + return newOptions; + }, + + set options(aOptions) { + let newOptions = aOptions ? aOptions : lazy.FeedUtils.optionsTemplate; + lazy.FeedUtils.setSubscriptionAttr( + this.url, + this.server, + "options", + newOptions + ); + }, + + get link() { + return lazy.FeedUtils.getSubscriptionAttr( + this.url, + this.server, + "link", + "" + ); + }, + + set link(aNewLink) { + if (!aNewLink) { + return; + } + lazy.FeedUtils.setSubscriptionAttr(this.url, this.server, "link", aNewLink); + }, + + parse() { + // Create a feed parser which will parse the feed. + let parser = new lazy.FeedParser(); + this.itemsToStore = parser.parseFeed(this, this.request.responseXML); + parser = null; + + if (this.mInvalidFeed) { + this.request = null; + this.mInvalidFeed = false; + return; + } + + this.itemsToStoreIndex = 0; + this.itemsStored = 0; + + // At this point, if we have items to potentially store and an existing + // folder, ensure the folder's msgDatabase is openable for new message + // processing. If not, reparse with an async nsIUrlListener |this| to + // continue once the reparse is complete. + if ( + this.itemsToStore.length > 0 && + this.folder && + !lazy.FeedUtils.isMsgDatabaseOpenable(this.folder, true, this) + ) { + return; + } + + // We have an msgDatabase; storeNextItem() will iterate through the parsed + // items, storing each one. + this.storeNextItem(); + }, + + /** + * Clear the 'valid' field of all feeditems associated with this feed. + * + * @returns {void} + */ + invalidateItems() { + let ds = lazy.FeedUtils.getItemsDS(this.server); + for (let id in ds.data) { + let item = ds.data[id]; + if (item.feedURLs.includes(this.url)) { + item.valid = false; + lazy.FeedUtils.log.trace("Feed.invalidateItems: item - " + id); + } + } + ds.saveSoon(); + }, + + /** + * Discards invalid items (in the feed item store) associated with the + * feed. There's a delay - invalid items are kept around for a set time + * before being purged. + * + * @param {Boolean} aDeleteFeed - is the feed being deleted (bypasses + * the delay time). + * @returns {void} + */ + removeInvalidItems(aDeleteFeed) { + let ds = lazy.FeedUtils.getItemsDS(this.server); + lazy.FeedUtils.log.debug("Feed.removeInvalidItems: for url - " + this.url); + + let currentTime = new Date().getTime(); + for (let id in ds.data) { + let item = ds.data[id]; + // skip valid items and ones not part of this feed. + if (!item.feedURLs.includes(this.url) || item.valid) { + continue; + } + let lastSeenTime = item.lastSeenTime || 0; + + if ( + currentTime - lastSeenTime < lazy.FeedUtils.INVALID_ITEM_PURGE_DELAY && + !aDeleteFeed + ) { + // Don't immediately purge items in active feeds; do so for deleted feeds. + continue; + } + + lazy.FeedUtils.log.trace("Feed.removeInvalidItems: item - " + id); + // Detach the item from this feed (it could be shared by multiple feeds). + item.feedURLs = item.feedURLs.filter(url => url != this.url); + if (item.feedURLs.length > 0) { + lazy.FeedUtils.log.debug( + "Feed.removeInvalidItems: " + + id + + " is from more than one feed; only the reference to" + + " this feed removed" + ); + } else { + delete ds.data[id]; + } + } + ds.saveSoon(); + }, + + createFolder() { + if (this.folder) { + return; + } + + try { + this.folder = this.server.rootMsgFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .createLocalSubfolder(this.folderName); + } catch (ex) { + // An error creating. + lazy.FeedUtils.log.info( + "Feed.createFolder: error creating folder - '" + + this.folderName + + "' in parent folder " + + this.server.rootMsgFolder.filePath.path + + " -- " + + ex + ); + // But its remnants are still there, clean up. + let xfolder = this.server.rootMsgFolder.getChildNamed(this.folderName); + this.server.rootMsgFolder.propagateDelete(xfolder, true); + } + }, + + // Gets the next item from itemsToStore and forces that item to be stored + // to the folder. If more items are left to be stored, fires a timer for + // the next one, otherwise triggers a download done notification to the UI. + storeNextItem() { + if (lazy.FeedUtils.CANCEL_REQUESTED) { + lazy.FeedUtils.CANCEL_REQUESTED = false; + this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogCancel); + return; + } + + if (this.itemsToStore.length == 0) { + let code = lazy.FeedUtils.kNewsBlogSuccess; + this.createFolder(); + if (!this.folder) { + code = lazy.FeedUtils.kNewsBlogFileError; + } + + this.cleanupParsingState(this, code); + return; + } + + let item = this.itemsToStore[this.itemsToStoreIndex]; + + if (item.store()) { + this.itemsStored++; + } + + if (!this.folder) { + this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogFileError); + return; + } + + this.itemsToStoreIndex++; + + // If the listener is tracking progress for each item, report it here. + if ( + item.feed.downloadCallback && + item.feed.downloadCallback.onFeedItemStored + ) { + item.feed.downloadCallback.onFeedItemStored( + item.feed, + this.itemsToStoreIndex, + this.itemsToStore.length + ); + } + + // Eventually we'll report individual progress here. + + if (this.itemsToStoreIndex < this.itemsToStore.length) { + if (!this.storeItemsTimer) { + this.storeItemsTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } + + this.storeItemsTimer.initWithCallback( + this, + 50, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else { + // We have just finished downloading one or more feed items into the + // destination folder; if the folder is still listed as having new + // messages in it, then we should set the biff state on the folder so the + // right RDF UI changes happen in the folder pane to indicate new mail. + if (item.feed.folder.hasNewMessages) { + item.feed.folder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NewMail; + // Run the bayesian spam filter, if enabled. + item.feed.folder.callFilterPlugins(null); + } + + this.cleanupParsingState(this, lazy.FeedUtils.kNewsBlogSuccess); + } + }, + + cleanupParsingState(aFeed, aCode) { + // Now that we are done parsing the feed, remove the feed from the cache. + FeedCache.removeFeed(aFeed.url); + + if (aFeed.parseItems) { + // Do this only if we're in parse/store mode. + aFeed.removeInvalidItems(false); + + if (aCode == lazy.FeedUtils.kNewsBlogSuccess && aFeed.mLastModified) { + aFeed.lastModified = aFeed.mLastModified; + } + + // Flush any feed item changes to disk. + let ds = lazy.FeedUtils.getItemsDS(aFeed.server); + ds.saveSoon(); + lazy.FeedUtils.log.debug( + "Feed.cleanupParsingState: items stored - " + this.itemsStored + ); + } + + // Force the xml http request to go away. This helps reduce some nasty + // assertions on shut down. + this.request = null; + this.itemsToStore = []; + this.itemsToStoreIndex = 0; + this.storeItemsTimer = null; + + if (aFeed.downloadCallback) { + aFeed.downloadCallback.downloaded(aFeed, aCode); + } + }, + + // nsITimerCallback + notify(aTimer) { + this.storeNextItem(); + }, +}; diff --git a/comm/mailnews/extensions/newsblog/FeedItem.jsm b/comm/mailnews/extensions/newsblog/FeedItem.jsm new file mode 100644 index 0000000000..40a0424a0d --- /dev/null +++ b/comm/mailnews/extensions/newsblog/FeedItem.jsm @@ -0,0 +1,490 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["FeedItem", "FeedEnclosure"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const lazy = {}; +ChromeUtils.defineModuleGetter( + lazy, + "FeedUtils", + "resource:///modules/FeedUtils.jsm" +); + +function FeedItem() { + this.mDate = lazy.FeedUtils.getValidRFC5322Date(); + this.mParserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); +} + +FeedItem.prototype = { + // Only for IETF Atom. + xmlContentBase: null, + id: null, + feed: null, + description: null, + content: null, + enclosures: [], + title: null, + // Author must be angle bracket enclosed to function as an addr-spec, in the + // absence of an addr-spec portion of an RFC5322 email address, as other + // functionality (gloda search) depends on this. + author: "<anonymous>", + inReplyTo: "", + keywords: [], + mURL: null, + characterSet: "UTF-8", + + ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes + ENCLOSURE_HEADER_BOUNDARY_PREFIX: "------------", // 12 dashes + MESSAGE_TEMPLATE: + "\n" + + "<!DOCTYPE html>\n" + + "<html>\n" + + " <head>\n" + + " <title>%TITLE%</title>\n" + + ' <base href="%BASE%">\n' + + " </head>\n" + + ' <body id="msgFeedSummaryBody" selected="false">\n' + + " %CONTENT%\n" + + " </body>\n" + + "</html>\n", + + get url() { + return this.mURL; + }, + + set url(aVal) { + try { + this.mURL = Services.io.newURI(aVal).spec; + } catch (ex) { + // The url as published or constructed can be a non url. It's used as a + // feeditem identifier in feeditems.rdf, as a messageId, and as an href + // and for the content-base header. Save as is; ensure not null. + this.mURL = aVal ? aVal : ""; + } + }, + + get date() { + return this.mDate; + }, + + set date(aVal) { + this.mDate = aVal; + }, + + get identity() { + return this.feed.name + ": " + this.title + " (" + this.id + ")"; + }, + + normalizeMessageID(messageID) { + // Escape occurrences of message ID meta characters <, >, and @. + messageID.replace(/</g, "%3C"); + messageID.replace(/>/g, "%3E"); + messageID.replace(/@/g, "%40"); + messageID = "<" + messageID.trim() + "@localhost.localdomain>"; + + lazy.FeedUtils.log.trace( + "FeedItem.normalizeMessageID: messageID - " + messageID + ); + return messageID; + }, + + get contentBase() { + if (this.xmlContentBase) { + return this.xmlContentBase; + } + + return this.mURL; + }, + + /** + * Writes the item to the folder as a message and updates the feeditems db. + * + * @returns {void} + */ + store() { + // this.title and this.content contain HTML. + // this.mUrl and this.contentBase contain plain text. + + let stored = false; + let ds = lazy.FeedUtils.getItemsDS(this.feed.server); + let resource = this.findStoredResource(); + if (!this.feed.folder) { + return stored; + } + + if (resource == null) { + resource = { + feedURLs: [this.feed.url], + lastSeenTime: 0, + valid: false, + stored: false, + }; + ds.data[this.id] = resource; + if (!this.content) { + lazy.FeedUtils.log.trace( + "FeedItem.store: " + + this.identity + + " no content; storing description or title" + ); + this.content = this.description || this.title; + } + + let content = this.MESSAGE_TEMPLATE; + content = content.replace(/%TITLE%/, this.title); + content = content.replace(/%BASE%/, this.htmlEscape(this.contentBase)); + content = content.replace(/%CONTENT%/, this.content); + this.content = content; + this.writeToFolder(); + this.markStored(resource); + stored = true; + } + + this.markValid(resource); + ds.saveSoon(); + return stored; + }, + + findStoredResource() { + // Checks to see if the item has already been stored in its feed's + // message folder. + lazy.FeedUtils.log.trace( + "FeedItem.findStoredResource: checking if stored - " + this.identity + ); + + let server = this.feed.server; + let folder = this.feed.folder; + + if (!folder) { + lazy.FeedUtils.log.debug( + "FeedItem.findStoredResource: folder '" + + this.feed.folderName + + "' doesn't exist; creating as child of " + + server.rootMsgFolder.prettyName + + "\n" + ); + this.feed.createFolder(); + return null; + } + + let ds = lazy.FeedUtils.getItemsDS(server); + let item = ds.data[this.id]; + if (!item || !item.stored) { + lazy.FeedUtils.log.trace("FeedItem.findStoredResource: not stored"); + return null; + } + + lazy.FeedUtils.log.trace("FeedItem.findStoredResource: already stored"); + return item; + }, + + markValid(resource) { + resource.lastSeenTime = new Date().getTime(); + // Items can be in multiple feeds. + if (!resource.feedURLs.includes(this.feed.url)) { + resource.feedURLs.push(this.feed.url); + } + resource.valid = true; + }, + + markStored(resource) { + // Items can be in multiple feeds. + if (!resource.feedURLs.includes(this.feed.url)) { + resource.feedURLs.push(this.feed.url); + } + resource.stored = true; + }, + + writeToFolder() { + lazy.FeedUtils.log.trace( + "FeedItem.writeToFolder: " + + this.identity + + " writing to message folder " + + this.feed.name + ); + // The subject may contain HTML entities. Convert these to their unencoded + // state. i.e. & 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(/</g, "<"); + s = s.replace(/'/g, "'"); + s = s.replace(/"/g, """); + return s; + }, +}; + +// A feed enclosure is to RSS what an attachment is for e-mail. We make +// enclosures look like attachments in the UI. +function FeedEnclosure(aURL, aContentType, aLength, aTitle) { + this.mURL = aURL; + // Store a reasonable mimetype if content-type is not present. + this.mContentType = aContentType || "application/unknown"; + this.mLength = aLength; + this.mTitle = aTitle; + + // Generate a fileName from the URL. + if (this.mURL) { + try { + let uri = Services.io.newURI(this.mURL).QueryInterface(Ci.nsIURL); + this.mFileName = uri.fileName; + // Determine mimetype from extension if content-type is not present. + if (!aContentType) { + let contentType = Cc["@mozilla.org/mime;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromExtension(uri.fileExtension); + this.mContentType = contentType; + } + } catch (ex) { + this.mFileName = this.mURL; + } + } +} + +FeedEnclosure.prototype = { + mURL: "", + mContentType: "", + mLength: 0, + mFileName: "", + mTitle: "", + ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes + + // Returns a string that looks like an e-mail attachment which represents + // the enclosure. + convertToAttachment(aBoundaryID) { + return ( + "\n" + + this.ENCLOSURE_BOUNDARY_PREFIX + + aBoundaryID + + "\n" + + "Content-Type: " + + this.mContentType + + '; name="' + + (this.mTitle || this.mFileName) + + (this.mLength ? '"; size=' + this.mLength : '"') + + "\n" + + "X-Mozilla-External-Attachment-URL: " + + this.mURL + + "\n" + + 'Content-Disposition: attachment; filename="' + + this.mFileName + + '"\n\n' + + lazy.FeedUtils.strings.GetStringFromName("externalAttachmentMsg") + + "\n" + ); + }, +}; diff --git a/comm/mailnews/extensions/newsblog/FeedParser.jsm b/comm/mailnews/extensions/newsblog/FeedParser.jsm new file mode 100644 index 0000000000..863d5789fe --- /dev/null +++ b/comm/mailnews/extensions/newsblog/FeedParser.jsm @@ -0,0 +1,1496 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["FeedParser"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "FeedItem", + "resource:///modules/FeedItem.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "FeedEnclosure", + "resource:///modules/FeedItem.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "FeedUtils", + "resource:///modules/FeedUtils.jsm" +); + +/** + * The feed parser. Depends on FeedItem.js, Feed.js. + * + * @class + */ +function FeedParser() { + this.parsedItems = []; + this.mSerializer = new XMLSerializer(); +} + +FeedParser.prototype = { + /** + * parseFeed() returns an array of parsed items ready for processing. It is + * currently a synchronous operation. If there is an error parsing the feed, + * parseFeed returns an empty feed in addition to calling aFeed.onParseError. + * + * @param {Feed} aFeed - The Feed object. + * @param {XMLDocument} aDOM - The document to parse. + * @returns {Array} - array of items, or empty array for error returns or + * nothing to do condition. + */ + parseFeed(aFeed, aDOM) { + if (!XMLDocument.isInstance(aDOM)) { + // No xml doc. + aFeed.onParseError(aFeed); + return []; + } + + let doc = aDOM.documentElement; + if (doc.namespaceURI == lazy.FeedUtils.MOZ_PARSERERROR_NS) { + // Gecko caught a basic parsing error. + let errStr = + doc.firstChild.textContent + "\n" + doc.firstElementChild.textContent; + lazy.FeedUtils.log.info("FeedParser.parseFeed: - " + errStr); + aFeed.onParseError(aFeed); + return []; + } else if (aDOM.querySelector("redirect")) { + // Check for RSS2.0 redirect document. + let channel = aDOM.querySelector("redirect"); + if (this.isPermanentRedirect(aFeed, channel, null)) { + return []; + } + + aFeed.onParseError(aFeed); + return []; + } else if ( + doc.namespaceURI == lazy.FeedUtils.RDF_SYNTAX_NS && + doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_NS, "channel")[0] + ) { + aFeed.mFeedType = "RSS_1.xRDF"; + lazy.FeedUtils.log.debug( + "FeedParser.parseFeed: type:url - " + + aFeed.mFeedType + + " : " + + aFeed.url + ); + + return this.parseAsRSS1(aFeed, aDOM); + } else if (doc.namespaceURI == lazy.FeedUtils.ATOM_03_NS) { + aFeed.mFeedType = "ATOM_0.3"; + lazy.FeedUtils.log.debug( + "FeedParser.parseFeed: type:url - " + + aFeed.mFeedType + + " : " + + aFeed.url + ); + return this.parseAsAtom(aFeed, aDOM); + } else if (doc.namespaceURI == lazy.FeedUtils.ATOM_IETF_NS) { + aFeed.mFeedType = "ATOM_IETF"; + lazy.FeedUtils.log.debug( + "FeedParser.parseFeed: type:url - " + + aFeed.mFeedType + + " : " + + aFeed.url + ); + return this.parseAsAtomIETF(aFeed, aDOM); + } else if ( + doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_090_NS, "channel")[0] + ) { + aFeed.mFeedType = "RSS_0.90"; + lazy.FeedUtils.log.debug( + "FeedParser.parseFeed: type:url - " + + aFeed.mFeedType + + " : " + + aFeed.url + ); + return this.parseAsRSS2(aFeed, aDOM); + } + + // Parse as RSS 0.9x. In theory even RSS 1.0 feeds could be parsed by + // the 0.9x parser if the RSS namespace were the default. + let rssVer = doc.localName == "rss" ? doc.getAttribute("version") : null; + if (rssVer) { + aFeed.mFeedType = "RSS_" + rssVer; + } else { + aFeed.mFeedType = "RSS_0.9x?"; + } + lazy.FeedUtils.log.debug( + "FeedParser.parseFeed: type:url - " + aFeed.mFeedType + " : " + aFeed.url + ); + return this.parseAsRSS2(aFeed, aDOM); + }, + + parseAsRSS2(aFeed, aDOM) { + // Get the first channel (assuming there is only one per RSS File). + let channel = aDOM.querySelector("channel"); + if (!channel) { + aFeed.onParseError(aFeed); + return []; + } + + // Usually the empty string, unless this is RSS .90. + let nsURI = channel.namespaceURI || ""; + + if (this.isPermanentRedirect(aFeed, null, channel)) { + return []; + } + + let tags = this.childrenByTagNameNS(channel, nsURI, "title"); + aFeed.title = aFeed.title || this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(channel, nsURI, "description"); + aFeed.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(channel, nsURI, "link"); + aFeed.link = this.validLink(this.getNodeValue(tags ? tags[0] : null)); + + if (!(aFeed.title || aFeed.description)) { + lazy.FeedUtils.log.error( + "FeedParser.parseAsRSS2: missing mandatory element " + + "<title> and <description>" + ); + // The RSS2 spec requires a <link> as well, but we can do without it + // so ignore the case of (valid) link missing. + aFeed.onParseError(aFeed); + return []; + } + + if (!aFeed.parseItems) { + return []; + } + + this.findSyUpdateTags(aFeed, channel); + + aFeed.invalidateItems(); + // XXX use getElementsByTagNameNS for now; childrenByTagNameNS would be + // better, but RSS .90 is still with us. + let itemNodes = aDOM.getElementsByTagNameNS(nsURI, "item"); + itemNodes = itemNodes ? itemNodes : []; + lazy.FeedUtils.log.debug( + "FeedParser.parseAsRSS2: items to parse - " + itemNodes.length + ); + + for (let itemNode of itemNodes) { + if (!itemNode.childElementCount) { + continue; + } + + let item = new lazy.FeedItem(); + item.feed = aFeed; + item.enclosures = []; + item.keywords = []; + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.FEEDBURNER_NS, + "origLink" + ); + let link = this.validLink(this.getNodeValue(tags ? tags[0] : null)); + if (!link) { + tags = this.childrenByTagNameNS(itemNode, nsURI, "link"); + link = this.validLink(this.getNodeValue(tags ? tags[0] : null)); + } + tags = this.childrenByTagNameNS(itemNode, nsURI, "guid"); + let guidNode = tags ? tags[0] : null; + + let guid; + let isPermaLink = false; + if (guidNode) { + guid = this.getNodeValue(guidNode); + // isPermaLink is true if the value is "true" or if the attribute is + // not present; all other values, including "false" and "False" and + // for that matter "TRuE" and "meatcake" are false. + if ( + !guidNode.hasAttribute("isPermaLink") || + guidNode.getAttribute("isPermaLink") == "true" + ) { + isPermaLink = true; + } + // If attribute isPermaLink is missing, it is good to check the validity + // of <guid> value as an URL to avoid linking to non-URL strings. + if (!guidNode.hasAttribute("isPermaLink")) { + try { + Services.io.newURI(guid); + if (Services.io.extractScheme(guid) == "tag") { + isPermaLink = false; + } + } catch (ex) { + isPermaLink = false; + } + } + + item.id = guid; + } + + let guidLink = this.validLink(guid); + if (isPermaLink && guidLink) { + item.url = guidLink; + } else if (link) { + item.url = link; + } else { + item.url = null; + } + + tags = this.childrenByTagNameNS(itemNode, nsURI, "description"); + item.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(itemNode, nsURI, "title"); + item.title = this.getNodeValue(tags ? tags[0] : null); + if (!(item.title || item.description)) { + lazy.FeedUtils.log.info( + "FeedParser.parseAsRSS2: <item> missing mandatory " + + "element, either <title> or <description>; skipping" + ); + continue; + } + + if (!item.id) { + // At this point, if there is no guid, uniqueness cannot be guaranteed + // by any of link or date (optional) or title (optional unless there + // is no description). Use a big chunk of description; minimize dupes + // with url and title if present. + item.id = + (item.url || item.feed.url) + + "#" + + item.title + + "#" + + (this.stripTags( + item.description ? item.description.substr(0, 150) : null + ) || item.title); + item.id = item.id.replace(/[\n\r\t\s]+/g, " "); + } + + // Escape html entities in <title>, which are unescaped as textContent + // values. If the title is used as content, it will remain escaped; if + // it is used as the title, it will be unescaped upon store. Bug 1240603. + // The <description> tag must follow escaping examples found in + // http://www.rssboard.org/rss-encoding-examples, i.e. single escape angle + // brackets for tags, which are removed if used as title, and double + // escape entities for presentation in title. + // Better: always use <title>. Best: use Atom. + if (!item.title) { + item.title = this.stripTags(item.description).substr(0, 150); + } else { + item.title = item.htmlEscape(item.title); + } + + tags = this.childrenByTagNameNS(itemNode, nsURI, "author"); + if (!tags) { + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.DC_NS, + "creator" + ); + } + let author = this.getNodeValue(tags ? tags[0] : null) || aFeed.title; + author = this.cleanAuthorName(author); + item.author = author ? ["<" + author + ">"] : item.author; + + tags = this.childrenByTagNameNS(itemNode, nsURI, "pubDate"); + if (!tags || !this.getNodeValue(tags[0])) { + tags = this.childrenByTagNameNS(itemNode, lazy.FeedUtils.DC_NS, "date"); + } + item.date = this.getNodeValue(tags ? tags[0] : null) || item.date; + + // If the date is invalid, users will see the beginning of the epoch + // unless we reset it here, so they'll see the current time instead. + // This is typical aggregator behavior. + if (item.date) { + item.date = item.date.trim(); + if (!lazy.FeedUtils.isValidRFC822Date(item.date)) { + // XXX Use this on the other formats as well. + item.date = this.dateRescue(item.date); + } + } + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.RSS_CONTENT_NS, + "encoded" + ); + item.content = this.getNodeValueFormatted(tags ? tags[0] : null); + + // Handle <enclosures> and <media:content>, which may be in a + // <media:group> (if present). + tags = this.childrenByTagNameNS(itemNode, nsURI, "enclosure"); + let encUrls = []; + if (tags) { + for (let tag of tags) { + let url = this.validLink(tag.getAttribute("url")); + if (url && !encUrls.includes(url)) { + let type = this.removeUnprintableASCII(tag.getAttribute("type")); + let length = this.removeUnprintableASCII( + tag.getAttribute("length") + ); + item.enclosures.push(new lazy.FeedEnclosure(url, type, length)); + encUrls.push(url); + } + } + } + + tags = itemNode.getElementsByTagNameNS(lazy.FeedUtils.MRSS_NS, "content"); + if (tags) { + for (let tag of tags) { + let url = this.validLink(tag.getAttribute("url")); + if (url && !encUrls.includes(url)) { + let type = this.removeUnprintableASCII(tag.getAttribute("type")); + let fileSize = this.removeUnprintableASCII( + tag.getAttribute("fileSize") + ); + item.enclosures.push(new lazy.FeedEnclosure(url, type, fileSize)); + } + } + } + + // The <origEnclosureLink> tag has no specification, especially regarding + // whether more than one tag is allowed and, if so, how tags would + // relate to previously declared (and well specified) enclosure urls. + // The common usage is to include 1 origEnclosureLink, in addition to + // the specified enclosure tags for 1 enclosure. Thus, we will replace the + // first enclosure's, if found, url with the first <origEnclosureLink> + // url only or else add the <origEnclosureLink> url. + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.FEEDBURNER_NS, + "origEnclosureLink" + ); + let origEncUrl = this.validLink(this.getNodeValue(tags ? tags[0] : null)); + if (origEncUrl) { + if (item.enclosures.length) { + item.enclosures[0].mURL = origEncUrl; + } else { + item.enclosures.push(new lazy.FeedEnclosure(origEncUrl)); + } + } + + // Support <category> and autotagging. + tags = this.childrenByTagNameNS(itemNode, nsURI, "category"); + if (tags) { + for (let tag of tags) { + let term = this.getNodeValue(tag); + term = term ? this.xmlUnescape(term.replace(/,/g, ";")) : null; + if (term && !item.keywords.includes(term)) { + item.keywords.push(term); + } + } + } + + this.parsedItems.push(item); + } + + return this.parsedItems; + }, + + /** + * Extracts feed details and (optionally) items from an RSS1 + * feed which has already been XML-parsed as an XMLDocument. + * The feed items are extracted only if feed.parseItems is set. + * + * Technically RSS1 is supposed to be treated as RDFXML, but in practice + * no feed parser anywhere ever does this, and feeds in the wild are + * pretty shakey on their RDF encoding too. So we just treat it as raw + * XML and pick out the bits we want. + * + * @param {Feed} feed - The Feed object. + * @param {XMLDocument} doc - The document to parse. + * @returns {Array} - array of FeedItems or empty array for error returns or + * nothing to do condition (ie unset feed.parseItems). + */ + parseAsRSS1(feed, doc) { + let channel = doc.querySelector("channel"); + if (!channel) { + feed.onParseError(feed); + return []; + } + + if (this.isPermanentRedirect(feed, null, channel)) { + return []; + } + + let titleNode = this.childByTagNameNS( + channel, + lazy.FeedUtils.RSS_NS, + "title" + ); + // If user entered a title manually, retain it. + feed.title = feed.title || this.getNodeValue(titleNode) || feed.url; + + let descNode = this.childByTagNameNS( + channel, + lazy.FeedUtils.RSS_NS, + "description" + ); + feed.description = this.getNodeValueFormatted(descNode) || ""; + + let linkNode = this.childByTagNameNS( + channel, + lazy.FeedUtils.RSS_NS, + "link" + ); + feed.link = this.validLink(this.getNodeValue(linkNode)) || feed.url; + + if (!(feed.title || feed.description) || !feed.link) { + lazy.FeedUtils.log.error( + "FeedParser.parseAsRSS1: missing mandatory element " + + "<title> and <description>, or <link>" + ); + feed.onParseError(feed); + return []; + } + + // If we're only interested in the overall feed description, we're done. + if (!feed.parseItems) { + return []; + } + + this.findSyUpdateTags(feed, channel); + + feed.invalidateItems(); + + // Now process all the individual items in the feed. + let itemNodes = doc.getElementsByTagNameNS(lazy.FeedUtils.RSS_NS, "item"); + itemNodes = itemNodes ? itemNodes : []; + + for (let itemNode of itemNodes) { + let item = new lazy.FeedItem(); + item.feed = feed; + + // Prefer the value of the link tag to the item URI since the URI could be + // a relative URN. + let itemURI = itemNode.getAttribute("about") || ""; + itemURI = this.removeUnprintableASCII(itemURI.trim()); + let linkNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.RSS_NS, + "link" + ); + item.id = this.getNodeValue(linkNode) || itemURI; + item.url = this.validLink(item.id); + + let descNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.RSS_NS, + "description" + ); + item.description = this.getNodeValueFormatted(descNode); + + let titleNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.RSS_NS, + "title" + ); + let subjectNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.DC_NS, + "subject" + ); + + item.title = + this.getNodeValue(titleNode) || this.getNodeValue(subjectNode); + if (!item.title && item.description) { + item.title = this.stripTags(item.description).substr(0, 150); + } + if (!item.url || !item.title) { + lazy.FeedUtils.log.info( + "FeedParser.parseAsRSS1: <item> missing mandatory " + + "element <item rdf:about> and <link>, or <title> and " + + "no <description>; skipping" + ); + continue; + } + + // TODO XXX: ignores multiple authors. + let authorNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.DC_NS, + "creator" + ); + let channelCreatorNode = this.childByTagNameNS( + channel, + lazy.FeedUtils.DC_NS, + "creator" + ); + let author = + this.getNodeValue(authorNode) || + this.getNodeValue(channelCreatorNode) || + feed.title; + author = this.cleanAuthorName(author); + item.author = author ? ["<" + author + ">"] : item.author; + + let dateNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.DC_NS, + "date" + ); + item.date = this.getNodeValue(dateNode) || item.date; + + let contentNode = this.childByTagNameNS( + itemNode, + lazy.FeedUtils.RSS_CONTENT_NS, + "encoded" + ); + item.content = this.getNodeValueFormatted(contentNode); + + this.parsedItems.push(item); + } + lazy.FeedUtils.log.debug( + "FeedParser.parseAsRSS1: items parsed - " + this.parsedItems.length + ); + + return this.parsedItems; + }, + + // TODO: deprecate ATOM_03_NS. + parseAsAtom(aFeed, aDOM) { + // Get the first channel (assuming there is only one per Atom File). + let channel = aDOM.querySelector("feed"); + if (!channel) { + aFeed.onParseError(aFeed); + return []; + } + + if (this.isPermanentRedirect(aFeed, null, channel)) { + return []; + } + + let tags = this.childrenByTagNameNS( + channel, + lazy.FeedUtils.ATOM_03_NS, + "title" + ); + aFeed.title = + aFeed.title || this.stripTags(this.getNodeValue(tags ? tags[0] : null)); + tags = this.childrenByTagNameNS( + channel, + lazy.FeedUtils.ATOM_03_NS, + "tagline" + ); + aFeed.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(channel, lazy.FeedUtils.ATOM_03_NS, "link"); + aFeed.link = this.validLink(this.findAtomLink("alternate", tags)); + + if (!aFeed.title) { + lazy.FeedUtils.log.error( + "FeedParser.parseAsAtom: missing mandatory element <title>" + ); + aFeed.onParseError(aFeed); + return []; + } + + if (!aFeed.parseItems) { + return []; + } + + this.findSyUpdateTags(aFeed, channel); + + aFeed.invalidateItems(); + let items = this.childrenByTagNameNS( + channel, + lazy.FeedUtils.ATOM_03_NS, + "entry" + ); + items = items ? items : []; + lazy.FeedUtils.log.debug( + "FeedParser.parseAsAtom: items to parse - " + items.length + ); + + for (let itemNode of items) { + if (!itemNode.childElementCount) { + continue; + } + + let item = new lazy.FeedItem(); + item.feed = aFeed; + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "link" + ); + item.url = this.validLink(this.findAtomLink("alternate", tags)); + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "id" + ); + item.id = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "summary" + ); + item.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "title" + ); + item.title = + this.getNodeValue(tags ? tags[0] : null) || + (item.description ? item.description.substr(0, 150) : null); + if (!item.title || !item.id) { + // We're lenient about other mandatory tags, but insist on these. + lazy.FeedUtils.log.info( + "FeedParser.parseAsAtom: <entry> missing mandatory " + + "element <id>, or <title> and no <summary>; skipping" + ); + continue; + } + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "author" + ); + if (!tags) { + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "contributor" + ); + } + if (!tags) { + tags = this.childrenByTagNameNS( + channel, + lazy.FeedUtils.ATOM_03_NS, + "author" + ); + } + + let authorEl = tags ? tags[0] : null; + + let author = ""; + if (authorEl) { + tags = this.childrenByTagNameNS( + authorEl, + lazy.FeedUtils.ATOM_03_NS, + "name" + ); + let name = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS( + authorEl, + lazy.FeedUtils.ATOM_03_NS, + "email" + ); + let email = this.getNodeValue(tags ? tags[0] : null); + if (name) { + author = name + (email ? " <" + email + ">" : ""); + } else if (email) { + author = email; + } + } + + item.author = author || item.author || aFeed.title; + + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "modified" + ); + if (!tags || !this.getNodeValue(tags[0])) { + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "issued" + ); + } + if (!tags || !this.getNodeValue(tags[0])) { + tags = this.childrenByTagNameNS( + channel, + lazy.FeedUtils.ATOM_03_NS, + "created" + ); + } + + item.date = this.getNodeValue(tags ? tags[0] : null) || item.date; + + // XXX We should get the xml:base attribute from the content tag as well + // and use it as the base HREF of the message. + // XXX Atom feeds can have multiple content elements; we should differentiate + // between them and pick the best one. + // Some Atom feeds wrap the content in a CTYPE declaration; others use + // a namespace to identify the tags as HTML; and a few are buggy and put + // HTML tags in without declaring their namespace so they look like Atom. + // We deal with the first two but not the third. + tags = this.childrenByTagNameNS( + itemNode, + lazy.FeedUtils.ATOM_03_NS, + "content" + ); + let contentNode = tags ? tags[0] : null; + + let content; + if (contentNode) { + content = ""; + for (let node of contentNode.childNodes) { + if (node.nodeType == node.CDATA_SECTION_NODE) { + content += node.data; + } else { + content += this.mSerializer.serializeToString(node); + } + } + + if (contentNode.getAttribute("mode") == "escaped") { + content = content.replace(/</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;</title> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/AccountManager.js" + ></script> + <script + defer="defer" + src="chrome://messenger-newsblog/content/am-newsblog.js" + ></script> + <script + defer="defer" + src="chrome://messenger-newsblog/content/newsblogOverlay.js" + ></script> + <script defer="defer" src="chrome://messenger/content/amUtils.js"></script> + <script defer="defer" src="chrome://messenger/content/am-prefs.js"></script> + <script> + // FIXME: move to script file. + window.addEventListener("load", event => { + parent.onPanelLoaded("am-newsblog.xhtml"); + }); + </script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <vbox id="containerBox" flex="1"> + <hbox id="am-newsblog-title" class="dialogheader"> + <label class="dialogheader-title" defaultTitle="&accountTitle.label;" /> + </hbox> + + <description class="secDesc">&accountSettingsDesc.label;</description> + + <hbox class="input-container"> + <label + id="server.prettyNameLabel" + value="&accountName.label;" + accesskey="&accountName.accesskey;" + control="server.prettyName" + /> + <html:input + id="server.prettyName" + type="text" + wsm_persist="true" + class="input-inline" + aria-labelledby="server.prettyNameLabel" + onblur="serverPrettyNameOnBlur(event);" + prefstring="mail.server.%serverkey%.name" + /> + </hbox> + + <separator class="thin" /> + + <html:div> + <html:fieldset> + <html:legend>&serverSettings.label;</html:legend> + <checkbox + id="doBiff" + label="&biffAll.label;" + accesskey="&biffAll.accesskey;" + oncommand="setPrefs(this)" + /> + </html:fieldset> + </html:div> + + <separator class="thin" /> + + <html:div> + <html:fieldset> + <html:legend>&newFeedSettings.label;</html:legend> + + <hbox align="center"> + <checkbox + id="updateEnabled" + label="&biffStart.label;" + accesskey="&biffStart.accesskey;" + oncommand="setPrefs(this)" + /> + <html:input + id="updateValue" + type="number" + class="size3" + min="1" + aria-labelledby="updateEnabled updateValue biffMinutes biffDays" + onchange="setPrefs(this)" + /> + <radiogroup + id="biffUnits" + orient="horizontal" + oncommand="setPrefs(this)" + > + <radio + id="biffMinutes" + value="min" + label="&biffMinutes.label;" + accesskey="&biffMinutes.accesskey;" + /> + <radio + id="biffDays" + value="d" + label="&biffDays.label;" + accesskey="&biffDays.accesskey;" + /> + </radiogroup> + </hbox> + + <checkbox + id="server.quickMode" + wsm_persist="true" + genericattr="true" + label="&quickMode.label;" + accesskey="&quickMode.accesskey;" + preftype="bool" + prefattribute="value" + prefstring="mail.server.%serverkey%.quickMode" + /> + + <checkbox + id="autotagEnable" + accesskey="&autotagEnable.accesskey;" + label="&autotagEnable.label;" + oncommand="setPrefs(this)" + /> + <hbox class="input-container"> + <checkbox + id="autotagUsePrefix" + class="indent" + accesskey="&autotagUsePrefix.accesskey;" + label="&autotagUsePrefix.label;" + oncommand="setPrefs(this)" + /> + <html:input + id="autotagPrefix" + type="text" + class="input-inline" + aria-labelledby="autotagUsePrefix" + placeholder="&autoTagPrefix.placeholder;" + onchange="setPrefs(this)" + /> + </hbox> + </html:fieldset> + </html:div> + + <separator class="thin" /> + + <hbox pack="end"> + <button + label="&manageSubscriptions.label;" + accesskey="&manageSubscriptions.accesskey;" + oncommand="openSubscriptionsDialog(gAccount.incomingServer.rootFolder);" + /> + </hbox> + + <separator class="thin" /> + + <html:div> + <html:fieldset> + <html:legend>&messageStorage.label;</html:legend> + + <checkbox + id="server.emptyTrashOnExit" + wsm_persist="true" + label="&emptyTrashOnExit.label;" + accesskey="&emptyTrashOnExit.accesskey;" + prefattribute="value" + prefstring="mail.server.%serverkey%.empty_trash_on_exit" + /> + + <separator class="thin" /> + + <vbox> + <hbox align="center"> + <label + id="server.localPathLabel" + value="&localPath1.label;" + control="server.localPath" + /> + <hbox class="input-container" flex="1"> + <html:input + id="server.localPath" + type="text" + readonly="readonly" + class="uri-element input-inline" + aria-labelledby="server.localPathLabel" + wsm_persist="true" + datatype="nsIFile" + prefstring="mail.server.%serverkey%.directory" + /> + </hbox> + <button + id="browseForLocalFolder" + label="&browseFolder.label;" + filepickertitle="&localFolderPicker.label;" + accesskey="&browseFolder.accesskey;" + oncommand="BrowseForLocalFolders();" + /> + </hbox> + </vbox> + </html:fieldset> + </html:div> + </vbox> + </html:body> +</html> diff --git a/comm/mailnews/extensions/newsblog/components.conf b/comm/mailnews/extensions/newsblog/components.conf new file mode 100644 index 0000000000..0e2c4ce03a --- /dev/null +++ b/comm/mailnews/extensions/newsblog/components.conf @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +Classes = [ + { + "cid": "{5c124537-adca-4456-b2b5-641ab687d1f6}", + "contract_ids": ["@mozilla.org/newsblog-feed-downloader;1"], + "jsm": "resource:///modules/NewsBlog.jsm", + "constructor": "FeedDownloader", + }, + { + "cid": "{e109c05f-d304-4ca5-8c44-6de1bfaf1f74}", + "contract_ids": ["@mozilla.org/accountmanager/extension;1?name=newsblog"], + "jsm": "resource:///modules/NewsBlog.jsm", + "constructor": "FeedAcctMgrExtension", + "categories": {"mailnews-accountmanager-extensions": "newsblog"}, + }, +] diff --git a/comm/mailnews/extensions/newsblog/feed-subscriptions.js b/comm/mailnews/extensions/newsblog/feed-subscriptions.js new file mode 100644 index 0000000000..43ce61b11b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/feed-subscriptions.js @@ -0,0 +1,3120 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * @file + * GUI-side code for managing folder subscriptions. + */ + +var { Feed } = ChromeUtils.import("resource:///modules/Feed.jsm"); +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); + +var FeedSubscriptions = { + get mMainWin() { + return Services.wm.getMostRecentWindow("mail:3pane"); + }, + + get mTree() { + return document.getElementById("rssSubscriptionsList"); + }, + + mFeedContainers: [], + mRSSServer: null, + mActionMode: null, + kSubscribeMode: 1, + kUpdateMode: 2, + kMoveMode: 3, + kCopyMode: 4, + kImportingOPML: 5, + kVerifyUrlMode: 6, + + get FOLDER_ACTIONS() { + return ( + Ci.nsIMsgFolderNotificationService.folderAdded | + Ci.nsIMsgFolderNotificationService.folderDeleted | + Ci.nsIMsgFolderNotificationService.folderRenamed | + Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted + ); + }, + + onLoad() { + // Extract the folder argument. + let folder; + if (window.arguments && window.arguments[0].folder) { + folder = window.arguments[0].folder; + } + + // Ensure dialog is fully loaded before selecting, to get visible row. + setTimeout(() => { + FeedSubscriptions.refreshSubscriptionView(folder); + }, 100); + let message = FeedUtils.strings.GetStringFromName("subscribe-loading"); + this.updateStatusItem("statusText", message); + + FeedUtils.CANCEL_REQUESTED = false; + + if (this.mMainWin) { + MailServices.mfn.addListener(this.FolderListener, this.FOLDER_ACTIONS); + } + }, + + onDialogAccept() { + let dismissDialog = true; + + // If we are in the middle of subscribing to a feed, inform the user that + // dismissing the dialog right now will abort the feed subscription. + if (this.mActionMode == this.kSubscribeMode) { + let pTitle = FeedUtils.strings.GetStringFromName( + "subscribe-cancelSubscriptionTitle" + ); + let pMessage = FeedUtils.strings.GetStringFromName( + "subscribe-cancelSubscription" + ); + dismissDialog = !Services.prompt.confirmEx( + window, + pTitle, + pMessage, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ); + } + + if (dismissDialog) { + FeedUtils.CANCEL_REQUESTED = this.mActionMode == this.kSubscribeMode; + if (this.mMainWin) { + MailServices.mfn.removeListener( + this.FolderListener, + this.FOLDER_ACTIONS + ); + } + } + + return dismissDialog; + }, + + refreshSubscriptionView(aSelectFolder, aSelectFeedUrl) { + let item = this.mView.currentItem; + this.loadSubscriptions(); + this.mTree.view = this.mView; + + if (aSelectFolder && !aSelectFeedUrl) { + this.selectFolder(aSelectFolder); + } else if (item) { + // If no folder to select, try to select the pre rebuild selection, in + // an existing window. For folderpane changes in a feed account. + let rootFolder = item.container + ? item.folder.rootFolder + : item.parentFolder.rootFolder; + if (item.container) { + if (!this.selectFolder(item.folder, { open: item.open })) { + // The item no longer exists, an ancestor folder was deleted or + // renamed/moved. + this.selectFolder(rootFolder); + } + } else { + let url = + item.parentFolder == aSelectFolder ? aSelectFeedUrl : item.url; + this.selectFeed({ folder: rootFolder, url }, null); + } + } + + this.mView.tree.ensureRowIsVisible(this.mView.selection.currentIndex); + this.clearStatusInfo(); + }, + + mView: { + kRowIndexUndefined: -1, + + get currentItem() { + // Get the current selection, if any. + let seln = this.selection; + let currentSelectionIndex = seln ? seln.currentIndex : null; + let item; + if (currentSelectionIndex != null) { + item = this.getItemAtIndex(currentSelectionIndex); + } + + return item; + }, + + /* nsITreeView */ + /* eslint-disable no-multi-spaces */ + tree: null, + + mRowCount: 0, + get rowCount() { + return this.mRowCount; + }, + + _selection: null, + get selection() { + return this._selection; + }, + set selection(val) { + this._selection = val; + }, + + setTree(aTree) { + this.tree = aTree; + }, + isSeparator(aRow) { + return false; + }, + isSorted() { + return false; + }, + isEditable(aRow, aColumn) { + return false; + }, + + getProgressMode(aRow, aCol) {}, + cycleHeader(aCol) {}, + cycleCell(aRow, aCol) {}, + selectionChanged() {}, + getRowProperties(aRow) { + return ""; + }, + getColumnProperties(aCol) { + return ""; + }, + getCellValue(aRow, aColumn) {}, + setCellValue(aRow, aColumn, aValue) {}, + setCellText(aRow, aColumn, aValue) {}, + /* eslint-enable no-multi-spaces */ + + getCellProperties(aRow, aColumn) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return ""; + } + + if (AppConstants.MOZ_APP_NAME != "thunderbird") { + if (!item.folder) { + return "serverType-rss"; + } else if (item.folder.isServer) { + return "serverType-rss isServer-true"; + } + + return "livemark"; + } + + let folder = item.folder; + let properties = "folderNameCol"; + let mainWin = FeedSubscriptions.mMainWin; + if (!mainWin) { + let hasFeeds = FeedUtils.getFeedUrlsInFolder(folder); + if (!folder) { + properties += " isFeed-true"; + } else if (hasFeeds) { + properties += " isFeedFolder-true"; + } else if (folder.isServer) { + properties += " serverType-rss isServer-true"; + } + } else { + let url = folder ? null : item.url; + folder = folder || item.parentFolder; + properties = mainWin.FolderUtils.getFolderProperties(folder, item.open); + properties += mainWin.FeedUtils.getFolderProperties(folder, url); + if ( + this.selection.currentIndex == aRow && + url && + item.options.updates.enabled && + properties.includes("isPaused") + ) { + item.options.updates.enabled = false; + FeedSubscriptions.updateFeedData(item); + } + } + + item.properties = properties; + return properties; + }, + + isContainer(aRow) { + let item = this.getItemAtIndex(aRow); + return item ? item.container : false; + }, + + isContainerOpen(aRow) { + let item = this.getItemAtIndex(aRow); + return item ? item.open : false; + }, + + isContainerEmpty(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return false; + } + + return item.children.length == 0; + }, + + getItemAtIndex(aRow) { + if (aRow < 0 || aRow >= FeedSubscriptions.mFeedContainers.length) { + return null; + } + + return FeedSubscriptions.mFeedContainers[aRow]; + }, + + getItemInViewIndex(aFolder) { + if (!aFolder || !(aFolder instanceof Ci.nsIMsgFolder)) { + return null; + } + + for (let index = 0; index < this.rowCount; index++) { + // Find the visible folder in the view. + let item = this.getItemAtIndex(index); + if (item && item.container && item.url == aFolder.URI) { + return index; + } + } + + return null; + }, + + removeItemAtIndex(aRow, aNoSelect) { + let itemToRemove = this.getItemAtIndex(aRow); + if (!itemToRemove) { + return; + } + + if (itemToRemove.container && itemToRemove.open) { + // Close it, if open container. + this.toggleOpenState(aRow); + } + + let parentIndex = this.getParentIndex(aRow); + let hasNextSibling = this.hasNextSibling(aRow, aRow); + if (parentIndex != this.kRowIndexUndefined) { + let parent = this.getItemAtIndex(parentIndex); + if (parent) { + for (let index = 0; index < parent.children.length; index++) { + if (parent.children[index] == itemToRemove) { + parent.children.splice(index, 1); + break; + } + } + } + } + + // Now remove it from our view. + FeedSubscriptions.mFeedContainers.splice(aRow, 1); + + // Now invalidate the correct tree rows. + this.mRowCount--; + this.tree.rowCountChanged(aRow, -1); + + // Now update the selection position, unless noSelect (selection is + // done later or not at all). If the item is the last child, select the + // parent. Otherwise select the next sibling. + if (!aNoSelect) { + if (aRow <= FeedSubscriptions.mFeedContainers.length) { + this.selection.select(hasNextSibling ? aRow : aRow - 1); + } else { + this.selection.clearSelection(); + } + } + + // Now refocus the tree. + FeedSubscriptions.mTree.focus(); + }, + + getCellText(aRow, aColumn) { + let item = this.getItemAtIndex(aRow); + return item && aColumn.id == "folderNameCol" ? item.name : ""; + }, + + getImageSrc(aRow, aCol) { + let item = this.getItemAtIndex(aRow); + if ((item.folder && item.folder.isServer) || item.open) { + return ""; + } + + if ( + !item.open && + (item.properties.includes("hasError") || + item.properties.includes("isBusy")) + ) { + return ""; + } + + if (item.favicon != null) { + return item.favicon; + } + + let callback = iconUrl => { + item.favicon = iconUrl; + if (item.folder) { + for (let child of item.children) { + if (!child.container) { + child.favicon = iconUrl; + break; + } + } + } + + this.selection.tree.invalidateRow(aRow); + }; + + // A closed non server folder. + if (item.folder) { + for (let child of item.children) { + if (!child.container) { + if (child.favicon != null) { + return child.favicon; + } + + setTimeout(async () => { + let iconUrl = await FeedUtils.getFavicon( + child.parentFolder, + child.url + ); + if (iconUrl) { + callback(iconUrl); + } + }, 0); + break; + } + } + } else { + // A feed. + setTimeout(async () => { + let iconUrl = await FeedUtils.getFavicon(item.parentFolder, item.url); + if (iconUrl) { + callback(iconUrl); + } + }, 0); + } + + // Store empty string to return default while favicons are retrieved. + return (item.favicon = ""); + }, + + canDrop(aRow, aOrientation) { + let dropResult = this.extractDragData(aRow); + return ( + aOrientation == Ci.nsITreeView.DROP_ON && + dropResult.canDrop && + (dropResult.dropUrl || + dropResult.dropOnIndex != this.kRowIndexUndefined) + ); + }, + + drop(aRow, aOrientation) { + let win = FeedSubscriptions; + let results = this.extractDragData(aRow); + if (!results.canDrop) { + return; + } + + // Preselect the drop folder. + this.selection.select(aRow); + + if (results.dropUrl) { + // Don't freeze the app that initiated the drop just because we are + // in a loop waiting for the user to dimisss the add feed dialog. + setTimeout(() => { + win.addFeed(results.dropUrl, null, true, null, win.kSubscribeMode); + }, 0); + + let folderItem = this.getItemAtIndex(aRow); + FeedUtils.log.debug( + "drop: folder, url - " + + folderItem.folder.name + + ", " + + results.dropUrl + ); + } else if (results.dropOnIndex != this.kRowIndexUndefined) { + win.moveCopyFeed(results.dropOnIndex, aRow, results.dropEffect); + } + }, + + // Helper function for drag and drop. + extractDragData(aRow) { + let dt = this._currentDataTransfer; + let dragDataResults = { + canDrop: false, + dropUrl: null, + dropOnIndex: this.kRowIndexUndefined, + dropEffect: dt.dropEffect, + }; + + if (dt.getData("text/x-moz-feed-index")) { + // Dragging a feed in the tree. + if (this.selection) { + dragDataResults.dropOnIndex = this.selection.currentIndex; + + let curItem = this.getItemAtIndex(this.selection.currentIndex); + let newItem = this.getItemAtIndex(aRow); + let curServer = + curItem && curItem.parentFolder + ? curItem.parentFolder.server + : null; + let newServer = + newItem && newItem.folder ? newItem.folder.server : null; + + // No copying within the same account and no moving to the account + // folder in the same account. + if ( + !( + curServer == newServer && + (dragDataResults.dropEffect == "copy" || + newItem.folder == curItem.parentFolder || + newItem.folder.isServer) + ) + ) { + dragDataResults.canDrop = true; + } + } + } else { + // Try to get a feed url. + let validUri = FeedUtils.getFeedUriFromDataTransfer(dt); + + if (validUri) { + dragDataResults.canDrop = true; + dragDataResults.dropUrl = validUri.spec; + } + } + + return dragDataResults; + }, + + getParentIndex(aRow) { + let item = this.getItemAtIndex(aRow); + + if (item) { + for (let index = aRow; index >= 0; index--) { + if (FeedSubscriptions.mFeedContainers[index].level < item.level) { + return index; + } + } + } + + return this.kRowIndexUndefined; + }, + + isIndexChildOfParentIndex(aRow, aChildRow) { + // For visible tree rows, not if items are children of closed folders. + let item = this.getItemAtIndex(aRow); + if (!item || aChildRow <= aRow) { + return false; + } + + let targetLevel = this.getItemAtIndex(aRow).level; + let rows = FeedSubscriptions.mFeedContainers; + + for (let i = aRow + 1; i < rows.length; i++) { + if (this.getItemAtIndex(i).level <= targetLevel) { + break; + } + if (aChildRow == i) { + return true; + } + } + + return false; + }, + + hasNextSibling(aRow, aAfterIndex) { + let targetLevel = this.getItemAtIndex(aRow).level; + let rows = FeedSubscriptions.mFeedContainers; + for (let i = aAfterIndex + 1; i < rows.length; i++) { + if (this.getItemAtIndex(i).level == targetLevel) { + return true; + } + if (this.getItemAtIndex(i).level < targetLevel) { + return false; + } + } + + return false; + }, + + hasPreviousSibling(aRow) { + let item = this.getItemAtIndex(aRow); + if (item && aRow) { + return this.getItemAtIndex(aRow - 1).level == item.level; + } + + return false; + }, + + getLevel(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return 0; + } + + return item.level; + }, + + toggleOpenState(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return; + } + + // Save off the current selection item. + let seln = this.selection; + let currentSelectionIndex = seln.currentIndex; + + let rowsChanged = this.toggle(aRow); + + // Now restore selection, ensuring selection is maintained on toggles. + if (currentSelectionIndex > aRow) { + seln.currentIndex = currentSelectionIndex + rowsChanged; + } else { + seln.select(currentSelectionIndex); + } + + seln.selectEventsSuppressed = false; + }, + + toggle(aRow) { + // Collapse the row, or build sub rows based on open states in the map. + let item = this.getItemAtIndex(aRow); + if (!item) { + return null; + } + + let rows = FeedSubscriptions.mFeedContainers; + let rowCount = 0; + let multiplier; + + function addDescendants(aItem) { + for (let i = 0; i < aItem.children.length; i++) { + rowCount++; + let child = aItem.children[i]; + rows.splice(aRow + rowCount, 0, child); + if (child.open) { + addDescendants(child); + } + } + } + + if (item.open) { + // Close the container. Add up all subfolders and their descendants + // who may be open. + multiplier = -1; + let nextRow = aRow + 1; + let nextItem = rows[nextRow]; + while (nextItem && nextItem.level > item.level) { + rowCount++; + nextItem = rows[++nextRow]; + } + + rows.splice(aRow + 1, rowCount); + } else { + // Open the container. Restore the open state of all subfolder and + // their descendants. + multiplier = 1; + addDescendants(item); + } + + let delta = multiplier * rowCount; + this.mRowCount += delta; + + item.open = !item.open; + // Suppress the select event caused by rowCountChanged. + this.selection.selectEventsSuppressed = true; + // Add or remove the children from our view. + this.tree.rowCountChanged(aRow, delta); + return delta; + }, + }, + + makeFolderObject(aFolder, aCurrentLevel) { + let defaultQuickMode = aFolder.server.getBoolValue("quickMode"); + let optionsAcct = aFolder.isServer + ? FeedUtils.getOptionsAcct(aFolder.server) + : null; + let open = + !aFolder.isServer && + aFolder.server == this.mRSSServer && + this.mActionMode == this.kImportingOPML; + let folderObject = { + children: [], + folder: aFolder, + name: aFolder.prettyName, + level: aCurrentLevel, + url: aFolder.URI, + quickMode: defaultQuickMode, + options: optionsAcct, + open, + container: true, + favicon: null, + }; + + // If a feed has any sub folders, add them to the list of children. + for (let folder of aFolder.subFolders) { + if ( + folder instanceof Ci.nsIMsgFolder && + !folder.getFlag(Ci.nsMsgFolderFlags.Trash) && + !folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + folderObject.children.push( + this.makeFolderObject(folder, aCurrentLevel + 1) + ); + } + } + + let feeds = this.getFeedsInFolder(aFolder); + for (let feed of feeds) { + // Now add any feed urls for the folder. + folderObject.children.push( + this.makeFeedObject(feed, aFolder, aCurrentLevel + 1) + ); + } + + // Finally, set the folder's quickMode based on the its first feed's + // quickMode, since that is how the view determines summary mode, and now + // quickMode is updated to be the same for all feeds in a folder. + if (feeds && feeds[0]) { + folderObject.quickMode = feeds[0].quickMode; + } + + folderObject.children = this.folderItemSorter(folderObject.children); + + return folderObject; + }, + + folderItemSorter(aArray) { + return aArray + .sort(function (a, b) { + return a.name.toLowerCase() > b.name.toLowerCase(); + }) + .sort(function (a, b) { + return a.container < b.container; + }); + }, + + getFeedsInFolder(aFolder) { + let feeds = []; + let feedUrlArray = FeedUtils.getFeedUrlsInFolder(aFolder); + if (!feedUrlArray) { + // No feedUrls in this folder. + return feeds; + } + + for (let url of feedUrlArray) { + let feed = new Feed(url, aFolder); + feeds.push(feed); + } + + return feeds; + }, + + makeFeedObject(aFeed, aFolder, aLevel) { + // Look inside the data source for the feed properties. + let feed = { + children: [], + parentFolder: aFolder, + name: aFeed.title || aFeed.description || aFeed.url, + url: aFeed.url, + quickMode: aFeed.quickMode, + options: aFeed.options || FeedUtils.optionsTemplate, + level: aLevel, + open: false, + container: false, + favicon: null, + }; + return feed; + }, + + loadSubscriptions() { + // Put together an array of folders. Each feed account level folder is + // included as the root. + let numFolders = 0; + let feedContainers = []; + // Get all the feed account folders. + let feedRootFolders = FeedUtils.getAllRssServerRootFolders(); + + feedRootFolders.forEach(function (rootFolder) { + feedContainers.push(this.makeFolderObject(rootFolder, 0)); + numFolders++; + }, this); + + this.mFeedContainers = feedContainers; + this.mView.mRowCount = numFolders; + + FeedSubscriptions.mTree.focus(); + }, + + /** + * Find the folder in the tree. The search may be limited to subfolders of + * a known folder, or expanded to include the entire tree. This function is + * also used to insert/remove folders without rebuilding the tree view cache + * (to avoid position/toggle state loss). + * + * @param {nsIMsgFolder} aFolder - The folder to find. + * @param {object} aParms - The params object, containing: + * + * {Integer} parentIndex - index of folder to start the search; if + * null (default), the index of the folder's + * rootFolder will be used. + * {boolean} select - if true (default) the folder's ancestors + * will be opened and the folder selected. + * {boolean} open - if true (default) the folder is opened. + * {boolean} remove - delete the item from tree row cache if true, + * false (default) otherwise. + * {nsIMsgFolder} newFolder - if not null (default) the new folder, + * for add or rename. + * + * @returns {Boolean} found - true if found, false if not. + */ + selectFolder(aFolder, aParms) { + let folderURI = aFolder.URI; + let parentIndex = + aParms && "parentIndex" in aParms ? aParms.parentIndex : null; + let selectIt = aParms && "select" in aParms ? aParms.select : true; + let openIt = aParms && "open" in aParms ? aParms.open : true; + let removeIt = aParms && "remove" in aParms ? aParms.remove : false; + let newFolder = aParms && "newFolder" in aParms ? aParms.newFolder : null; + let startIndex, startItem; + let found = false; + + let firstVisRow, curFirstVisRow, curLastVisRow; + if (this.mView.tree) { + firstVisRow = this.mView.tree.getFirstVisibleRow(); + } + + if (parentIndex != null) { + // Use the parentIndex if given. + startIndex = parentIndex; + if (aFolder.isServer) { + // Fake item for account root folder. + startItem = { + name: "AccountRoot", + children: [this.mView.getItemAtIndex(startIndex)], + container: true, + open: false, + url: null, + level: -1, + }; + } else { + startItem = this.mView.getItemAtIndex(startIndex); + } + } else { + // Get the folder's root parent index. + let index = 0; + for (index; index < this.mView.rowCount; index++) { + let item = this.mView.getItemAtIndex(index); + if (item.url == aFolder.server.rootFolder.URI) { + break; + } + } + + startIndex = index; + if (aFolder.isServer) { + // Fake item for account root folder. + startItem = { + name: "AccountRoot", + children: [this.mView.getItemAtIndex(startIndex)], + container: true, + open: false, + url: null, + level: -1, + }; + } else { + startItem = this.mView.getItemAtIndex(startIndex); + } + } + + function containsFolder(aItem) { + // Search for the folder. If it's found, set the open state on all + // ancestor folders. A toggle() rebuilds the view rows to match the map. + if (aItem.url == folderURI) { + return (found = true); + } + + for (let i = 0; i < aItem.children.length; i++) { + if (aItem.children[i].container && containsFolder(aItem.children[i])) { + if (removeIt && aItem.children[i].url == folderURI) { + // Get all occurrences in the tree cache arrays. + FeedUtils.log.debug( + "selectFolder: delete in cache, " + + "parent:children:item:index - " + + aItem.name + + ":" + + aItem.children.length + + ":" + + aItem.children[i].name + + ":" + + i + ); + aItem.children.splice(i, 1); + FeedUtils.log.debug( + "selectFolder: deleted in cache, " + + "parent:children - " + + aItem.name + + ":" + + aItem.children.length + ); + removeIt = false; + return true; + } + if (newFolder) { + let newItem = FeedSubscriptions.makeFolderObject( + newFolder, + aItem.level + 1 + ); + newItem.open = aItem.children[i].open; + if (newFolder.isServer) { + FeedSubscriptions.mFeedContainers[startIndex] = newItem; + } else { + aItem.children[i] = newItem; + aItem.children = FeedSubscriptions.folderItemSorter( + aItem.children + ); + } + FeedUtils.log.trace( + "selectFolder: parentName:newFolderName:newFolderItem - " + + aItem.name + + ":" + + newItem.name + + ":" + + newItem.toSource() + ); + newFolder = null; + return true; + } + if (!found) { + // For the folder to find. + found = true; + aItem.children[i].open = openIt; + } else if (selectIt || openIt) { + // For ancestor folders. + aItem.children[i].open = true; + } + + return true; + } + } + + return false; + } + + if (startItem) { + // Find a folder with a specific parent. + containsFolder(startItem); + if (!found) { + return false; + } + + if (!selectIt) { + return true; + } + + if (startItem.open) { + this.mView.toggle(startIndex); + } + + this.mView.toggleOpenState(startIndex); + } + + for (let index = 0; index < this.mView.rowCount && selectIt; index++) { + // The desired folder is now in the view. + let item = this.mView.getItemAtIndex(index); + if (!item.container) { + continue; + } + + if (item.url == folderURI) { + if ( + item.children.length && + ((!item.open && openIt) || (item.open && !openIt)) + ) { + this.mView.toggleOpenState(index); + } + + this.mView.selection.select(index); + found = true; + break; + } + } + + // Ensure tree position does not jump unnecessarily. + curFirstVisRow = this.mView.tree.getFirstVisibleRow(); + curLastVisRow = this.mView.tree.getLastVisibleRow(); + if ( + firstVisRow >= 0 && + this.mView.rowCount - curLastVisRow > firstVisRow - curFirstVisRow + ) { + this.mView.tree.scrollToRow(firstVisRow); + } else { + this.mView.tree.ensureRowIsVisible(this.mView.rowCount - 1); + } + + FeedUtils.log.debug( + "selectFolder: curIndex:firstVisRow:" + + "curFirstVisRow:curLastVisRow:rowCount - " + + this.mView.selection.currentIndex + + ":" + + firstVisRow + + ":" + + curFirstVisRow + + ":" + + curLastVisRow + + ":" + + this.mView.rowCount + ); + return found; + }, + + /** + * Find the feed in the tree. The search first gets the feed's folder, + * then selects the child feed. + * + * @param {Feed} aFeed - The feed to find. + * @param {Integer} aParentIndex - Index to start the folder search. + * + * @returns {Boolean} found - true if found, false if not. + */ + selectFeed(aFeed, aParentIndex) { + let folder = aFeed.folder; + let server = aFeed.server || aFeed.folder.server; + let found = false; + + if (aFeed.folder.isServer) { + // If passed the root folder, the caller wants to get the feed's folder + // from the db (for cases of an ancestor folder rename/move). + let destFolder = FeedUtils.getSubscriptionAttr( + aFeed.url, + server, + "destFolder" + ); + folder = server.rootFolder.getChildWithURI(destFolder, true, false); + } + + if (this.selectFolder(folder, { parentIndex: aParentIndex })) { + let seln = this.mView.selection; + let item = this.mView.currentItem; + if (item) { + for (let i = seln.currentIndex + 1; i < this.mView.rowCount; i++) { + if (this.mView.getItemAtIndex(i).url == aFeed.url) { + this.mView.selection.select(i); + this.mView.tree.ensureRowIsVisible(i); + found = true; + break; + } + } + } + } + + return found; + }, + + updateFeedData(aItem) { + if (!aItem) { + return; + } + + let nameValue = document.getElementById("nameValue"); + let locationValue = document.getElementById("locationValue"); + let locationValidate = document.getElementById("locationValidate"); + let isServer = aItem.folder && aItem.folder.isServer; + let isFolder = aItem.folder && !aItem.folder.isServer; + let isFeed = !aItem.container; + let server, displayFolder; + + if (isFeed) { + // A feed item. Set the feed location and title info. + nameValue.value = aItem.name; + locationValue.value = aItem.url; + locationValidate.removeAttribute("collapsed"); + + // Root the location picker to the news & blogs server. + server = aItem.parentFolder.server; + displayFolder = aItem.parentFolder; + } else { + // A folder/container item. + nameValue.value = ""; + nameValue.disabled = true; + locationValue.value = ""; + locationValidate.setAttribute("collapsed", true); + + server = aItem.folder.server; + displayFolder = aItem.folder; + } + + // Common to both folder and feed items. + nameValue.disabled = aItem.container; + this.setFolderPicker(displayFolder, isFeed); + + // Set quick mode value. + document.getElementById("quickMode").checked = aItem.quickMode; + + if (isServer) { + aItem.options = FeedUtils.getOptionsAcct(server); + } + + // Update items. + let updateEnabled = document.getElementById("updateEnabled"); + let updateValue = document.getElementById("updateValue"); + let biffUnits = document.getElementById("biffUnits"); + let recommendedUnits = document.getElementById("recommendedUnits"); + let recommendedUnitsVal = document.getElementById("recommendedUnitsVal"); + let updates = aItem.options + ? aItem.options.updates + : FeedUtils._optionsDefault.updates; + + updateEnabled.checked = updates.enabled; + updateValue.disabled = !updateEnabled.checked || isFolder; + biffUnits.disabled = !updateEnabled.checked || isFolder; + biffUnits.value = updates.updateUnits; + let minutes = + updates.updateUnits == FeedUtils.kBiffUnitsMinutes + ? updates.updateMinutes + : updates.updateMinutes / (24 * 60); + updateValue.value = Number(minutes); + if (isFeed) { + recommendedUnitsVal.value = this.getUpdateMinutesRec(updates); + } else { + recommendedUnitsVal.value = ""; + } + + let hideRec = recommendedUnitsVal.value == ""; + recommendedUnits.hidden = hideRec; + recommendedUnitsVal.hidden = hideRec; + + // Autotag items. + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + let category = aItem.options ? aItem.options.category : null; + + autotagEnable.checked = category && category.enabled; + autotagUsePrefix.checked = category && category.prefixEnabled; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + autotagPrefix.value = category && category.prefix ? category.prefix : ""; + }, + + setFolderPicker(aFolder, aIsFeed) { + let folderPrettyPath = FeedUtils.getFolderPrettyPath(aFolder); + if (!folderPrettyPath) { + return; + } + + let selectFolder = document.getElementById("selectFolder"); + let selectFolderPopup = document.getElementById("selectFolderPopup"); + let selectFolderValue = document.getElementById("selectFolderValue"); + + selectFolder.setAttribute("hidden", !aIsFeed); + selectFolder._folder = aFolder; + selectFolderValue.toggleAttribute("hidden", aIsFeed); + selectFolderValue.setAttribute("showfilepath", false); + + if (aIsFeed) { + selectFolderPopup._ensureInitialized(); + selectFolderPopup.selectFolder(aFolder); + selectFolder.setAttribute("label", folderPrettyPath); + selectFolder.setAttribute("uri", aFolder.URI); + } else { + selectFolderValue.value = folderPrettyPath; + selectFolderValue.setAttribute("prettypath", folderPrettyPath); + selectFolderValue.setAttribute("filepath", aFolder.filePath.path); + } + }, + + onClickSelectFolderValue(aEvent) { + let target = aEvent.target; + if ( + ("button" in aEvent && + (aEvent.button != 0 || + aEvent.target.localName != "div" || + target.selectionStart != target.selectionEnd)) || + (aEvent.keyCode && aEvent.keyCode != aEvent.DOM_VK_RETURN) + ) { + return; + } + + // Toggle between showing prettyPath and absolute filePath. + if (target.getAttribute("showfilepath") == "true") { + target.setAttribute("showfilepath", false); + target.value = target.getAttribute("prettypath"); + } else { + target.setAttribute("showfilepath", true); + target.value = target.getAttribute("filepath"); + } + }, + + /** + * The user changed the folder for storing the feed. + * + * @param {Event} aEvent - Event. + * @returns {void} + */ + setNewFolder(aEvent) { + aEvent.stopPropagation(); + this.setFolderPicker(aEvent.target._folder, true); + + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container || !item.parentFolder) { + return; + } + + let selectFolder = document.getElementById("selectFolder"); + let editFolderURI = selectFolder.getAttribute("uri"); + if (item.parentFolder.URI == editFolderURI) { + return; + } + + let feed = new Feed(item.url, item.parentFolder); + + // Make sure the new folderpicked folder is visible. + this.selectFolder(selectFolder._folder); + // Now go back to the feed item. + this.selectFeed(feed, null); + // We need to find the index of the new parent folder. + let newParentIndex = this.mView.kRowIndexUndefined; + for (let index = 0; index < this.mView.rowCount; index++) { + let item = this.mView.getItemAtIndex(index); + if (item && item.container && item.url == editFolderURI) { + newParentIndex = index; + break; + } + } + + if (newParentIndex != this.mView.kRowIndexUndefined) { + this.moveCopyFeed(seln.currentIndex, newParentIndex, "move"); + } + }, + + setSummary(aChecked) { + let item = this.mView.currentItem; + if (!item || !item.folder) { + // Not a folder. + return; + } + + if (item.folder.isServer) { + if (document.getElementById("locationValue").value) { + // Intent is to add a feed/folder to the account, so return. + return; + } + + // An account folder. If it changes, all non feed containing subfolders + // need to be updated with the new default. + item.folder.server.setBoolValue("quickMode", aChecked); + this.FolderListener.folderAdded(item.folder); + } else if (!FeedUtils.getFeedUrlsInFolder(item.folder)) { + // Not a folder with feeds. + return; + } else { + let feedsInFolder = this.getFeedsInFolder(item.folder); + // Update the feeds database, for each feed in the folder. + feedsInFolder.forEach(function (feed) { + feed.quickMode = aChecked; + }); + // Update the folder's feeds properties in the tree map. + item.children.forEach(function (feed) { + feed.quickMode = aChecked; + }); + } + + // Update the folder in the tree map. + item.quickMode = aChecked; + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + setPrefs(aNode) { + let item = this.mView.currentItem; + if (!item) { + return; + } + + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + let updateEnabled = document.getElementById("updateEnabled"); + let updateValue = document.getElementById("updateValue"); + let biffUnits = document.getElementById("biffUnits"); + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + if ( + isFolder || + (isServer && document.getElementById("locationValue").value) + ) { + // Intend to subscribe a feed to a folder, a value must be in the url + // field. Update states for addFeed() and return. + updateValue.disabled = !updateEnabled.checked; + biffUnits.disabled = !updateEnabled.checked; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + return; + } + + switch (aNode.id) { + case "nameValue": + // Check to see if the title value changed, no blank title allowed. + if (!aNode.value) { + aNode.value = item.name; + return; + } + + item.name = aNode.value; + let seln = this.mView.selection; + seln.tree.invalidateRow(seln.currentIndex); + break; + case "locationValue": + let updateFeedButton = document.getElementById("updateFeed"); + // Change label based on whether feed url has beed edited. + updateFeedButton.label = + aNode.value == item.url + ? updateFeedButton.getAttribute("verifylabel") + : updateFeedButton.getAttribute("updatelabel"); + updateFeedButton.setAttribute( + "accesskey", + aNode.value == item.url + ? updateFeedButton.getAttribute("verifyaccesskey") + : updateFeedButton.getAttribute("updateaccesskey") + ); + // Disable the Update button if no feed url value is entered. + updateFeedButton.disabled = !aNode.value; + return; + case "updateEnabled": + case "updateValue": + case "biffUnits": + item.options.updates.enabled = updateEnabled.checked; + let minutes = + biffUnits.value == FeedUtils.kBiffUnitsMinutes + ? updateValue.value + : updateValue.value * 24 * 60; + item.options.updates.updateMinutes = Number(minutes); + item.options.updates.updateUnits = biffUnits.value; + break; + case "autotagEnable": + item.options.category.enabled = aNode.checked; + break; + case "autotagUsePrefix": + item.options.category.prefixEnabled = aNode.checked; + item.options.category.prefix = autotagPrefix.value; + break; + case "autotagPrefix": + item.options.category.prefix = aNode.value; + break; + } + + if (isServer) { + FeedUtils.setOptionsAcct(item.folder.server, item.options); + } else { + let feed = new Feed(item.url, item.parentFolder); + feed.title = item.name; + feed.options = item.options; + + if (aNode.id == "updateEnabled") { + FeedUtils.setStatus( + item.parentFolder, + item.url, + "enabled", + aNode.checked + ); + this.mView.selection.tree.invalidateRow( + this.mView.selection.currentIndex + ); + } + if (aNode.id == "updateValue") { + FeedUtils.setStatus( + item.parentFolder, + item.url, + "updateMinutes", + item.options.updates.updateMinutes + ); + } + } + + this.updateFeedData(item); + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + getUpdateMinutesRec(aUpdates) { + // Assume the parser has stored correct/valid values for the spec. If the + // feed doesn't use any of these tags, updatePeriod will be null. + if (aUpdates.updatePeriod == null) { + return ""; + } + + let biffUnits = document.getElementById("biffUnits").value; + let units = biffUnits == FeedUtils.kBiffUnitsDays ? 1 : 24 * 60; + let frequency = aUpdates.updateFrequency; + let val; + switch (aUpdates.updatePeriod) { + case "hourly": + val = + biffUnits == FeedUtils.kBiffUnitsDays + ? 1 / frequency / 24 + : 60 / frequency; + break; + case "daily": + val = units / frequency; + break; + case "weekly": + val = (7 * units) / frequency; + break; + case "monthly": + val = (30 * units) / frequency; + break; + case "yearly": + val = (365 * units) / frequency; + break; + } + + return val ? Math.round(val * 1000) / 1000 : ""; + }, + + onKeyPress(aEvent) { + if ( + aEvent.keyCode == aEvent.DOM_VK_DELETE && + aEvent.target.id == "rssSubscriptionsList" + ) { + this.removeFeed(true); + } + + this.clearStatusInfo(); + }, + + onSelect() { + let item = this.mView.currentItem; + this.updateFeedData(item); + this.setFocus(); + this.updateButtons(item); + }, + + updateButtons(aSelectedItem) { + let item = aSelectedItem; + let isServer = item && item.folder && item.folder.isServer; + let isFeed = item && !item.container; + document.getElementById("addFeed").hidden = !item || isFeed; + document.getElementById("updateFeed").hidden = !isFeed; + document.getElementById("removeFeed").hidden = !isFeed; + document.getElementById("importOPML").hidden = !isServer; + document.getElementById("exportOPML").hidden = !isServer; + + document.getElementById("importOPML").disabled = document.getElementById( + "exportOPML" + ).disabled = this.mActionMode == this.kImportingOPML; + }, + + onMouseDown(aEvent) { + if ( + aEvent.button != 0 || + aEvent.target.id == "validationText" || + aEvent.target.id == "addCertException" + ) { + return; + } + + this.clearStatusInfo(); + }, + + onFocusChange() { + setTimeout(() => { + this.setFocus(); + }, 0); + }, + + setFocus() { + let item = this.mView.currentItem; + if (!item || this.mActionMode == this.kImportingOPML) { + return; + } + + let locationValue = document.getElementById("locationValue"); + let updateEnabled = document.getElementById("updateEnabled"); + + let quickMode = document.getElementById("quickMode"); + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + + let addFeedButton = document.getElementById("addFeed"); + let updateFeedButton = document.getElementById("updateFeed"); + + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + + // Enabled by default. + updateEnabled.disabled = + quickMode.disabled = + autotagEnable.disabled = + false; + + updateEnabled.parentNode + .querySelectorAll("input,radio,label") + .forEach(item => { + item.disabled = !updateEnabled.checked; + }); + + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + + let focusedElement = window.document.commandDispatcher.focusedElement; + + if (isServer) { + addFeedButton.disabled = + addFeedButton != focusedElement && + locationValue != document.activeElement && + !locationValue.value; + } else if (isFolder) { + let disable = + locationValue != document.activeElement && !locationValue.value; + // Summary is enabled for a folder with feeds or if adding a feed. + quickMode.disabled = + disable && !FeedUtils.getFeedUrlsInFolder(item.folder); + // All other options disabled unless intent is to add a feed. + updateEnabled.disabled = disable; + updateEnabled.parentNode + .querySelectorAll("input,radio,label") + .forEach(item => { + item.disabled = disable; + }); + + autotagEnable.disabled = disable; + + addFeedButton.disabled = + addFeedButton != focusedElement && + locationValue != document.activeElement && + !locationValue.value; + } else { + // Summary is disabled; applied per folder to apply to all feeds in it. + quickMode.disabled = true; + // Ensure the current feed url is restored if the user did not update. + if ( + locationValue.value != item.url && + locationValue != document.activeElement && + focusedElement != updateFeedButton && + focusedElement.id != "addCertException" + ) { + locationValue.value = item.url; + } + this.setPrefs(locationValue); + // Set button state. + updateFeedButton.disabled = !locationValue.value; + } + }, + + removeFeed(aPrompt) { + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let itemToRemove = this.mView.getItemAtIndex(seln.currentIndex); + + if (!itemToRemove || itemToRemove.container) { + return; + } + + if (aPrompt) { + // Confirm unsubscribe prompt. + let pTitle = FeedUtils.strings.GetStringFromName( + "subscribe-confirmFeedDeletionTitle" + ); + let pMessage = FeedUtils.strings.formatStringFromName( + "subscribe-confirmFeedDeletion", + [itemToRemove.name] + ); + if ( + Services.prompt.confirmEx( + window, + pTitle, + pMessage, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) + ) { + return; + } + } + + let feed = new Feed(itemToRemove.url, itemToRemove.parentFolder); + FeedUtils.deleteFeed(feed); + + // Now that we have removed the feed from the datasource, it is time to + // update our view layer. Update parent folder's quickMode if necessary + // and remove the child from its parent folder object. + let parentIndex = this.mView.getParentIndex(seln.currentIndex); + let parentItem = this.mView.getItemAtIndex(parentIndex); + this.updateFolderQuickModeInView(itemToRemove, parentItem, true); + this.mView.removeItemAtIndex(seln.currentIndex, false); + let message = FeedUtils.strings.GetStringFromName("subscribe-feedRemoved"); + this.updateStatusItem("statusText", message); + }, + + /** + * This addFeed is used by 1) Add button, 1) Update button, 3) Drop of a + * feed url on a folder (which can be an add or move). If Update, the new + * url is added and the old removed; thus aParse is false and no new messages + * are downloaded, the feed is only validated and stored in the db. If dnd, + * the drop folder is selected and the url is prefilled, so proceed just as + * though the url were entered manually. This allows a user to see the dnd + * url better in case of errors. + * + * @param {String} aFeedLocation - the feed url; get the url from the + * input field if null. + * @param {nsIMsgFolder} aFolder - folder to subscribe, current selected + * folder if null. + * @param {Boolean} aParse - if true (default) parse and download + * the feed's articles. + * @param {Object} aParams - additional params. + * @param {Integer} aMode - action mode (default is kSubscribeMode) + * of the add. + * + * @returns {Boolean} success - true if edit checks passed and an + * async download has been initiated. + */ + addFeed(aFeedLocation, aFolder, aParse, aParams, aMode) { + let message; + let parse = aParse == null ? true : aParse; + let mode = aMode == null ? this.kSubscribeMode : aMode; + let locationValue = document.getElementById("locationValue"); + let quickMode = + aParams && "quickMode" in aParams + ? aParams.quickMode + : document.getElementById("quickMode").checked; + let name = + aParams && "name" in aParams + ? aParams.name + : document.getElementById("nameValue").value; + let options = aParams && "options" in aParams ? aParams.options : null; + + if (aFeedLocation) { + locationValue.value = aFeedLocation; + } + let feedLocation = locationValue.value.trim(); + + if (!feedLocation) { + locationValue.focus(); + message = locationValue.getAttribute("placeholder"); + this.updateStatusItem("statusText", message); + return false; + } + + if (!FeedUtils.isValidScheme(feedLocation)) { + locationValue.focus(); + message = FeedUtils.strings.GetStringFromName("subscribe-feedNotValid"); + this.updateStatusItem("statusText", message); + return false; + } + + let addFolder; + if (aFolder) { + // For Update or if passed a folder. + if (aFolder instanceof Ci.nsIMsgFolder) { + addFolder = aFolder; + } + } else { + // A folder must be selected for Add and Drop. + let index = this.mView.selection.currentIndex; + let item = this.mView.getItemAtIndex(index); + if (item && item.container) { + addFolder = item.folder; + } + } + + // Shouldn't happen. Or else not passed an nsIMsgFolder. + if (!addFolder) { + return false; + } + + // Before we go any further, make sure the user is not already subscribed + // to this feed. + if (FeedUtils.feedAlreadyExists(feedLocation, addFolder.server)) { + locationValue.focus(); + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedAlreadySubscribed" + ); + this.updateStatusItem("statusText", message); + return false; + } + + if (!options) { + // Not passed a param, get values from the ui. + options = FeedUtils.optionsTemplate; + options.updates.enabled = + document.getElementById("updateEnabled").checked; + let biffUnits = document.getElementById("biffUnits").value; + let units = document.getElementById("updateValue").value; + let minutes = + biffUnits == FeedUtils.kBiffUnitsMinutes ? units : units * 24 * 60; + options.updates.updateUnits = biffUnits; + options.updates.updateMinutes = Number(minutes); + options.category.enabled = + document.getElementById("autotagEnable").checked; + options.category.prefixEnabled = + document.getElementById("autotagUsePrefix").checked; + options.category.prefix = document.getElementById("autotagPrefix").value; + } + + let feedProperties = { + feedName: name, + feedLocation, + feedFolder: addFolder, + quickMode, + options, + }; + + let feed = this.storeFeed(feedProperties); + if (!feed) { + return false; + } + + // Now validate and start downloading the feed. + message = FeedUtils.strings.GetStringFromName("subscribe-validating-feed"); + this.updateStatusItem("statusText", message); + this.updateStatusItem("progressMeter", "?"); + document.getElementById("addFeed").disabled = true; + this.mActionMode = mode; + feed.download(parse, this.mFeedDownloadCallback); + return true; + }, + + // Helper routine used by addFeed and importOPMLFile. + storeFeed(feedProperties) { + let feed = new Feed(feedProperties.feedLocation, feedProperties.feedFolder); + feed.title = feedProperties.feedName; + feed.quickMode = feedProperties.quickMode; + feed.options = feedProperties.options; + return feed; + }, + + /** + * When a feed item is selected, the Update button is used to verify the + * existing feed url, or to verify and update the feed url if the field + * has been edited. This is the only use of the Update button. + * + * @returns {void} + */ + updateFeed() { + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container || !item.parentFolder) { + return; + } + + let feed = new Feed(item.url, item.parentFolder); + + // Disable the button. + document.getElementById("updateFeed").disabled = true; + + let feedLocation = document.getElementById("locationValue").value.trim(); + if (feed.url != feedLocation) { + // Updating a url. We need to add the new url and delete the old, to + // ensure everything is cleaned up correctly. + this.addFeed(null, item.parentFolder, false, null, this.kUpdateMode); + return; + } + + // Now we want to verify if the stored feed url still works. If it + // doesn't, show the error. + let message = FeedUtils.strings.GetStringFromName( + "subscribe-validating-feed" + ); + this.mActionMode = this.kVerifyUrlMode; + this.updateStatusItem("statusText", message); + this.updateStatusItem("progressMeter", "?"); + feed.download(false, this.mFeedDownloadCallback); + }, + + /** + * Moves or copies a feed to another folder or account. + * + * @param {Integer} aOldFeedIndex - Index in tree of target feed item. + * @param {Integer} aNewParentIndex - Index in tree of target parent folder item. + * @param {String} aMoveCopy - Either "move" or "copy". + * + * @returns {void} + */ + moveCopyFeed(aOldFeedIndex, aNewParentIndex, aMoveCopy) { + let moveFeed = aMoveCopy == "move"; + let currentItem = this.mView.getItemAtIndex(aOldFeedIndex); + if ( + !currentItem || + this.mView.getParentIndex(aOldFeedIndex) == aNewParentIndex + ) { + // If the new parent is the same as the current parent, then do nothing. + return; + } + + let currentParentIndex = this.mView.getParentIndex(aOldFeedIndex); + let currentParentItem = this.mView.getItemAtIndex(currentParentIndex); + let currentFolder = currentParentItem.folder; + + let newParentItem = this.mView.getItemAtIndex(aNewParentIndex); + let newFolder = newParentItem.folder; + + let accountMoveCopy = false; + if (currentFolder.rootFolder.URI == newFolder.rootFolder.URI) { + // Moving within the same account/feeds db. + if (newFolder.isServer || !moveFeed) { + // No moving to account folder if already in the account; can only move, + // not copy, to folder in the same account. + return; + } + + // Update the destFolder for this feed's subscription. + FeedUtils.setSubscriptionAttr( + currentItem.url, + currentItem.parentFolder.server, + "destFolder", + newFolder.URI + ); + + // Update folderpane favicons. + Services.obs.notifyObservers(currentFolder, "folder-properties-changed"); + Services.obs.notifyObservers(newFolder, "folder-properties-changed"); + } else { + // Moving/copying to a new account. If dropping on the account folder, + // a new subfolder is created if necessary. + accountMoveCopy = true; + let mode = moveFeed ? this.kMoveMode : this.kCopyMode; + let params = { + quickMode: currentItem.quickMode, + name: currentItem.name, + options: currentItem.options, + }; + // Subscribe to the new folder first. If it already exists in the + // account or on error, return. + if (!this.addFeed(currentItem.url, newFolder, false, params, mode)) { + return; + } + // Unsubscribe the feed from the old folder, if add to the new folder + // is successful, and doing a move. + if (moveFeed) { + let feed = new Feed(currentItem.url, currentItem.parentFolder); + FeedUtils.deleteFeed(feed); + } + } + + // Update local favicons. + currentParentItem.favicon = newParentItem.favicon = null; + + // Finally, update our view layer. Update old parent folder's quickMode + // and remove the old row, if move. Otherwise no change to the view. + if (moveFeed) { + this.updateFolderQuickModeInView(currentItem, currentParentItem, true); + this.mView.removeItemAtIndex(aOldFeedIndex, true); + if (aNewParentIndex > aOldFeedIndex) { + aNewParentIndex--; + } + } + + if (accountMoveCopy) { + // If a cross account move/copy, download callback will update the view + // with the new location. Preselect folder/mode for callback. + this.selectFolder(newFolder, { parentIndex: aNewParentIndex }); + return; + } + + // Add the new row location to the view. + currentItem.level = newParentItem.level + 1; + currentItem.parentFolder = newFolder; + this.updateFolderQuickModeInView(currentItem, newParentItem, false); + newParentItem.children.push(currentItem); + + if (newParentItem.open) { + // Close the container, selecting the feed will rebuild the view rows. + this.mView.toggle(aNewParentIndex); + } + + this.selectFeed( + { folder: newParentItem.folder, url: currentItem.url }, + aNewParentIndex + ); + + let message = FeedUtils.strings.GetStringFromName("subscribe-feedMoved"); + this.updateStatusItem("statusText", message); + }, + + updateFolderQuickModeInView(aFeedItem, aParentItem, aRemove) { + let feedItem = aFeedItem; + let parentItem = aParentItem; + let feedUrlArray = FeedUtils.getFeedUrlsInFolder(feedItem.parentFolder); + let feedsInFolder = feedUrlArray ? feedUrlArray.length : 0; + + if (aRemove && feedsInFolder < 1) { + // Removed only feed in folder; set quickMode to server default. + parentItem.quickMode = parentItem.folder.server.getBoolValue("quickMode"); + } + + if (!aRemove) { + // Just added a feed to a folder. If there are already feeds in the + // folder, the feed must reflect the parent's quickMode. If it is the + // only feed, update the parent folder to the feed's quickMode. + if (feedsInFolder > 1) { + let feed = new Feed(feedItem.url, feedItem.parentFolder); + feed.quickMode = parentItem.quickMode; + feedItem.quickMode = parentItem.quickMode; + } else { + parentItem.quickMode = feedItem.quickMode; + } + } + }, + + onDragStart(aEvent) { + // Get the selected feed article (if there is one). + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + // Only initiate a drag if the item is a feed (ignore folders/containers). + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container) { + return; + } + + aEvent.dataTransfer.setData("text/x-moz-feed-index", seln.currentIndex); + aEvent.dataTransfer.effectAllowed = "copyMove"; + }, + + onDragOver(aEvent) { + this.mView._currentDataTransfer = aEvent.dataTransfer; + }, + + mFeedDownloadCallback: { + mSubscribeMode: true, + async downloaded(feed, aErrorCode) { + // Offline check is done in the context of 3pane, return to the subscribe + // window once the modal prompt is dispatched. + window.focus(); + // Feed is null if our attempt to parse the feed failed. + let message = ""; + let win = FeedSubscriptions; + if ( + aErrorCode == FeedUtils.kNewsBlogSuccess || + aErrorCode == FeedUtils.kNewsBlogNoNewItems + ) { + win.updateStatusItem("progressMeter", 100); + + if (win.mActionMode == win.kVerifyUrlMode) { + // Just checking for errors, if none bye. The (non error) code + // kNewsBlogNoNewItems can only happen in verify mode. + win.mActionMode = null; + win.clearStatusInfo(); + if (Services.io.offline) { + return; + } + + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedVerified" + ); + win.updateStatusItem("statusText", message); + return; + } + + // Update lastUpdateTime if successful. + let options = feed.options; + options.updates.lastUpdateTime = Date.now(); + feed.options = options; + + // Add the feed to the databases. + FeedUtils.addFeed(feed); + + // Set isBusy status, and clear it after getting favicon. This makes + // sure the folder icon is redrawn to reflect what we got. + FeedUtils.setStatus( + feed.folder, + feed.url, + "code", + FeedUtils.kNewsBlogFeedIsBusy + ); + await FeedUtils.getFavicon(feed.folder, feed.url); + FeedUtils.setStatus(feed.folder, feed.url, "code", aErrorCode); + + // Now add the feed to our view. If adding, the current selection will + // be a folder; if updating it will be a feed. No need to rebuild the + // entire view, that is too jarring. + let curIndex = win.mView.selection.currentIndex; + let curItem = win.mView.getItemAtIndex(curIndex); + if (curItem) { + let parentIndex, parentItem, newItem, level; + if (curItem.container) { + // Open the container, if it exists. + let folderExists = win.selectFolder(feed.folder, { + parentIndex: curIndex, + }); + if (!folderExists) { + // This means a new folder was created. + parentIndex = curIndex; + parentItem = curItem; + level = curItem.level + 1; + newItem = win.makeFolderObject(feed.folder, level); + } else { + // If a folder happens to exist which matches one that would + // have been created, the feed system reuses it. Get the + // current item again if reusing a previously unselected folder. + curIndex = win.mView.selection.currentIndex; + curItem = win.mView.getItemAtIndex(curIndex); + parentIndex = curIndex; + parentItem = curItem; + level = curItem.level + 1; + newItem = win.makeFeedObject(feed, feed.folder, level); + } + } else { + // Adding a feed. + parentIndex = win.mView.getParentIndex(curIndex); + parentItem = win.mView.getItemAtIndex(parentIndex); + level = curItem.level; + newItem = win.makeFeedObject(feed, feed.folder, level); + } + + if (!newItem.container) { + win.updateFolderQuickModeInView(newItem, parentItem, false); + } + + parentItem.children.push(newItem); + parentItem.children = win.folderItemSorter(parentItem.children); + parentItem.favicon = null; + + if (win.mActionMode == win.kSubscribeMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedAdded" + ); + } + if (win.mActionMode == win.kUpdateMode) { + win.removeFeed(false); + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedUpdated" + ); + } + if (win.mActionMode == win.kMoveMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedMoved" + ); + } + if (win.mActionMode == win.kCopyMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedCopied" + ); + } + + win.selectFeed(feed, parentIndex); + } + } else { + // Non success. Remove intermediate traces from the feeds database. + // But only if we're not in verify mode. + if ( + win.mActionMode != win.kVerifyUrlMode && + feed && + feed.url && + feed.server + ) { + FeedUtils.deleteFeed(feed); + } + + if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedNotValid" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogRequestFailure) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-networkError" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogFileError) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogBadCertError) { + let host = Services.io.newURI(feed.url).host; + message = FeedUtils.strings.formatStringFromName( + "newsblog-badCertError", + [host] + ); + } + if (aErrorCode == FeedUtils.kNewsBlogNoAuthError) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-noAuthError" + ); + } + + // Focus the url if verify/update failed. + if ( + win.mActionMode == win.kUpdateMode || + win.mActionMode == win.kVerifyUrlMode + ) { + document.getElementById("locationValue").focus(); + } + } + + win.mActionMode = null; + win.clearStatusInfo(); + let code = feed.url.startsWith("http") ? aErrorCode : null; + win.updateStatusItem("statusText", message, code); + }, + + // This gets called after the RSS parser finishes storing a feed item to + // disk. aCurrentFeedItems is an integer corresponding to how many feed + // items have been downloaded so far. aMaxFeedItems is an integer + // corresponding to the total number of feed items to download. + onFeedItemStored(feed, aCurrentFeedItems, aMaxFeedItems) { + window.focus(); + let message = FeedUtils.strings.formatStringFromName( + "subscribe-gettingFeedItems", + [aCurrentFeedItems, aMaxFeedItems] + ); + FeedSubscriptions.updateStatusItem("statusText", message); + this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems); + }, + + onProgress(feed, aProgress, aProgressMax, aLengthComputable) { + FeedSubscriptions.updateStatusItem( + "progressMeter", + (aProgress * 100) / (aProgressMax || 100) + ); + }, + }, + + // Status routines. + updateStatusItem(aID, aValue, aErrorCode) { + let el = document.getElementById(aID); + if (el.getAttribute("collapsed")) { + el.removeAttribute("collapsed"); + } + if (el.hidden) { + el.hidden = false; + } + + if (aID == "progressMeter") { + if (aValue == "?") { + el.removeAttribute("value"); + } else { + el.value = aValue; + } + } else if (aID == "statusText") { + el.textContent = aValue; + } + + el = document.getElementById("validationText"); + if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) { + el.removeAttribute("collapsed"); + } else { + el.setAttribute("collapsed", true); + } + + el = document.getElementById("addCertException"); + if (aErrorCode == FeedUtils.kNewsBlogBadCertError) { + el.removeAttribute("collapsed"); + } else { + el.setAttribute("collapsed", true); + } + }, + + clearStatusInfo() { + document.getElementById("statusText").textContent = ""; + document.getElementById("progressMeter").hidden = true; + document.getElementById("validationText").collapsed = true; + document.getElementById("addCertException").collapsed = true; + }, + + checkValidation(aEvent) { + if (aEvent.button != 0) { + return; + } + + let validationQuery = "http://validator.w3.org/feed/check.cgi?url="; + + if (this.mMainWin) { + let tabmail = this.mMainWin.document.getElementById("tabmail"); + if (tabmail) { + let feedLocation = document.getElementById("locationValue").value; + let url = validationQuery + encodeURIComponent(feedLocation); + + this.mMainWin.focus(); + this.mMainWin.openContentTab(url); + FeedUtils.log.debug("checkValidation: query url - " + url); + } + } + aEvent.stopPropagation(); + }, + + addCertExceptionDialog() { + let locationValue = document.getElementById("locationValue"); + let feedURL = locationValue.value.trim(); + let params = { + exceptionAdded: false, + location: feedURL, + prefetchCert: true, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + if (params.exceptionAdded) { + this.clearStatusInfo(); + } + + locationValue.focus(); + }, + + // Listener for folder pane changes. + FolderListener: { + get feedWindow() { + let subscriptionsWindow = Services.wm.getMostRecentWindow( + "Mail:News-BlogSubscriptions" + ); + return subscriptionsWindow ? subscriptionsWindow.FeedSubscriptions : null; + }, + + get currentSelectedIndex() { + return this.feedWindow + ? this.feedWindow.mView.selection.currentIndex + : -1; + }, + + get currentSelectedItem() { + return this.feedWindow ? this.feedWindow.mView.currentItem : null; + }, + + folderAdded(aFolder) { + if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) { + return; + } + + let parentFolder = aFolder.isServer ? aFolder : aFolder.parent; + FeedUtils.log.debug( + "folderAdded: folder:parent - " + + aFolder.name + + ":" + + (parentFolder ? parentFolder.filePath.path : "(null)") + ); + + if (!parentFolder || !this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(parentFolder); + let open = indexInView != null; + + if (aFolder.isServer) { + if (indexInView != null) { + // Existing account root folder in the view. + open = feedWindow.mView.getItemAtIndex(indexInView).open; + } else { + // Add the account root folder to the view. + feedWindow.mFeedContainers.push( + feedWindow.makeFolderObject(parentFolder, 0) + ); + feedWindow.mView.mRowCount++; + feedWindow.mTree.view = feedWindow.mView; + feedWindow.mView.tree.scrollToRow(firstVisRow); + return; + } + } + + // Rebuild the added folder's parent item in the tree row cache. + feedWindow.selectFolder(parentFolder, { + select: false, + open, + newFolder: parentFolder, + }); + + if (indexInView == null || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let parentIndex = feedWindow.mView.getParentIndex(indexInView); + if (parentIndex == feedWindow.mView.kRowIndexUndefined) { + // Root folder is its own parent. + parentIndex = indexInView; + } + + if (open) { + // Close an open parent (or root) folder. + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + } + + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open }); + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder, url: curSelItem.url }, + parentIndex + ); + } + }, + + folderDeleted(aFolder) { + if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) { + return; + } + + FeedUtils.log.debug("folderDeleted: folder - " + aFolder.name); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let indexInView = feedWindow.mView.getItemInViewIndex(aFolder); + let open = indexInView != null; + + // Delete the folder from the tree row cache. + feedWindow.selectFolder(aFolder, { + select: false, + open: false, + remove: true, + }); + + if (!open || curSelIndex < 0) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + feedWindow.mView.removeItemAtIndex(indexInView, !select); + }, + + folderRenamed(aOrigFolder, aNewFolder) { + if (aNewFolder.server.type != "rss" || FeedUtils.isInTrash(aNewFolder)) { + return; + } + + FeedUtils.log.debug( + "folderRenamed: old:new - " + aOrigFolder.name + ":" + aNewFolder.name + ); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(aOrigFolder); + let open = indexInView != null; + + // Rebuild the renamed folder's item in the tree row cache. + feedWindow.selectFolder(aOrigFolder, { + select: false, + open, + newFolder: aNewFolder, + }); + + if (!open || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + let parentIndex = feedWindow.mView.getParentIndex(indexInView); + if (parentIndex == feedWindow.mView.kRowIndexUndefined) { + // Root folder is its own parent. + parentIndex = indexInView; + } + + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + if (curSelItem.folder == aOrigFolder) { + feedWindow.selectFolder(aNewFolder, { open: curSelItem.open }); + } else if (select) { + feedWindow.mView.selection.select(indexInView); + } else { + feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open }); + } + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url }, + parentIndex + ); + } + }, + + folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) { + if (aDestFolder.server.type != "rss") { + return; + } + + FeedUtils.log.debug( + "folderMoveCopyCompleted: move:src:dest - " + + aMove + + ":" + + aSrcFolder.name + + ":" + + aDestFolder.name + ); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(aSrcFolder); + let destIndexInView = feedWindow.mView.getItemInViewIndex(aDestFolder); + let open = indexInView != null || destIndexInView != null; + let parentIndex = feedWindow.mView.getItemInViewIndex( + aDestFolder.parent || aDestFolder + ); + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + if (aMove) { + this.folderDeleted(aSrcFolder); + if (aDestFolder.getFlag(Ci.nsMsgFolderFlags.Trash)) { + return; + } + } + + setTimeout(() => { + // State on disk needs to settle before a folder object can be rebuilt. + feedWindow.selectFolder(aDestFolder, { + select: false, + open: open || select, + newFolder: aDestFolder, + }); + + if (!open || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + if (curSelItem.folder == aSrcFolder || select) { + feedWindow.selectFolder(aDestFolder, { open: true }); + } else { + feedWindow.selectFolder(curSelItem.folder, { + open: curSelItem.open, + }); + } + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url }, + null + ); + } + }, 50); + }, + }, + + /* *************************************************************** */ + /* OPML Functions */ + /* *************************************************************** */ + + get brandShortName() { + let brandBundle = document.getElementById("bundle_brand"); + return brandBundle ? brandBundle.getString("brandShortName") : ""; + }, + + /** + * Export feeds as opml file Save As filepicker function. + * + * @param {Boolean} aList - If true, exporting as list; if false (default) + * exporting feeds in folder structure - used for title. + * @returns {Promise} nsIFile or null. + */ + opmlPickSaveAsFile(aList) { + let accountName = this.mRSSServer.rootFolder.prettyName; + let fileName = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDefaultFileName", + [this.brandShortName, accountName] + ); + let title = aList + ? FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleList", + [accountName] + ) + : FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleStruct", + [accountName] + ); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + fp.defaultString = fileName; + fp.defaultExtension = "opml"; + if ( + this.opmlLastSaveAsDir && + this.opmlLastSaveAsDir instanceof Ci.nsIFile + ) { + fp.displayDirectory = this.opmlLastSaveAsDir; + } + + let opmlFilterText = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLExportOPMLFilesFilterText" + ); + fp.appendFilter(opmlFilterText, "*.opml"); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.filterIndex = 0; + fp.init(window, title, Ci.nsIFilePicker.modeSave); + + return new Promise(resolve => { + fp.open(rv => { + if ( + (rv != Ci.nsIFilePicker.returnOK && + rv != Ci.nsIFilePicker.returnReplace) || + !fp.file + ) { + resolve(null); + return; + } + + this.opmlLastSaveAsDir = fp.file.parent; + resolve(fp.file); + }); + }); + }, + + /** + * Import feeds opml file Open filepicker function. + * + * @returns {Promise} [{nsIFile} file, {String} fileUrl] or null. + */ + opmlPickOpenFile() { + let title = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportTitle" + ); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + fp.defaultString = ""; + if (this.opmlLastOpenDir && this.opmlLastOpenDir instanceof Ci.nsIFile) { + fp.displayDirectory = this.opmlLastOpenDir; + } + + let opmlFilterText = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLExportOPMLFilesFilterText" + ); + fp.appendFilter(opmlFilterText, "*.opml"); + fp.appendFilters(Ci.nsIFilePicker.filterXML); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.init(window, title, Ci.nsIFilePicker.modeOpen); + + return new Promise(resolve => { + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.file) { + resolve(null); + return; + } + + this.opmlLastOpenDir = fp.file.parent; + resolve([fp.file, fp.fileURL.spec]); + }); + }); + }, + + async exportOPML(aEvent) { + // Account folder must be selected. + let item = this.mView.currentItem; + if (!item || !item.folder || !item.folder.isServer) { + return; + } + + this.mRSSServer = item.folder.server; + let rootFolder = this.mRSSServer.rootFolder; + let exportAsList = aEvent.ctrlKey; + let SPACES2 = " "; + let SPACES4 = " "; + + if (this.mRSSServer.rootFolder.hasSubFolders) { + let opmlDoc = document.implementation.createDocument("", "opml", null); + let opmlRoot = opmlDoc.documentElement; + opmlRoot.setAttribute("version", "1.0"); + opmlRoot.setAttribute("xmlns:fz", "urn:forumzilla:"); + + this.generatePPSpace(opmlRoot, SPACES2); + + // Make the <head> element. + let head = opmlDoc.createElement("head"); + this.generatePPSpace(head, SPACES4); + let titleText = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportFileDialogTitle", + [this.brandShortName, rootFolder.prettyName] + ); + let title = opmlDoc.createElement("title"); + title.appendChild(opmlDoc.createTextNode(titleText)); + head.appendChild(title); + this.generatePPSpace(head, SPACES4); + let dt = opmlDoc.createElement("dateCreated"); + dt.appendChild(opmlDoc.createTextNode(new Date().toUTCString())); + head.appendChild(dt); + this.generatePPSpace(head, SPACES2); + opmlRoot.appendChild(head); + + this.generatePPSpace(opmlRoot, SPACES2); + + // Add <outline>s to the <body>. + let body = opmlDoc.createElement("body"); + if (exportAsList) { + this.generateOutlineList(rootFolder, body, SPACES4.length + 2); + } else { + this.generateOutlineStruct(rootFolder, body, SPACES4.length); + } + + this.generatePPSpace(body, SPACES2); + + if (!body.childElementCount) { + // No folders/feeds. + return; + } + + opmlRoot.appendChild(body); + this.generatePPSpace(opmlRoot, ""); + + // Get file to save from filepicker. + let saveAsFile = await this.opmlPickSaveAsFile(exportAsList); + if (!saveAsFile) { + return; + } + + let fos = FileUtils.openSafeFileOutputStream(saveAsFile); + let serializer = new XMLSerializer(); + serializer.serializeToStream(opmlDoc, fos, "utf-8"); + FileUtils.closeSafeFileOutputStream(fos); + + let statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDone", + [saveAsFile.path] + ); + this.updateStatusItem("statusText", statusReport); + FeedUtils.log.info("exportOPML: " + statusReport); + } + }, + + generatePPSpace(aNode, indentString) { + aNode.appendChild(aNode.ownerDocument.createTextNode("\n")); + aNode.appendChild(aNode.ownerDocument.createTextNode(indentString)); + }, + + generateOutlineList(baseFolder, parent, indentLevel) { + // Pretty printing. + let indentString = " ".repeat(indentLevel - 2); + + let feedOutline; + for (let folder of baseFolder.subFolders) { + FeedUtils.log.debug( + "generateOutlineList: folder - " + folder.filePath.path + ); + if ( + !(folder instanceof Ci.nsIMsgFolder) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + continue; + } + + FeedUtils.log.debug( + "generateOutlineList: CONTINUE folderName - " + folder.name + ); + + if (folder.hasSubFolders) { + FeedUtils.log.debug( + "generateOutlineList: has subfolders - " + folder.name + ); + // Recurse. + this.generateOutlineList(folder, parent, indentLevel); + } + + // Add outline elements with xmlUrls. + let feeds = this.getFeedsInFolder(folder); + for (let feed of feeds) { + FeedUtils.log.debug( + "generateOutlineList: folder has FEED url - " + + folder.name + + " : " + + feed.url + ); + feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument); + this.generatePPSpace(parent, indentString); + parent.appendChild(feedOutline); + } + } + }, + + generateOutlineStruct(baseFolder, parent, indentLevel) { + // Pretty printing. + function indentString(len) { + return " ".repeat(len - 2); + } + + let folderOutline, feedOutline; + for (let folder of baseFolder.subFolders) { + FeedUtils.log.debug( + "generateOutlineStruct: folder - " + folder.filePath.path + ); + if ( + !(folder instanceof Ci.nsIMsgFolder) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + continue; + } + + FeedUtils.log.debug( + "generateOutlineStruct: CONTINUE folderName - " + folder.name + ); + + // Make a folder outline element. + folderOutline = parent.ownerDocument.createElement("outline"); + folderOutline.setAttribute("title", folder.prettyName); + this.generatePPSpace(parent, indentString(indentLevel + 2)); + + if (folder.hasSubFolders) { + FeedUtils.log.debug( + "generateOutlineStruct: has subfolders - " + folder.name + ); + // Recurse. + this.generateOutlineStruct(folder, folderOutline, indentLevel + 2); + } + + let feeds = this.getFeedsInFolder(folder); + for (let feed of feeds) { + // Add feed outline elements with xmlUrls. + FeedUtils.log.debug( + "generateOutlineStruct: folder has FEED url - " + + folder.name + + " : " + + feed.url + ); + feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument); + this.generatePPSpace(folderOutline, indentString(indentLevel + 4)); + folderOutline.appendChild(feedOutline); + } + + parent.appendChild(folderOutline); + } + }, + + exportOPMLOutline(aFeed, aDoc) { + let outRv = aDoc.createElement("outline"); + outRv.setAttribute("type", "rss"); + outRv.setAttribute("title", aFeed.title); + outRv.setAttribute("text", aFeed.title); + outRv.setAttribute("version", "RSS"); + outRv.setAttribute("fz:quickMode", aFeed.quickMode); + outRv.setAttribute("fz:options", JSON.stringify(aFeed.options)); + outRv.setAttribute("xmlUrl", aFeed.url); + outRv.setAttribute("htmlUrl", aFeed.link); + return outRv; + }, + + async importOPML() { + // Account folder must be selected in subscribe dialog. + let item = this.mView ? this.mView.currentItem : null; + if (!item || !item.folder || !item.folder.isServer) { + return; + } + + let server = item.folder.server; + // Get file and file url to open from filepicker. + let [openFile, openFileUrl] = await this.opmlPickOpenFile(); + + this.mActionMode = this.kImportingOPML; + this.updateButtons(null); + this.selectFolder(item.folder, { select: false, open: true }); + let statusReport = FeedUtils.strings.GetStringFromName("subscribe-loading"); + this.updateStatusItem("statusText", statusReport); + // If there were a getElementsByAttribute in html, we could go determined... + this.updateStatusItem("progressMeter", "?"); + + if ( + !(await this.importOPMLFile( + openFile, + openFileUrl, + server, + this.importOPMLFinished + )) + ) { + this.mActionMode = null; + this.updateButtons(item); + this.clearStatusInfo(); + } + }, + + /** + * Import opml file into a feed account. Used by the Subscribe dialog and + * the Import wizard. + * + * @param {nsIFile} aFile - The opml file. + * @param {string} aFileUrl - The opml file url. + * @param {nsIMsgIncomingServer} aServer - The account server. + * @param {Function} aCallback - Callback function. + * + * @returns {Boolean} - false if error. + */ + async importOPMLFile(aFile, aFileUrl, aServer, aCallback) { + if (aServer && aServer instanceof Ci.nsIMsgIncomingServer) { + this.mRSSServer = aServer; + } + + if (!aFile || !aFileUrl || !this.mRSSServer) { + return false; + } + + let opmlDom, statusReport; + FeedUtils.log.debug( + "importOPMLFile: fileName:fileUrl - " + aFile.leafName + ":" + aFileUrl + ); + let request = new Request(aFileUrl); + await fetch(request) + .then(function (response) { + if (!response.ok) { + // If the OPML file is not readable/accessible. + statusReport = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + return null; + } + + return response.text(); + }) + .then(function (responseText) { + if (responseText != null) { + opmlDom = new DOMParser().parseFromString( + responseText, + "application/xml" + ); + if ( + !XMLDocument.isInstance(opmlDom) || + opmlDom.documentElement.namespaceURI == + FeedUtils.MOZ_PARSERERROR_NS || + opmlDom.documentElement.tagName != "opml" || + !( + opmlDom.querySelector("body") && + opmlDom.querySelector("body").childElementCount + ) + ) { + // If the OPML file is invalid or empty. + statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLImportInvalidFile", + [aFile.leafName] + ); + } + } + }) + .catch(function (error) { + statusReport = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + FeedUtils.log.error("importOPMLFile: error - " + error.message); + }); + + if (statusReport) { + FeedUtils.log.error("importOPMLFile: status - " + statusReport); + Services.prompt.alert(window, null, statusReport); + return false; + } + + let body = opmlDom.querySelector("body"); + this.importOPMLOutlines(body, this.mRSSServer, aCallback); + return true; + }, + + importOPMLOutlines(aBody, aRSSServer, aCallback) { + let win = this; + let rssServer = aRSSServer; + let callback = aCallback; + let outline, feedFolder; + let badTag = false; + let firstFeedInFolderQuickMode = null; + let lastFolder; + let feedsAdded = 0; + let rssOutlines = 0; + + function processor(aParentNode, aParentFolder) { + FeedUtils.log.trace( + "importOPMLOutlines: PROCESSOR tag:name:children - " + + aParentNode.tagName + + ":" + + aParentNode.getAttribute("text") + + ":" + + aParentNode.childElementCount + ); + while (true) { + if (aParentNode.tagName == "body" && !aParentNode.childElementCount) { + // Finished. + let statusReport = win.importOPMLStatus(feedsAdded, rssOutlines); + callback(statusReport, lastFolder, win); + return; + } + + outline = aParentNode.firstElementChild; + if (outline.tagName != "outline") { + FeedUtils.log.info( + "importOPMLOutlines: skipping, node is not an " + + "<outline> - <" + + outline.tagName + + ">" + ); + badTag = true; + break; + } + + let outlineName = + outline.getAttribute("text") || + outline.getAttribute("title") || + outline.getAttribute("xmlUrl"); + let feedUrl, folder; + + if (outline.getAttribute("type") == "rss") { + // A feed outline. + feedUrl = + outline.getAttribute("xmlUrl") || outline.getAttribute("url"); + if (!feedUrl) { + FeedUtils.log.info( + "importOPMLOutlines: skipping, type=rss <outline> " + + "has no url - " + + outlineName + ); + break; + } + + rssOutlines++; + feedFolder = aParentFolder; + + if (FeedUtils.feedAlreadyExists(feedUrl, rssServer)) { + FeedUtils.log.info( + "importOPMLOutlines: feed already subscribed in account " + + rssServer.prettyName + + ", url - " + + feedUrl + ); + break; + } + + if ( + aParentNode.tagName == "outline" && + aParentNode.getAttribute("type") != "rss" + ) { + // Parent is a folder, already created. + folder = feedFolder; + } else { + // Parent is not a folder outline, likely the <body> in a flat list. + // Create feed's folder with feed's name and account rootFolder as + // parent of feed's folder. + // NOTE: Assume a type=rss outline must be a leaf and is not a + // direct parent of another type=rss outline; such a structure + // may lead to unintended nesting and inaccurate counts. + folder = rssServer.rootFolder; + } + + // Create the feed. + let quickMode = outline.hasAttribute("fz:quickMode") + ? outline.getAttribute("fz:quickMode") == "true" + : rssServer.getBoolValue("quickMode"); + let options = outline.getAttribute("fz:options"); + options = options ? JSON.parse(options) : null; + + if (firstFeedInFolderQuickMode === null) { + // The summary/web page pref applies to all feeds in a folder, + // though it is a property of an individual feed. This can be + // set (and is obvious) in the subscribe dialog; ensure import + // doesn't leave mismatches if mismatched in the opml file. + firstFeedInFolderQuickMode = quickMode; + } else { + quickMode = firstFeedInFolderQuickMode; + } + + let feedProperties = { + feedName: outlineName, + feedLocation: feedUrl, + feedFolder: folder, + quickMode, + options, + }; + + FeedUtils.log.info( + "importOPMLOutlines: importing feed: name, url - " + + outlineName + + ", " + + feedUrl + ); + + let feed = win.storeFeed(feedProperties); + if (outline.hasAttribute("htmlUrl")) { + feed.link = outline.getAttribute("htmlUrl"); + } + + feed.createFolder(); + if (!feed.folder) { + // Non success. Remove intermediate traces from the feeds database. + if (feed && feed.url && feed.server) { + FeedUtils.deleteFeed(feed); + } + + FeedUtils.log.info( + "importOPMLOutlines: skipping, error creating folder - '" + + feed.folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + badTag = true; + break; + } + + // Add the feed to the databases. + FeedUtils.addFeed(feed); + // Feed correctly added. + feedsAdded++; + lastFolder = feed.folder; + } else { + // A folder outline. If a folder exists in the account structure at + // the same level as in the opml structure, feeds are placed into the + // existing folder. + let folderName = outlineName; + try { + feedFolder = aParentFolder.getChildNamed(folderName); + } catch (ex) { + // Folder not found, create it. + FeedUtils.log.info( + "importOPMLOutlines: creating folder - '" + + folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + firstFeedInFolderQuickMode = null; + try { + feedFolder = aParentFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .createLocalSubfolder(folderName); + } catch (ex) { + // An error creating. Skip it. + FeedUtils.log.info( + "importOPMLOutlines: skipping, error creating folder - '" + + folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + let xfolder = aParentFolder.getChildNamed(folderName); + aParentFolder.propagateDelete(xfolder, true); + badTag = true; + break; + } + } + } + + break; + } + + if (!outline.childElementCount || badTag) { + // Remove leaf nodes that are processed or bad tags from the opml dom, + // and go back to reparse. This method lets us use setTimeout to + // prevent UI hang, in situations of both deep and shallow trees. + // A yield/generator.next() method is fine for shallow trees, but not + // the true recursion required for deeper trees; both the shallow loop + // and the recurse should give it up. + outline.remove(); + badTag = false; + outline = aBody; + feedFolder = rssServer.rootFolder; + } + + setTimeout(() => { + processor(outline, feedFolder); + }, 0); + } + + processor(aBody, rssServer.rootFolder); + }, + + importOPMLStatus(aFeedsAdded, aRssOutlines, aFolderOutlines) { + let statusReport; + if (aRssOutlines > aFeedsAdded) { + statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLImportStatus", + [ + PluralForm.get( + aFeedsAdded, + FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportUniqueFeeds" + ) + ).replace("#1", aFeedsAdded), + PluralForm.get( + aRssOutlines, + FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportFoundFeeds" + ) + ).replace("#1", aRssOutlines), + ], + 2 + ); + } else { + statusReport = PluralForm.get( + aFeedsAdded, + FeedUtils.strings.GetStringFromName("subscribe-OPMLImportFeedCount") + ).replace("#1", aFeedsAdded); + } + + return statusReport; + }, + + importOPMLFinished(aStatusReport, aLastFolder, aWin) { + if (aLastFolder) { + aWin.selectFolder(aLastFolder, { select: false, newFolder: aLastFolder }); + aWin.selectFolder(aLastFolder.parent); + } + aWin.mActionMode = null; + aWin.updateButtons(aWin.mView.currentItem); + aWin.clearStatusInfo(); + aWin.updateStatusItem("statusText", aStatusReport); + }, +}; diff --git a/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml b/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml new file mode 100644 index 0000000000..d2f8cba2bb --- /dev/null +++ b/comm/mailnews/extensions/newsblog/feed-subscriptions.xhtml @@ -0,0 +1,373 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderPane.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger-newsblog/skin/feed-subscriptions.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % feedDTD SYSTEM "chrome://messenger-newsblog/locale/feed-subscriptions.dtd"> +%feedDTD; +<!ENTITY % newsblogDTD SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd"> +%newsblogDTD; ]> + +<html + id="feedSubscriptions" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + scrolling="false" + windowtype="Mail:News-BlogSubscriptions" + persist="width height screenX screenY sizemode" + lightweightthemes="true" +> + <head> + <title>&feedSubscriptions.label;</title> + <link rel="localization" href="security/certificates/certManager.ftl" /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/specialTabs.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/dialogShadowDom.js" + ></script> + <script + defer="defer" + src="chrome://messenger-newsblog/content/feed-subscriptions.js" + ></script> + <script> + window.addEventListener("load", event => { + FeedSubscriptions.onLoad(); + }); + window.addEventListener("keypress", event => { + FeedSubscriptions.onKeyPress(event); + }); + window.addEventListener("mousedown", event => { + FeedSubscriptions.onMouseDown(event); + }); + </script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <dialog + id="subscriptionsDialog" + buttons="accept" + buttonlabelaccept="&button.close.label;" + > + <keyset id="extensionsKeys"> + <key + id="key_close" + key="&cmd.close.commandKey;" + modifiers="accel" + oncommand="window.close();" + /> + <key id="key_close2" keycode="VK_ESCAPE" oncommand="window.close();" /> + </keyset> + + <stringbundle + id="bundle_newsblog" + src="chrome://messenger-newsblog/locale/newsblog.properties" + /> + <stringbundle + id="bundle_brand" + src="chrome://branding/locale/brand.properties" + /> + + <vbox flex="1" id="contentPane"> + <hbox pack="end"> + <label + is="text-link" + id="learnMore" + crop="end" + value="&learnMore.label;" + href="https://support.mozilla.org/kb/how-subscribe-news-feeds-and-blogs" + /> + </hbox> + + <tree + id="rssSubscriptionsList" + treelines="true" + flex="1" + hidecolumnpicker="true" + onselect="FeedSubscriptions.onSelect();" + seltype="single" + > + <treecols> + <treecol id="folderNameCol" primary="true" hideheader="true" /> + </treecols> + <treechildren + id="subscriptionChildren" + ondragstart="FeedSubscriptions.onDragStart(event);" + ondragover="FeedSubscriptions.onDragOver(event);" + /> + </tree> + + <hbox id="rssFeedInfoBox"> + <vbox flex="1"> + <hbox flex="1"> + <vbox pack="end"> + <hbox flex="1" align="center"> + <label + id="nameLabel" + accesskey="&feedTitle.accesskey;" + control="nameValue" + value="&feedTitle.label;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="locationLabel" + accesskey="&feedLocation.accesskey;" + control="locationValue" + value="&feedLocation.label;" + /> + </hbox> + <hbox flex="1" align="center"> + <label + id="feedFolderLabel" + value="&feedFolder.label;" + accesskey="&feedFolder.accesskey;" + control="selectFolder" + /> + </hbox> + </vbox> + <vbox flex="1"> + <html:input + id="nameValue" + type="text" + class="input-inline" + aria-labelledby="nameLabel" + onchange="FeedSubscriptions.setPrefs(this);" + /> + <hbox class="input-container"> + <html:input + id="locationValue" + type="url" + class="uri-element input-inline" + aria-labelledby="locationLabel" + placeholder="&feedLocation2.placeholder;" + onchange="FeedSubscriptions.setPrefs(this);" + onfocus="FeedSubscriptions.onFocusChange();" + onblur="FeedSubscriptions.onFocusChange();" + /> + <hbox align="center"> + <label + is="text-link" + id="locationValidate" + collapsed="true" + crop="end" + value="&locationValidate.label;" + onclick="FeedSubscriptions.checkValidation(event);" + /> + </hbox> + </hbox> + <hbox class="input-container"> + <menulist + id="selectFolder" + flex="1" + class="folderMenuItem" + hidden="true" + > + <menupopup + is="folder-menupopup" + id="selectFolderPopup" + class="menulist-menupopup" + mode="feeds" + showFileHereLabel="true" + showAccountsFileHere="true" + oncommand="FeedSubscriptions.setNewFolder(event);" + /> + </menulist> + <html:input + id="selectFolderValue" + class="input-inline" + readonly="readonly" + aria-labelledby="feedFolderLabel" + onkeypress="FeedSubscriptions.onClickSelectFolderValue(event);" + onclick="FeedSubscriptions.onClickSelectFolderValue(event);" + /> + </hbox> + </vbox> + </hbox> + + <hbox align="center"> + <checkbox + id="updateEnabled" + label="&biffStart.label;" + accesskey="&biffStart.accesskey;" + oncommand="FeedSubscriptions.setPrefs(this);" + /> + <html:input + id="updateValue" + type="number" + class="size3 input-inline" + min="1" + aria-labelledby="updateEnabled updateValue biffMinutes biffDays recommendedUnits recommendedUnitsVal" + oninput="FeedSubscriptions.setPrefs(this);" + onchange="FeedSubscriptions.setPrefs(this);" + /> + <radiogroup + id="biffUnits" + orient="horizontal" + oncommand="FeedSubscriptions.setPrefs(this);" + > + <radio + id="biffMinutes" + value="min" + label="&biffMinutes.label;" + accesskey="&biffMinutes.accesskey;" + /> + <radio + id="biffDays" + value="d" + label="&biffDays.label;" + accesskey="&biffDays.accesskey;" + /> + </radiogroup> + <hbox id="recommendedBox"> + <label + id="recommendedUnits" + value="&recommendedUnits.label;" + hidden="true" + control="updateMinutes" + /> + <label + id="recommendedUnitsVal" + value="" + hidden="true" + control="updateMinutes" + /> + </hbox> + </hbox> + <checkbox + id="quickMode" + accesskey="&quickMode.accesskey;" + label="&quickMode.label;" + oncommand="FeedSubscriptions.setSummary(this.checked);" + /> + <checkbox + id="autotagEnable" + accesskey="&autotagEnable.accesskey;" + label="&autotagEnable.label;" + oncommand="FeedSubscriptions.setPrefs(this);" + /> + <hbox class="input-container"> + <checkbox + id="autotagUsePrefix" + class="indent" + accesskey="&autotagUsePrefix.accesskey;" + label="&autotagUsePrefix.label;" + oncommand="FeedSubscriptions.setPrefs(this);" + /> + <html:input + id="autotagPrefix" + type="text" + class="input-inline" + placeholder="&autoTagPrefix.placeholder;" + onchange="FeedSubscriptions.setPrefs(this);" + /> + </hbox> + <separator class="thin" /> + </vbox> + </hbox> + + <hbox id="statusContainerBox" align="center"> + <vbox flex="1"> + <description id="statusText" /> + </vbox> + <spacer flex="1" /> + <label + id="validationText" + collapsed="true" + class="text-link" + crop="end" + value="&validateText.label;" + onclick="FeedSubscriptions.checkValidation(event);" + /> + <button + id="addCertException" + collapsed="true" + data-l10n-id="certmgr-add-exception" + oncommand="FeedSubscriptions.addCertExceptionDialog();" + /> + <html:progress + id="progressMeter" + hidden="hidden" + value="0" + max="100" + /> + </hbox> + + <hbox align="end"> + <hbox class="actionButtons" flex="1"> + <button + id="addFeed" + hidden="true" + disabled="true" + label="&button.addFeed.label;" + accesskey="&button.addFeed.accesskey;" + oncommand="FeedSubscriptions.addFeed();" + /> + + <button + id="updateFeed" + hidden="true" + disabled="true" + label="&button.verifyFeed.label;" + accesskey="&button.verifyFeed.accesskey;" + verifylabel="&button.verifyFeed.label;" + verifyaccesskey="&button.verifyFeed.accesskey;" + updatelabel="&button.updateFeed.label;" + updateaccesskey="&button.updateFeed.accesskey;" + oncommand="FeedSubscriptions.updateFeed();" + /> + + <button + id="removeFeed" + hidden="true" + label="&button.removeFeed.label;" + accesskey="&button.removeFeed.accesskey;" + oncommand="FeedSubscriptions.removeFeed(true);" + /> + + <spacer flex="1" /> + + <button + id="importOPML" + hidden="true" + label="&button.importOPML.label;" + accesskey="&button.importOPML.accesskey;" + oncommand="FeedSubscriptions.importOPML();" + /> + + <button + id="exportOPML" + hidden="true" + label="&button.exportOPML.label;" + accesskey="&button.exportOPML.accesskey;" + tooltiptext="&button.exportOPML.tooltip;" + oncommand="FeedSubscriptions.exportOPML(event);" + /> + </hbox> + </hbox> + </vbox> + </dialog> + </html:body> +</html> diff --git a/comm/mailnews/extensions/newsblog/feedAccountWizard.js b/comm/mailnews/extensions/newsblog/feedAccountWizard.js new file mode 100644 index 0000000000..686caff3a3 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/feedAccountWizard.js @@ -0,0 +1,56 @@ +/* -*- Mode: JavaScript; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); + +window.addEventListener("DOMContentLoaded", () => { + FeedAccountWizard.onLoad(); +}); + +/** Feed account standalone wizard functions. */ +var FeedAccountWizard = { + accountName: "", + + onLoad() { + document + .querySelector("wizard") + .addEventListener("wizardfinish", this.onFinish.bind(this)); + let accountSetupPage = document.getElementById("accountsetuppage"); + accountSetupPage.addEventListener( + "pageshow", + this.accountSetupPageValidate.bind(this) + ); + accountSetupPage.addEventListener( + "pagehide", + this.accountSetupPageValidate.bind(this) + ); + let donePage = document.getElementById("done"); + donePage.addEventListener("pageshow", this.donePageInit.bind(this)); + }, + + accountSetupPageValidate() { + this.accountName = document.getElementById("prettyName").value.trim(); + document.querySelector("wizard").canAdvance = this.accountName; + }, + + donePageInit() { + document.getElementById("account.name.text").value = this.accountName; + }, + + onFinish() { + let account = FeedUtils.createRssAccount(this.accountName); + let openerWindow = window.opener.top; + // The following block is the same as in AccountWizard.js. + if ("selectServer" in openerWindow) { + // Opened from Account Settings. + openerWindow.selectServer(account.incomingServer); + } + + // Post a message to the main window on successful account setup. + openerWindow.postMessage("account-created", "*"); + + window.close(); + }, +}; diff --git a/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml b/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml new file mode 100644 index 0000000000..ef1be03dd7 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/feedAccountWizard.xhtml @@ -0,0 +1,95 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://messenger/skin/accountWizard.css" type="text/css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE html [ <!ENTITY % accountDTD SYSTEM "chrome://messenger/locale/AccountWizard.dtd"> +%accountDTD; +<!ENTITY % newsblogDTD SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd" > +%newsblogDTD; +<!ENTITY % imDTD SYSTEM "chrome://messenger/locale/imAccountWizard.dtd" > +%imDTD; ]> + +<html + id="FeedAccountWizard" + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + scrolling="false" +> + <head> + <title>&feedWindowTitle.label;</title> + <link rel="localization" href="toolkit/global/wizard.ftl" /> + <script + defer="defer" + src="chrome://messenger/content/globalOverlay.js" + ></script> + <script + defer="defer" + src="chrome://global/content/editMenuOverlay.js" + ></script> + <script + defer="defer" + src="chrome://messenger-newsblog/content/feedAccountWizard.js" + ></script> + </head> + <html:body + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <wizard> + <!-- Account setup page : User gets a choice to enter a name for the account --> + <!-- Defaults : Feed account name -> default string --> + <wizardpage + id="accountsetuppage" + pageid="accountsetuppage" + label="&accnameTitle.label;" + > + <vbox flex="1"> + <description>&accnameDesc.label;</description> + <separator class="thin" /> + <hbox align="center" class="input-container"> + <label + id="prettyNameLabel" + class="label" + value="&accnameLabel.label;" + accesskey="&accnameLabel.accesskey;" + control="prettyName" + /> + <html:input + id="prettyName" + type="text" + class="input-inline" + value="&feeds.accountName;" + aria-labelledby="prettyNameLabel" + oninput="FeedAccountWizard.accountSetupPageValidate();" + /> + </hbox> + </vbox> + </wizardpage> + + <!-- Done page : Summarizes information collected to create a feed account --> + <wizardpage id="done" pageid="done" label="&accountSummaryTitle.label;"> + <vbox flex="1"> + <description>&accountSummaryInfo.label;</description> + <separator class="thin" /> + <hbox id="account.name" align="center"> + <label + id="account.name.label" + class="label" + value="&accnameLabel.label;" + /> + <label id="account.name.text" class="label" /> + </hbox> + <separator /> + <spacer flex="1" /> + </vbox> + </wizardpage> + </wizard> + </html:body> +</html> diff --git a/comm/mailnews/extensions/newsblog/jar.mn b/comm/mailnews/extensions/newsblog/jar.mn new file mode 100644 index 0000000000..46d1a700cd --- /dev/null +++ b/comm/mailnews/extensions/newsblog/jar.mn @@ -0,0 +1,13 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +newsblog.jar: +% content messenger-newsblog %content/messenger-newsblog/ + content/messenger-newsblog/newsblogOverlay.js (newsblogOverlay.js) + content/messenger-newsblog/feed-subscriptions.js (feed-subscriptions.js) + content/messenger-newsblog/feed-subscriptions.xhtml (feed-subscriptions.xhtml) + content/messenger-newsblog/am-newsblog.js (am-newsblog.js) + content/messenger-newsblog/am-newsblog.xhtml (am-newsblog.xhtml) + content/messenger-newsblog/feedAccountWizard.js (feedAccountWizard.js) + content/messenger-newsblog/feedAccountWizard.xhtml (feedAccountWizard.xhtml) diff --git a/comm/mailnews/extensions/newsblog/moz.build b/comm/mailnews/extensions/newsblog/moz.build new file mode 100644 index 0000000000..f4a9d0dd9f --- /dev/null +++ b/comm/mailnews/extensions/newsblog/moz.build @@ -0,0 +1,25 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += [ + "Feed.jsm", + "FeedItem.jsm", + "FeedParser.jsm", + "FeedUtils.jsm", + "NewsBlog.jsm", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.ini", +] +XPCSHELL_TESTS_MANIFESTS += [ + "test/unit/xpcshell.ini", +] diff --git a/comm/mailnews/extensions/newsblog/newsblogOverlay.js b/comm/mailnews/extensions/newsblog/newsblogOverlay.js new file mode 100644 index 0000000000..9145ad0dab --- /dev/null +++ b/comm/mailnews/extensions/newsblog/newsblogOverlay.js @@ -0,0 +1,416 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals ReloadMessage, getMessagePaneBrowser, openContentTab, + GetNumSelectedMessages, gMessageNotificationBar */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); +var { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm", +}); + +// This global is for SeaMonkey compatibility. +var gShowFeedSummary; + +var FeedMessageHandler = { + gShowSummary: true, + gToggle: false, + kSelectOverrideWebPage: 0, + kSelectOverrideSummary: 1, + kSelectFeedDefault: 2, + kOpenWebPage: 0, + kOpenSummary: 1, + kOpenToggleInMessagePane: 2, + kOpenLoadInBrowser: 3, + + FeedAccountTypes: ["rss"], + + /** + * How to load message on threadpane select. + */ + get onSelectPref() { + return Services.prefs.getIntPref("rss.show.summary"); + }, + + set onSelectPref(val) { + Services.prefs.setIntPref("rss.show.summary", val); + ReloadMessage(); + }, + + /** + * Load web page on threadpane select. + */ + get loadWebPageOnSelectPref() { + return Services.prefs.getIntPref("rss.message.loadWebPageOnSelect"); + }, + + /** + * How to load message on open (enter/dbl click in threadpane, contextmenu). + */ + get onOpenPref() { + return Services.prefs.getIntPref("rss.show.content-base"); + }, + + set onOpenPref(val) { + Services.prefs.setIntPref("rss.show.content-base", val); + }, + + /** + * Determine whether to show a feed message summary or load a web page in the + * message pane. + * + * @param {nsIMsgDBHdr} aMsgHdr - The message. + * @param {boolean} aToggle - true if in toggle mode, false otherwise. + * + * @returns {Boolean} - true if summary is to be displayed, false if web page. + */ + shouldShowSummary(aMsgHdr, aToggle) { + // Not a feed message, always show summary (the message). + if (!FeedUtils.isFeedMessage(aMsgHdr)) { + return true; + } + + // Notified of a summary reload when toggling, reset toggle and return. + if (!aToggle && this.gToggle) { + return !(this.gToggle = false); + } + + let showSummary = true; + this.gToggle = aToggle; + + // Thunderbird 2 rss messages with 'Show article summary' not selected, + // ie message body constructed to show web page in an iframe, can't show + // a summary - notify user. + let browser = getMessagePaneBrowser(); + let contentDoc = browser ? browser.contentDocument : null; + let rssIframe = contentDoc + ? contentDoc.getElementById("_mailrssiframe") + : null; + if (rssIframe) { + if (this.gToggle || this.onSelectPref == this.kSelectOverrideSummary) { + this.gToggle = false; + } + + return false; + } + + if (aToggle) { + // Toggle mode, flip value. + return (gShowFeedSummary = this.gShowSummary = !this.gShowSummary); + } + + let wintype = document.documentElement.getAttribute("windowtype"); + let tabMail = document.getElementById("tabmail"); + let messageTab = tabMail && tabMail.currentTabInfo.mode.type == "message"; + let messageWindow = wintype == "mail:messageWindow"; + + switch (this.onSelectPref) { + case this.kSelectOverrideWebPage: + showSummary = false; + break; + case this.kSelectOverrideSummary: + showSummary = true; + break; + case this.kSelectFeedDefault: + // Get quickmode per feed folder pref from feed subscriptions. If the feed + // message is not in a feed account folder (hence the folder is not in + // the feeds database), err on the side of showing the summary. + // For the former, toggle or global override is necessary; for the + // latter, a show summary checkbox toggle in Subscribe dialog will set + // one on the path to bliss. + let folder = aMsgHdr.folder; + showSummary = true; + const ds = FeedUtils.getSubscriptionsDS(folder.server); + for (let sub of ds.data) { + if (sub.destFolder == folder.URI) { + showSummary = sub.quickMode; + break; + } + } + break; + } + + gShowFeedSummary = this.gShowSummary = showSummary; + + if (messageWindow || messageTab) { + // Message opened in either standalone window or tab, due to either + // message open pref (we are here only if the pref is 0 or 1) or + // contextmenu open. + switch (this.onOpenPref) { + case this.kOpenToggleInMessagePane: + // Opened by contextmenu, use the value derived above. + // XXX: allow a toggle via crtl? + break; + case this.kOpenWebPage: + showSummary = false; + break; + case this.kOpenSummary: + showSummary = true; + break; + } + } + + // Auto load web page in browser on select, per pref; shouldShowSummary() is + // always called first to 1)test if feed, 2)get summary pref, so do it here. + if (this.loadWebPageOnSelectPref) { + setTimeout(FeedMessageHandler.loadWebPage, 20, aMsgHdr, { + browser: true, + }); + } + + return showSummary; + }, + + /** + * Load a web page for feed messages. Use MsgHdrToMimeMessage() to get + * the content-base url from the message headers. We cannot rely on + * currentHeaderData; it has not yet been streamed at our entry point in + * displayMessageChanged(), and in the case of a collapsed message pane it + * is not streamed. + * + * @param {nsIMsgDBHdr} aMessageHdr - The message. + * @param {Object} aWhere - name value=true pair, where name is in: + * 'messagepane', 'browser', 'tab', 'window'. + * @returns {void} + */ + loadWebPage(aMessageHdr, aWhere) { + MsgHdrToMimeMessage(aMessageHdr, null, function (aMsgHdr, aMimeMsg) { + if ( + aMimeMsg && + aMimeMsg.headers["content-base"] && + aMimeMsg.headers["content-base"][0] + ) { + let url = aMimeMsg.headers["content-base"], + uri; + try { + // The message and headers are stored as a string of UTF-8 bytes + // and we need to convert that cpp |string| to js UTF-16 explicitly + // for idn and non-ascii urls with this api. + url = decodeURIComponent(escape(url)); + uri = Services.io.newURI(url); + } catch (ex) { + FeedUtils.log.info( + "FeedMessageHandler.loadWebPage: " + + "invalid Content-Base header url - " + + url + ); + return; + } + if (aWhere.browser) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(uri); + } else if (aWhere.messagepane) { + let browser = getMessagePaneBrowser(); + // Load about:blank in the browser before (potentially) switching + // to a remote process. This prevents sandbox flags being carried + // over to the web document. + MailE10SUtils.loadAboutBlank(browser); + MailE10SUtils.loadURI(browser, url); + } else if (aWhere.tab) { + openContentTab(url, "tab", null); + } else if (aWhere.window) { + openContentTab(url, "window", null); + } + } else { + FeedUtils.log.info( + "FeedMessageHandler.loadWebPage: could not get " + + "Content-Base header url for this message" + ); + } + }); + }, + + /** + * Display summary or load web page for feed messages. Caller should already + * know if the message is a feed message. + * + * @param {nsIMsgDBHdr} aMsgHdr - The message. + * @param {Boolean} aShowSummary - true if summary is to be displayed, + * false if web page. + * @returns {void} + */ + setContent(aMsgHdr, aShowSummary) { + if (aShowSummary) { + // Only here if toggling to summary in 3pane. + if (this.gToggle && window.gDBView && GetNumSelectedMessages() == 1) { + ReloadMessage(); + } + } else { + let browser = getMessagePaneBrowser(); + if (browser && browser.contentDocument && browser.contentDocument.body) { + browser.contentDocument.body.hidden = true; + } + // If in a non rss folder, hide possible remote content bar on a web + // page load, as it doesn't apply. + gMessageNotificationBar.clearMsgNotifications(); + + this.loadWebPage(aMsgHdr, { messagepane: true }); + this.gToggle = false; + } + }, +}; + +function openSubscriptionsDialog(aFolder) { + // Check for an existing feed subscriptions window and focus it. + let subscriptionsWindow = Services.wm.getMostRecentWindow( + "Mail:News-BlogSubscriptions" + ); + + if (subscriptionsWindow) { + if (aFolder) { + subscriptionsWindow.FeedSubscriptions.selectFolder(aFolder); + subscriptionsWindow.FeedSubscriptions.mView.tree.ensureRowIsVisible( + subscriptionsWindow.FeedSubscriptions.mView.selection.currentIndex + ); + } + + subscriptionsWindow.focus(); + } else { + window.browsingContext.topChromeWindow.openDialog( + "chrome://messenger-newsblog/content/feed-subscriptions.xhtml", + "", + "centerscreen,chrome,dialog=no,resizable", + { folder: aFolder } + ); + } +} + +// Special case attempts to reply/forward/edit as new RSS articles. For +// messages stored prior to Tb15, we are here only if the message's folder's +// account server is rss and feed messages moved to other types will have their +// summaries loaded, as viewing web pages only happened in an rss account. +// The user may choose whether to load a summary or web page link by ensuring +// the current feed message is being viewed as either a summary or web page. +function openComposeWindowForRSSArticle( + aMsgComposeWindow, + aMsgHdr, + aMessageUri, + aType, + aFormat, + aIdentity, + aMsgWindow +) { + // Ensure right content is handled for web pages in window/tab. + let tabmail = document.getElementById("tabmail"); + let is3pane = + tabmail && tabmail.selectedTab && tabmail.selectedTab.mode + ? tabmail.selectedTab.mode.type == "folder" + : false; + let showingwebpage = + "FeedMessageHandler" in window && + !is3pane && + FeedMessageHandler.onOpenPref == FeedMessageHandler.kOpenWebPage; + + if (gShowFeedSummary && !showingwebpage) { + // The user is viewing the summary. + MailServices.compose.OpenComposeWindow( + aMsgComposeWindow, + aMsgHdr, + aMessageUri, + aType, + aFormat, + aIdentity, + null, + aMsgWindow + ); + } else { + // Set up the compose message and get the feed message's web page link. + let msgHdr = aMsgHdr; + let type = aType; + let msgComposeType = Ci.nsIMsgCompType; + let subject = msgHdr.mime2DecodedSubject; + let fwdPrefix = Services.prefs.getCharPref("mail.forward_subject_prefix"); + fwdPrefix = fwdPrefix ? fwdPrefix + ": " : ""; + + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + + let composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if ( + type == msgComposeType.Reply || + type == msgComposeType.ReplyAll || + type == msgComposeType.ReplyToSender || + type == msgComposeType.ReplyToGroup || + type == msgComposeType.ReplyToSenderAndGroup || + type == msgComposeType.ReplyToList + ) { + subject = "Re: " + subject; + } else if ( + type == msgComposeType.ForwardInline || + type == msgComposeType.ForwardAsAttachment + ) { + subject = fwdPrefix + subject; + } + + params.composeFields = composeFields; + params.composeFields.subject = subject; + params.composeFields.body = ""; + params.bodyIsLink = false; + params.identity = aIdentity; + + try { + // The feed's web page url is stored in the Content-Base header. + MsgHdrToMimeMessage( + msgHdr, + null, + function (aMsgHdr, aMimeMsg) { + if ( + aMimeMsg && + aMimeMsg.headers["content-base"] && + aMimeMsg.headers["content-base"][0] + ) { + let url = decodeURIComponent( + escape(aMimeMsg.headers["content-base"]) + ); + params.composeFields.body = url; + params.bodyIsLink = true; + MailServices.compose.OpenComposeWindowWithParams(null, params); + } else { + // No content-base url, use the summary. + MailServices.compose.OpenComposeWindow( + aMsgComposeWindow, + aMsgHdr, + aMessageUri, + aType, + aFormat, + aIdentity, + null, + aMsgWindow + ); + } + }, + false, + { saneBodySize: true } + ); + } catch (ex) { + // Error getting header, use the summary. + MailServices.compose.OpenComposeWindow( + aMsgComposeWindow, + aMsgHdr, + aMessageUri, + aType, + aFormat, + aIdentity, + null, + aMsgWindow + ); + } + } +} diff --git a/comm/mailnews/extensions/newsblog/test/browser/browser.ini b/comm/mailnews/extensions/newsblog/test/browser/browser.ini new file mode 100644 index 0000000000..1abed389c7 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/browser/browser.ini @@ -0,0 +1,20 @@ +[DEFAULT] +prefs = + datareporting.policy.dataSubmissionPolicyBypassNotification=true + mail.account.account1.server=server1 + mail.accountmanager.accounts=account1 + mail.provider.suppress_dialog_on_startup=true + mail.server.server1.hostname=Feeds + mail.server.server1.name=Feeds + mail.server.server1.type=rss + mail.server.server1.userName=nobody + mail.spellcheck.inline=false + mail.spotlight.firstRunDone=true + mail.winsearch.firstRunDone=true + mailnews.database.global.indexer.enabled=false + mailnews.start_page.override_url=about:blank + mailnews.start_page.url=about:blank +subsuite = thunderbird +support-files = data/** + +[browser_feedDisplay.js] diff --git a/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js b/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js new file mode 100644 index 0000000000..98fa755c46 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/browser/browser_feedDisplay.js @@ -0,0 +1,228 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); +var { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +add_task(async () => { + function folderTreeClick(row, event = {}) { + EventUtils.synthesizeMouseAtCenter( + folderTree.rows[row].querySelector(".name"), + event, + about3Pane + ); + } + function threadTreeClick(row, event = {}) { + EventUtils.synthesizeMouseAtCenter( + threadTree.getRowAtIndex(row), + event, + about3Pane + ); + } + + /** @implements {nsIExternalProtocolService} */ + let mockExternalProtocolService = { + QueryInterface: ChromeUtils.generateQI(["nsIExternalProtocolService"]), + _loadedURLs: [], + loadURI(uri, windowContext) { + this._loadedURLs.push(uri.spec); + }, + isExposedProtocol(scheme) { + return true; + }, + urlLoaded(url) { + return this._loadedURLs.includes(url); + }, + }; + + let mockExternalProtocolServiceCID = MockRegistrar.register( + "@mozilla.org/uriloader/external-protocol-service;1", + mockExternalProtocolService + ); + + registerCleanupFunction(() => { + MockRegistrar.unregister(mockExternalProtocolServiceCID); + + // Some tests that open new windows don't return focus to the main window + // in a way that satisfies mochitest, and the test times out. + Services.focus.focusedWindow = about3Pane; + }); + + let tabmail = document.getElementById("tabmail"); + let about3Pane = tabmail.currentAbout3Pane; + let { folderTree, threadTree, messageBrowser } = about3Pane; + let menu = about3Pane.document.getElementById("folderPaneContext"); + let menuItem = about3Pane.document.getElementById( + "folderPaneContext-subscribe" + ); + // Not `currentAboutMessage` as that's null right now. + let aboutMessage = messageBrowser.contentWindow; + let messagePane = aboutMessage.getMessagePaneBrowser(); + + let account = MailServices.accounts.getAccount("account1"); + let rootFolder = account.incomingServer.rootFolder; + about3Pane.displayFolder(rootFolder.URI); + let index = about3Pane.folderTree.selectedIndex; + Assert.equal(index, 0); + + let shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + folderTreeClick(index, { type: "contextmenu" }); + await shownPromise; + + let hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + let dialogPromise = BrowserTestUtils.promiseAlertDialog( + null, + "chrome://messenger-newsblog/content/feed-subscriptions.xhtml", + { + async callback(dialogWindow) { + let dialogDocument = dialogWindow.document; + + let list = dialogDocument.getElementById("rssSubscriptionsList"); + let locationInput = dialogDocument.getElementById("locationValue"); + let addFeedButton = dialogDocument.getElementById("addFeed"); + + await BrowserTestUtils.waitForEvent(list, "select"); + + EventUtils.synthesizeMouseAtCenter(locationInput, {}, dialogWindow); + await TestUtils.waitForCondition(() => !addFeedButton.disabled); + EventUtils.sendString( + "https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml", + dialogWindow + ); + EventUtils.synthesizeKey("VK_TAB", {}, dialogWindow); + + // There's no good way to know if we're ready to continue. + await new Promise(r => dialogWindow.setTimeout(r, 250)); + + let hiddenPromise = BrowserTestUtils.waitForAttribute( + "hidden", + addFeedButton, + "true" + ); + EventUtils.synthesizeMouseAtCenter(addFeedButton, {}, dialogWindow); + await hiddenPromise; + + EventUtils.synthesizeMouseAtCenter( + dialogDocument.querySelector("dialog").getButton("accept"), + {}, + dialogWindow + ); + }, + } + ); + menu.activateItem(menuItem); + await Promise.all([hiddenPromise, dialogPromise]); + + let folder = rootFolder.subFolders.find(f => f.name == "Test Feed"); + Assert.ok(folder); + + about3Pane.displayFolder(folder.URI); + index = folderTree.selectedIndex; + Assert.equal(about3Pane.threadTree.view.rowCount, 1); + + // Description mode. + + let loadedPromise = BrowserTestUtils.browserLoaded(messagePane); + threadTreeClick(0); + await loadedPromise; + + Assert.notEqual(messagePane.currentURI.spec, "about:blank"); + await SpecialPowers.spawn(messagePane, [], () => { + let doc = content.document; + + let p = doc.querySelector("p"); + Assert.equal(p.textContent, "This is the description."); + + let style = content.getComputedStyle(doc.body); + Assert.equal(style.backgroundColor, "rgba(0, 0, 0, 0)"); + + let noscript = doc.querySelector("noscript"); + style = content.getComputedStyle(noscript); + Assert.equal(style.display, "inline"); + }); + + Assert.ok( + aboutMessage.document.getElementById("expandedtoRow").hidden, + "The To field is not visible" + ); + Assert.equal( + aboutMessage.document.getElementById("dateLabel").textContent, + aboutMessage.document.getElementById("dateLabelSubject").textContent, + "The regular date label and the subject date have the same value" + ); + Assert.ok( + BrowserTestUtils.is_hidden( + aboutMessage.document.getElementById("dateLabel"), + "The regular date label is not visible" + ) + ); + Assert.ok( + BrowserTestUtils.is_visible( + aboutMessage.document.getElementById("dateLabelSubject") + ), + "The date label on the subject line is visible" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, messagePane); + Assert.deepEqual(mockExternalProtocolService._loadedURLs, [ + "https://example.org/link/from/description", + ]); + mockExternalProtocolService._loadedURLs.length = 0; + + // Web mode. + + loadedPromise = BrowserTestUtils.browserLoaded( + messagePane, + false, + "https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/article.html" + ); + window.FeedMessageHandler.onSelectPref = 0; + await loadedPromise; + + await SpecialPowers.spawn(messagePane, [], () => { + let doc = content.document; + + let p = doc.querySelector("p"); + Assert.equal(p.textContent, "This is the article."); + + let style = content.getComputedStyle(doc.body); + Assert.equal(style.backgroundColor, "rgb(0, 128, 0)"); + + let noscript = doc.querySelector("noscript"); + style = content.getComputedStyle(noscript); + Assert.equal(style.display, "none"); + }); + await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, messagePane); + Assert.deepEqual(mockExternalProtocolService._loadedURLs, [ + "https://example.org/link/from/article", + ]); + mockExternalProtocolService._loadedURLs.length = 0; + + // Clean up. + + shownPromise = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + about3Pane.folderTree.selectedRow, + { type: "contextmenu" }, + about3Pane + ); + await shownPromise; + + hiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + let promptPromise = BrowserTestUtils.promiseAlertDialog("accept"); + menuItem = about3Pane.document.getElementById("folderPaneContext-remove"); + menu.activateItem(menuItem); + await Promise.all([hiddenPromise, promptPromise]); + + window.FeedMessageHandler.onSelectPref = 1; + + folderTree.selectedIndex = 0; +}); diff --git a/comm/mailnews/extensions/newsblog/test/browser/data/article.html b/comm/mailnews/extensions/newsblog/test/browser/data/article.html new file mode 100644 index 0000000000..4c6b780c41 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/browser/data/article.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"/> + <title></title> + </head> + <body> + <p>This is the article.</p> + <p><a href="https://example.org/link/from/article">Here's a link.</a></p> + <script> + document.body.style.backgroundColor = "green"; + </script> + <noscript> + <p>This noscript should not display.</p> + </noscript> + </body> +</html> diff --git a/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml b/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml new file mode 100644 index 0000000000..ec6aebccc1 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/browser/data/rss.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0"> + <channel> + <title>Test Feed</title> + <link>https://example.org/</link> + <description></description> + <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate> + <language>en-US</language> + + <item> + <title>Test Article</title> + <link>https://example.org/browser/comm/mailnews/extensions/newsblog/test/browser/data/article.html</link> + <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate> + + <description><![CDATA[ + <p>This is the description.</p> + <p><a href="https://example.org/link/from/description">Here's a link.</a></p> + <script> + document.body.style.backgroundColor = "red"; + </script> + <noscript> + <p>This noscript should display.</p> + </noscript> + ]]></description> + </item> + </channel> +</rss> diff --git a/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js b/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js new file mode 100644 index 0000000000..004e1acf68 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/head_feeds.js @@ -0,0 +1,35 @@ +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); +var { localAccountUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/LocalAccountUtils.jsm" +); + +let { FeedParser } = ChromeUtils.import("resource:///modules/FeedParser.jsm"); +let { Feed } = ChromeUtils.import("resource:///modules/Feed.jsm"); +let { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); +let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +// Set up local web server to serve up test files. +// We run it on a random port so that other tests can run concurrently +// even if they also run a web server. +let httpServer = new HttpServer(); +httpServer.registerDirectory("/", do_get_file("resources")); +httpServer.start(-1); +const SERVER_PORT = httpServer.identity.primaryPort; + +// Ensure the profile directory is set up +do_get_profile(); + +var gDEPTH = "../../../../../"; + +registerCleanupFunction(async () => { + await httpServer.stop(); + load(gDEPTH + "mailnews/resources/mailShutdown.js"); +}); diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/README.md b/comm/mailnews/extensions/newsblog/test/unit/resources/README.md new file mode 100644 index 0000000000..df655769b0 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/README.md @@ -0,0 +1,24 @@ +Data files for unit testing the feeds code. + +- `rss_7_1.rdf` + Simple RSS1.0 feed example, from: + https://www.w3.org/2000/10/rdf-tests/RSS_1.0/rss_7_1.rdf + +- `rss_7_1_BORKED.rdf` + Sabotaged version of `rss_7_1.rdf` with a bad + <items> list, pointing to all sorts of URLs not + represented as <item>s in the feed (see Bug 476641). + +- `rss2_example.xml` + RSS2.0 example from wikipedia, but with + Japanese text in the title, with leading/trailing + whitespace. + +- `rss2_guid.xml` + RSS2.0 feed where two items have the same link but different guid. + (they should both appear in the feed). + +- `feeds-*/` + Test cases for migrating legacy .rdf config files to + the new json ones. The .rdf files are the old data, + and the .json files are the expected results. diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.json @@ -0,0 +1 @@ +{} diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf new file mode 100644 index 0000000000..81a5a62ec5 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeditems.rdf @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.json @@ -0,0 +1 @@ +[] diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf new file mode 100644 index 0000000000..f7e6b400e2 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-bad/feeds.rdf @@ -0,0 +1,17 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description RDF:about="urn:forumzilla:root"> + <fz:feeds RDF:resource="rdf:#$cvA6q"/> + </RDF:Description> + <fz:feed RDF:about="https://example.com/feed/" + fz:quickMode="false" + dc:title="A feed with no dc:identifier, and thus no url. Should be ditched."> + </fz:feed> + <RDF:Seq RDF:about="rdf:#$cvA6q"> + <RDF:li RDF:resource="https://example.com/feed/"/> + </RDF:Seq> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.json @@ -0,0 +1 @@ +{} diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf new file mode 100644 index 0000000000..81a5a62ec5 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeditems.rdf @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.json @@ -0,0 +1 @@ +[] diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf new file mode 100644 index 0000000000..a5f9c997e8 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-empty/feeds.rdf @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description RDF:about="urn:forumzilla:root"> + <fz:feeds RDF:resource="rdf:#$cvA6q"/> + </RDF:Description> + <RDF:Seq RDF:about="rdf:#$cvA6q"> + </RDF:Seq> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.json @@ -0,0 +1 @@ +{} diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf new file mode 100644 index 0000000000..81a5a62ec5 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeditems.rdf @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json new file mode 100644 index 0000000000..e20f7a2f00 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.json @@ -0,0 +1,23 @@ +[ + { + "title": "Government Digital Service", + "url": "https://gds.blog.gov.uk/feed/", + "quickMode": false, + "options": { + "version": 2, + "updates": { + "enabled": true, + "updateMinutes": 100, + "updateUnits": "min", + "lastUpdateTime": 1568784489107, + "lastDownloadTime": null, + "updatePeriod": "", + "updateFrequency": "", + "updateBase": "" + }, + "category": { "enabled": false, "prefixEnabled": false, "prefix": "" } + }, + "destFolder": "mailbox://nobody@Feeds/Government%20Digital%20Service", + "link": "https://gds.blog.gov.uk/feed/" + } +] diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf new file mode 100644 index 0000000000..3b6b6b2df8 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-missing-timestamp/feeds.rdf @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description RDF:about="urn:forumzilla:root"> + <fz:feeds RDF:resource="rdf:#$cvA6q"/> + </RDF:Description> + <fz:feed RDF:about="https://gds.blog.gov.uk/feed/" + fz:quickMode="false" + dc:title="Government Digital Service" + NS1:link="https://gds.blog.gov.uk/feed/" + fz:options="{"version":2,"updates":{"enabled":true,"updateMinutes":100,"updateUnits":"min","lastUpdateTime":1568784489107,"lastDownloadTime":null,"updatePeriod":"","updateFrequency":"","updateBase":""},"category":{"enabled":false,"prefixEnabled":false,"prefix":""}}" + dc:identifier="https://gds.blog.gov.uk/feed/"> + <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Government%20Digital%20Service"/> + </fz:feed> + <RDF:Seq RDF:about="rdf:#$cvA6q"> + <RDF:li RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Seq> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json new file mode 100644 index 0000000000..a16c68f0c0 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.json @@ -0,0 +1,122 @@ +{ + "https://gds.blog.gov.uk/?p=32978": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488792, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32944": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488971, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=33011": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488610, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=33020": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488551, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16464": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520041, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32951": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488909, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32963": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488851, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16431": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520152, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16477": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784519983, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32939": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784489030, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16453": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520096, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16418": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520209, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16507": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784519869, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16490": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784519926, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16378": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520323, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32927": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784489089, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=33001": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488670, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16393": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784520265, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://civilservice.blog.gov.uk/?p=16514": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784519812, + "feedURLs": ["https://civilservice.blog.gov.uk/feed/"] + }, + "https://gds.blog.gov.uk/?p=32988": { + "stored": true, + "valid": true, + "lastSeenTime": 1568784488731, + "feedURLs": ["https://gds.blog.gov.uk/feed/"] + } +} diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf new file mode 100644 index 0000000000..f0941b5f6b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeditems.rdf @@ -0,0 +1,126 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32978" + fz:stored="true" + fz:last-seen-timestamp="1568784488792" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32944" + fz:stored="true" + fz:last-seen-timestamp="1568784488971" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33011" + fz:stored="true" + fz:last-seen-timestamp="1568784488610" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33020" + fz:stored="true" + fz:last-seen-timestamp="1568784488551" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16464" + fz:stored="true" + fz:last-seen-timestamp="1568784520041" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32951" + fz:stored="true" + fz:last-seen-timestamp="1568784488909" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32963" + fz:stored="true" + fz:last-seen-timestamp="1568784488851" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16431" + fz:stored="true" + fz:last-seen-timestamp="1568784520152" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16477" + fz:stored="true" + fz:last-seen-timestamp="1568784519983" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32939" + fz:stored="true" + fz:last-seen-timestamp="1568784489030" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16453" + fz:stored="true" + fz:last-seen-timestamp="1568784520096" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16418" + fz:stored="true" + fz:last-seen-timestamp="1568784520209" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16507" + fz:stored="true" + fz:last-seen-timestamp="1568784519869" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16490" + fz:stored="true" + fz:last-seen-timestamp="1568784519926" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16378" + fz:stored="true" + fz:last-seen-timestamp="1568784520323" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32927" + fz:stored="true" + fz:last-seen-timestamp="1568784489089" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=33001" + fz:stored="true" + fz:last-seen-timestamp="1568784488670" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16393" + fz:stored="true" + fz:last-seen-timestamp="1568784520265" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fcivilservice.blog.gov.uk%2f%3fp=16514" + fz:stored="true" + fz:last-seen-timestamp="1568784519812" + fz:valid="true"> + <fz:feed RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Description> + <RDF:Description RDF:about="urn:feeditem:https:%2f%2fgds.blog.gov.uk%2f%3fp=32988" + fz:stored="true" + fz:last-seen-timestamp="1568784488731" + fz:valid="true"> + <fz:feed RDF:resource="https://gds.blog.gov.uk/feed/"/> + </RDF:Description> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json new file mode 100644 index 0000000000..2ab7d4f780 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.json @@ -0,0 +1,46 @@ +[ + { + "title": "Government Digital Service", + "lastModified": "Wed, 11 Sep 2019 15:47:49 GMT", + "url": "https://gds.blog.gov.uk/feed/", + "quickMode": false, + "options": { + "version": 2, + "updates": { + "enabled": true, + "updateMinutes": 100, + "updateUnits": "min", + "lastUpdateTime": 1568784489107, + "lastDownloadTime": null, + "updatePeriod": "", + "updateFrequency": "", + "updateBase": "" + }, + "category": { "enabled": false, "prefixEnabled": false, "prefix": "" } + }, + "destFolder": "mailbox://nobody@Feeds/Government%20Digital%20Service", + "link": "https://gds.blog.gov.uk/feed/" + }, + { + "title": "Civil Service", + "lastModified": "Tue, 17 Sep 2019 16:21:00 GMT", + "url": "https://civilservice.blog.gov.uk/feed/", + "quickMode": false, + "options": { + "version": 2, + "updates": { + "enabled": true, + "updateMinutes": 100, + "updateUnits": "min", + "lastUpdateTime": 1568784520338, + "lastDownloadTime": null, + "updatePeriod": "", + "updateFrequency": "", + "updateBase": "" + }, + "category": { "enabled": false, "prefixEnabled": false, "prefix": "" } + }, + "destFolder": "mailbox://nobody@Feeds/Civil%20Service", + "link": "https://civilservice.blog.gov.uk/feed/" + } +] diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf new file mode 100644 index 0000000000..5c2fb72c74 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/feeds-simple/feeds.rdf @@ -0,0 +1,32 @@ +<?xml version="1.0"?> +<RDF:RDF xmlns:NS1="http://purl.org/rss/1.0/" + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:fz="urn:forumzilla:" + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description RDF:about="urn:forumzilla:root"> + <fz:feeds RDF:resource="rdf:#$cvA6q"/> + </RDF:Description> + <fz:feed RDF:about="https://gds.blog.gov.uk/feed/" + fz:quickMode="false" + dc:title="Government Digital Service" + NS1:link="https://gds.blog.gov.uk/feed/" + dc:lastModified="Wed, 11 Sep 2019 15:47:49 GMT" + fz:options="{"version":2,"updates":{"enabled":true,"updateMinutes":100,"updateUnits":"min","lastUpdateTime":1568784489107,"lastDownloadTime":null,"updatePeriod":"","updateFrequency":"","updateBase":""},"category":{"enabled":false,"prefixEnabled":false,"prefix":""}}" + dc:identifier="https://gds.blog.gov.uk/feed/"> + <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Government%20Digital%20Service"/> + </fz:feed> + <fz:feed RDF:about="https://civilservice.blog.gov.uk/feed/" + fz:quickMode="false" + dc:title="Civil Service" + NS1:link="https://civilservice.blog.gov.uk/feed/" + dc:lastModified="Tue, 17 Sep 2019 16:21:00 GMT" + fz:options="{"version":2,"updates":{"enabled":true,"updateMinutes":100,"updateUnits":"min","lastUpdateTime":1568784520338,"lastDownloadTime":null,"updatePeriod":"","updateFrequency":"","updateBase":""},"category":{"enabled":false,"prefixEnabled":false,"prefix":""}}" + dc:identifier="https://civilservice.blog.gov.uk/feed/"> + <fz:destFolder RDF:resource="mailbox://nobody@Feeds/Civil%20Service"/> + </fz:feed> + <RDF:Seq RDF:about="rdf:#$cvA6q"> + <RDF:li RDF:resource="https://gds.blog.gov.uk/feed/"/> + <RDF:li RDF:resource="https://civilservice.blog.gov.uk/feed/"/> + </RDF:Seq> +</RDF:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml new file mode 100644 index 0000000000..0fe6268ec1 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_example.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0"> +<channel> + <title> + + 本当に簡単なシンジケーションの例 + + </title> + <description>This is an example of an RSS feed</description> + <link>http://www.example.com/main.html</link> + <lastBuildDate>Mon, 06 Sep 2010 00:01:00 +0000 </lastBuildDate> + <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate> + <ttl>1800</ttl> + + <item> + <title>Example entry</title> + <description>Here is some text containing an interesting description.</description> + <link>http://www.example.com/blog/post/1</link> + <guid isPermaLink="false">7bd204c6-1655-4c27-aeee-53f933c5395f</guid> + <pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate> + </item> + +</channel> +</rss> + diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml new file mode 100644 index 0000000000..9f1efea9cc --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss2_guid.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0"> +<channel> + <title>GUID test</title> + <description>This RSS feed has multiple items with the same link, but with different guids. They should be treated as separate items (see Bug 1656090)</description> + <link>http://www.example.com/main.html</link> + <lastBuildDate>Mon, 06 Sep 2020 00:01:00 +0000 </lastBuildDate> + <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate> + <ttl>1800</ttl> + + <item> + <title>Entry One</title> + <description>Blah blah blah.</description> + <link>http://www.example.com/blog/post/1</link> + <guid isPermaLink="false">0524a046-df56-11ea-8bc9-47d63411283f</guid> + <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate> + </item> + <item> + <title>Entry One Again(with same link but different guid!)</title> + <description>Blah blah blah.</description> + <link>http://www.example.com/blog/post/1</link> + <guid isPermaLink="false">0524a35c-df56-11ea-8bca-43f820f3bd93</guid> + <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate> + </item> + <item> + <title>Entry Two</title> + <description>Blah blah blah.</description> + <link>http://www.example.com/blog/post/2</link> + <guid isPermaLink="false">0524a442-df56-11ea-8bcb-035a71f5f71a</guid> + <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate> + </item> + <item> + <title>Entry Three</title> + <description>Blah blah blah.</description> + <link>http://www.example.com/blog/post/3</link> + <guid isPermaLink="false">0524a53c-df56-11ea-8bcc-8f0977d58351</guid> + <pubDate>Sun, 06 Sep 2019 16:20:00 +0000</pubDate> + </item> + +</channel> +</rss> + diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf new file mode 100644 index 0000000000..179965e7de --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1.rdf @@ -0,0 +1,66 @@ +<?xml version="1.0"?> + +<!-- RDF Site Summary (RSS) 1.0 + http://groups.yahoo.com/group/rss-dev/files/specification.html + Section 7 + --> + +<rdf:RDF + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://purl.org/rss/1.0/"> + + <channel rdf:about="http://www.xml.com/xml/news.rss"> + <title>XML.com</title> + <link>http://xml.com/pub</link> + <description> + XML.com features a rich mix of information and services + for the XML community. + </description> + + <image rdf:resource="http://xml.com/universal/images/xml_tiny.gif" /> + + <items> + <rdf:Seq> + <rdf:li resource="http://xml.com/pub/2000/08/09/xslt/xslt.html" /> + <rdf:li resource="http://xml.com/pub/2000/08/09/rdfdb/index.html" /> + </rdf:Seq> + </items> + + <textinput rdf:resource="http://search.xml.com" /> + + </channel> + + <image rdf:about="http://xml.com/universal/images/xml_tiny.gif"> + <title>XML.com</title> + <link>http://www.xml.com</link> + <url>http://xml.com/universal/images/xml_tiny.gif</url> + </image> + + <item rdf:about="http://xml.com/pub/2000/08/09/xslt/xslt.html"> + <title>Processing Inclusions with XSLT</title> + <link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link> + <description> + Processing document inclusions with general XML tools can be + problematic. This article proposes a way of preserving inclusion + information through SAX-based processing. + </description> + </item> + + <item rdf:about="http://xml.com/pub/2000/08/09/rdfdb/index.html"> + <title>Putting RDF to Work</title> + <link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link> + <description> + Tool and API support for the Resource Description Framework + is slowly coming of age. Edd Dumbill takes a look at RDFDB, + one of the most exciting new RDF toolkits. + </description> + </item> + + <textinput rdf:about="http://search.xml.com"> + <title>Search XML.com</title> + <description>Search XML.com's XML collection</description> + <name>s</name> + <link>http://search.xml.com</link> + </textinput> + +</rdf:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf new file mode 100644 index 0000000000..e2c6e0b109 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/resources/rss_7_1_BORKED.rdf @@ -0,0 +1,66 @@ +<?xml version="1.0"?> + +<!-- RDF Site Summary (RSS) 1.0 + http://groups.yahoo.com/group/rss-dev/files/specification.html + Section 7 + --> + +<rdf:RDF + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://purl.org/rss/1.0/"> + + <channel rdf:about="http://www.xml.com/xml/news.rss"> + <title>XML.com</title> + <link>http://xml.com/pub</link> + <description> + XML.com features a rich mix of information and services + for the XML community. + </description> + + <image rdf:resource="http://xml.com/universal/images/xml_tiny.gif" /> + + <items> + <rdf:Seq> + <rdf:li resource="http://OBVIOUSLY_WRONG_URL.com/blah/blah.html" /> + <rdf:li resource="http://ANOTHER_OBVIOUSLY_WRONG_URL.com/foo/bar/wibble.html" /> + </rdf:Seq> + </items> + + <textinput rdf:resource="http://search.xml.com" /> + + </channel> + + <image rdf:about="http://xml.com/universal/images/xml_tiny.gif"> + <title>XML.com</title> + <link>http://www.xml.com</link> + <url>http://xml.com/universal/images/xml_tiny.gif</url> + </image> + + <item rdf:about="http://xml.com/pub/2000/08/09/xslt/xslt.html"> + <title>Processing Inclusions with XSLT</title> + <link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link> + <description> + Processing document inclusions with general XML tools can be + problematic. This article proposes a way of preserving inclusion + information through SAX-based processing. + </description> + </item> + + <item rdf:about="http://xml.com/pub/2000/08/09/rdfdb/index.html"> + <title>Putting RDF to Work</title> + <link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link> + <description> + Tool and API support for the Resource Description Framework + is slowly coming of age. Edd Dumbill takes a look at RDFDB, + one of the most exciting new RDF toolkits. + </description> + </item> + + <textinput rdf:about="http://search.xml.com"> + <title>Search XML.com</title> + <description>Search XML.com's XML collection</description> + <name>s</name> + <link>http://search.xml.com</link> + </textinput> + +</rdf:RDF> diff --git a/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js b/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js new file mode 100644 index 0000000000..6577280356 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/test_feedparser.js @@ -0,0 +1,146 @@ +// see https://www.w3.org/2000/10/rdf-tests/ for test files (including rss1) +// - test examples for all feed types +// - test items in test_download() +// - test rss1 feed with itunes `new-feed-url` redirect +// - test rss1 feed with RSS syndication extension tags (updatePeriod et al) +// - test multiple/missing authors (with fallback to feed title) +// - test missing dates +// - test content formatting + +// Some RSS1 feeds in the wild: +// https://www.livejournal.com/stats/latest-rss.bml +// https://journals.sagepub.com/action/showFeed?ui=0&mi=ehikzz&ai=2b4&jc=acrc&type=etoc&feed=rss +// https://www.revolutionspodcast.com/index.rdf +// https://www.tandfonline.com/feed/rss/uasa20 +// http://export.arxiv.org/rss/astro-ph +// - uses html formatting in <dc:creator> + +// Helper to compare feeditems. +function assertItemsEqual(got, expected) { + Assert.equal(got.length, expected.length); + for (let i = 0; i < expected.length; i++) { + // Only check fields in expected. Means testdata can exclude "description" and other bulky fields. + for (let k of Object.keys(expected[i])) { + Assert.equal(got[i][k], expected[i][k]); + } + } +} + +// Test the rss1 feed parser +add_task(async function test_rss1() { + // Boilerplate. + let account = FeedUtils.createRssAccount("test_rss1"); + let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + let folder = rootFolder.createLocalSubfolder("folderofeeds"); + + // These two files yield the same feed, but the second one has a sabotaged + // <items> to simulate badly-encoded feeds seen in the wild. + for (let testFile of [ + "resources/rss_7_1.rdf", + "resources/rss_7_1_BORKED.rdf", + ]) { + dump(`checking ${testFile}\n`); + // Would be nicer to use the test http server to fetch the file, but that + // would involve XMLHTTPRequest. This is more concise. + let doc = await do_parse_document(testFile, "application/xml"); + let feed = new Feed( + "https://www.w3.org/2000/10/rdf-tests/RSS_1.0/rss_7_1.rdf", + folder + ); + feed.parseItems = true; // We want items too, not just the feed details. + feed.onParseError = function (f) { + throw new Error("PARSE ERROR"); + }; + let parser = new FeedParser(); + let items = parser.parseAsRSS1(feed, doc); + + // Check some channel details. + Assert.equal(feed.title, "XML.com"); + Assert.equal(feed.link, "http://xml.com/pub"); + + // Check the items (the titles and links at least!). + assertItemsEqual(items, [ + { + url: "http://xml.com/pub/2000/08/09/xslt/xslt.html", + title: "Processing Inclusions with XSLT", + }, + { + url: "http://xml.com/pub/2000/08/09/rdfdb/index.html", + title: "Putting RDF to Work", + }, + ]); + } +}); + +// Test feed downloading. +// Mainly checking that it doesn't crash and that the right feed parser is used. +add_task(async function test_download() { + // Boilerplate + let account = FeedUtils.createRssAccount("test_feed_download"); + let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + // load & parse example rss feed + // Feed object rejects anything other than http and https, so we're + // running a local http server for testing (see head_feeds.js for it). + let feedTests = [ + { + url: "http://localhost:" + SERVER_PORT + "/rss_7_1.rdf", + feedType: "RSS_1.xRDF", + title: "XML.com", + expectedItems: 2, + }, + { + // Has Japanese title with leading/trailing whitespace. + url: "http://localhost:" + SERVER_PORT + "/rss2_example.xml", + feedType: "RSS_2.0", + title: "本当に簡単なシンジケーションの例", + expectedItems: 1, + }, + { + // Has two items with same link but different guid (Bug 1656090). + url: "http://localhost:" + SERVER_PORT + "/rss2_guid.xml", + feedType: "RSS_2.0", + title: "GUID test", + expectedItems: 4, + }, + // TODO: examples for the other feed types! + ]; + + let n = 1; + for (let test of feedTests) { + let folder = rootFolder.createLocalSubfolder("feed" + n); + n++; + let feed = new Feed(test.url, folder); + + let dl = new Promise(function (resolve, reject) { + let cb = { + downloaded(f, error, disable) { + if (error != FeedUtils.kNewsBlogSuccess) { + reject( + new Error(`download failed (url=${feed.url} error=${error})`) + ); + return; + } + // Feed has downloaded - make sure the right type was detected. + Assert.equal(feed.mFeedType, test.feedType, "feed type matching"); + Assert.equal(feed.title, test.title, "title matching"); + // Make sure we're got the expected number of messages in the folder. + let cnt = [...folder.messages].length; + Assert.equal(cnt, test.expectedItems, "itemcount matching"); + + resolve(); + }, + onProgress(f, loaded, total, lengthComputable) {}, + }; + + feed.download(true, cb); + }); + + // Wait for this feed to complete downloading. + await dl; + } +}); diff --git a/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js b/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js new file mode 100644 index 0000000000..a2bc2cf702 --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/test_rdfmigration.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { MailMigrator } = ChromeUtils.import( + "resource:///modules/MailMigrator.jsm" +); + +/** + * Tests migration of old .rdf feed config files to the new .json files. + * + * @param {String} testDataDir - A directory containing legacy feeds.rdf and + * feeditems.rdf files, along with coressponding + * .json files containing the expected results + * of the migration. + * @returns {void} + */ +async function migrationTest(testDataDir) { + // Set up an RSS account/server. + let account = FeedUtils.createRssAccount("rss_migration_test"); + let rootFolder = account.incomingServer.rootMsgFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + // Note, we don't create any folders to hold downloaded feed items, + // that's OK here, because we're only migrating the config files, not + // downloading feeds. The migration doesn't check destFolder existence. + let rootDir = rootFolder.filePath.path; + + // Install legacy feeds.rdf/feeditems.rdf + for (let f of ["feeds.rdf", "feeditems.rdf"]) { + await IOUtils.copy( + PathUtils.join(testDataDir, f), + PathUtils.join(rootDir, f) + ); + } + + // Perform the migration + await MailMigrator._migrateRSSServer(account.incomingServer); + + // Check actual results against expectations. + for (let f of ["feeds.json", "feeditems.json"]) { + let got = await IOUtils.readJSON(PathUtils.join(rootDir, f)); + let expected = await IOUtils.readJSON(PathUtils.join(testDataDir, f)); + Assert.deepEqual(got, expected, `match ${testDataDir}/${f}`); + } + + // Delete the account and all it's files. + MailServices.accounts.removeAccount(account, true); +} + +add_task(async function test_rdfmigration() { + let testDataDirs = [ + "feeds-simple", + "feeds-empty", + "feeds-missing-timestamp", + "feeds-bad", + ]; + for (let d of testDataDirs) { + await migrationTest(do_get_file("resources/" + d).path); + } +}); diff --git a/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini b/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini new file mode 100644 index 0000000000..3b7fc2869c --- /dev/null +++ b/comm/mailnews/extensions/newsblog/test/unit/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +head = head_feeds.js +tail = +support-files = resources/* + +[test_feedparser.js] +[test_rdfmigration.js] + |