diff options
Diffstat (limited to 'mailnews/extensions/newsblog')
16 files changed, 7576 insertions, 0 deletions
diff --git a/mailnews/extensions/newsblog/content/Feed.js b/mailnews/extensions/newsblog/content/Feed.js new file mode 100644 index 000000000..7e47260a8 --- /dev/null +++ b/mailnews/extensions/newsblog/content/Feed.js @@ -0,0 +1,620 @@ +/* -*- 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/. */ + +// 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: function (aFeed) + { + this.mFeeds[this.normalizeHost(aFeed.url)] = aFeed; + }, + + getFeed: function (aUrl) + { + let index = this.normalizeHost(aUrl); + if (index in this.mFeeds) + return this.mFeeds[index]; + + return null; + }, + + removeFeed: function (aUrl) + { + let index = this.normalizeHost(aUrl); + if (index in this.mFeeds) + delete this.mFeeds[index]; + }, + + normalizeHost: function (aUrl) + { + try + { + let normalizedUrl = Services.io.newURI(aUrl, null, null); + normalizedUrl.host = normalizedUrl.host.toLowerCase(); + return normalizedUrl.spec + } + catch (ex) + { + return aUrl; + } + } +}; + +function Feed(aResource, aRSSServer) +{ + this.resource = aResource.QueryInterface(Ci.nsIRDFResource); + this.server = aRSSServer; +} + +Feed.prototype = +{ + description: null, + author: null, + request: null, + server: null, + downloadCallback: null, + resource: null, + items: new Array(), + itemsStored: 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 Subcribe dialog and opml export. + let name = this.title || this.description || this.url; + 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 = FeedUtils.strings.GetStringFromName("ImportFeedsNew"); + return this.mFolderName = FeedUtils.getSanitizedFolderName(this.server.rootMsgFolder, + folderName, + defaultName, + true); + }, + + download: function(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 ? true : aParseItems ? true : false; + + // 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 (!FeedUtils.isValidScheme(this.url)) + { + // Simulate an invalid feed error. + 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, 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 = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsIXMLHttpRequest); + // 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", FeedUtils.REQUEST_ACCEPT); + this.request.timeout = FeedUtils.REQUEST_TIMEOUT; + this.request.onload = this.onDownloaded; + this.request.onerror = this.onDownloadError; + this.request.ontimeout = this.onDownloadError; + FeedCache.putFeed(this); + this.request.send(null); + }, + + onDownloaded: function(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; + } + + FeedUtils.log.debug("Feed.onDownloaded: got a download - " + 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; + + // The download callback is called asynchronously when parse() is done. + feed.parse(); + }, + + onProgress: function(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: function(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 = FeedUtils.kNewsBlogRequestFailure; + + 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 = FeedUtils.kNewsBlogNoNewItems; + } + else { + let [errType, errName] = FeedUtils.createTCPErrorFromFailedXHR(request); + 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 + // overrideable security certificate errors. + error = FeedUtils.kNewsBlogBadCertError; + + if (request.status == 401 || request.status == 403) + // Unauthorized or Forbidden. + error = FeedUtils.kNewsBlogNoAuthError; + } + + feed.downloadCallback.downloaded(feed, error); + } + + FeedCache.removeFeed(url); + }, + + onParseError: function(aFeed) + { + if (!aFeed) + return; + + aFeed.mInvalidFeed = true; + if (aFeed.downloadCallback) + aFeed.downloadCallback.downloaded(aFeed, FeedUtils.kNewsBlogInvalidFeed); + + FeedCache.removeFeed(aFeed.url); + }, + + onUrlChange: function(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, FeedUtils.kNewsBlogCancel); + + FeedCache.removeFeed(aOldUrl); + }, + + get url() + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let url = ds.GetTarget(this.resource, FeedUtils.DC_IDENTIFIER, true); + if (url) + url = url.QueryInterface(Ci.nsIRDFLiteral).Value; + else + url = this.resource.ValueUTF8; + + return url; + }, + + get title() + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let title = ds.GetTarget(this.resource, FeedUtils.DC_TITLE, true); + if (title) + title = title.QueryInterface(Ci.nsIRDFLiteral).Value; + + return title; + }, + + set title (aNewTitle) + { + if (!aNewTitle) + return; + + let ds = FeedUtils.getSubscriptionsDS(this.server); + aNewTitle = FeedUtils.rdf.GetLiteral(aNewTitle); + let old_title = ds.GetTarget(this.resource, FeedUtils.DC_TITLE, true); + if (old_title) + ds.Change(this.resource, FeedUtils.DC_TITLE, old_title, aNewTitle); + else + ds.Assert(this.resource, FeedUtils.DC_TITLE, aNewTitle, true); + }, + + get lastModified() + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let lastModified = ds.GetTarget(this.resource, + FeedUtils.DC_LASTMODIFIED, + true); + if (lastModified) + lastModified = lastModified.QueryInterface(Ci.nsIRDFLiteral).Value; + + return lastModified; + }, + + set lastModified(aLastModified) + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + aLastModified = FeedUtils.rdf.GetLiteral(aLastModified); + let old_lastmodified = ds.GetTarget(this.resource, + FeedUtils.DC_LASTMODIFIED, + true); + if (old_lastmodified) + ds.Change(this.resource, FeedUtils.DC_LASTMODIFIED, + old_lastmodified, aLastModified); + else + ds.Assert(this.resource, FeedUtils.DC_LASTMODIFIED, aLastModified, true); + }, + + get quickMode () + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let quickMode = ds.GetTarget(this.resource, FeedUtils.FZ_QUICKMODE, true); + if (quickMode) + { + quickMode = quickMode.QueryInterface(Ci.nsIRDFLiteral); + quickMode = quickMode.Value == "true"; + } + + return quickMode; + }, + + set quickMode (aNewQuickMode) + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + aNewQuickMode = FeedUtils.rdf.GetLiteral(aNewQuickMode); + let old_quickMode = ds.GetTarget(this.resource, + FeedUtils.FZ_QUICKMODE, + true); + if (old_quickMode) + ds.Change(this.resource, FeedUtils.FZ_QUICKMODE, + old_quickMode, aNewQuickMode); + else + ds.Assert(this.resource, FeedUtils.FZ_QUICKMODE, + aNewQuickMode, true); + }, + + get options () + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let options = ds.GetTarget(this.resource, FeedUtils.FZ_OPTIONS, true); + if (options) + return JSON.parse(options.QueryInterface(Ci.nsIRDFLiteral).Value); + + return null; + }, + + set options (aOptions) + { + let newOptions = aOptions ? FeedUtils.newOptions(aOptions) : + FeedUtils._optionsDefault; + let ds = FeedUtils.getSubscriptionsDS(this.server); + newOptions = FeedUtils.rdf.GetLiteral(JSON.stringify(newOptions)); + let oldOptions = ds.GetTarget(this.resource, FeedUtils.FZ_OPTIONS, true); + if (oldOptions) + ds.Change(this.resource, FeedUtils.FZ_OPTIONS, oldOptions, newOptions); + else + ds.Assert(this.resource, FeedUtils.FZ_OPTIONS, newOptions, true); + }, + + categoryPrefs: function () + { + let categoryPrefsAcct = FeedUtils.getOptionsAcct(this.server).category; + if (!this.options) + return categoryPrefsAcct; + + return this.options.category; + }, + + get link () + { + let ds = FeedUtils.getSubscriptionsDS(this.server); + let link = ds.GetTarget(this.resource, FeedUtils.RSS_LINK, true); + if (link) + link = link.QueryInterface(Ci.nsIRDFLiteral).Value; + + return link; + }, + + set link (aNewLink) + { + if (!aNewLink) + return; + + let ds = FeedUtils.getSubscriptionsDS(this.server); + aNewLink = FeedUtils.rdf.GetLiteral(aNewLink); + let old_link = ds.GetTarget(this.resource, FeedUtils.RSS_LINK, true); + if (old_link) + ds.Change(this.resource, FeedUtils.RSS_LINK, old_link, aNewLink); + else + ds.Assert(this.resource, FeedUtils.RSS_LINK, aNewLink, true); + }, + + parse: function() + { + // Create a feed parser which will parse the feed. + let parser = new FeedParser(); + this.itemsToStore = parser.parseFeed(this, this.request.responseXML); + parser = null; + + if (this.mInvalidFeed) + { + this.request = null; + this.mInvalidFeed = false; + return; + } + + // storeNextItem() will iterate through the parsed items, storing each one. + this.itemsToStoreIndex = 0; + this.itemsStored = 0; + this.storeNextItem(); + }, + + invalidateItems: function () + { + let ds = FeedUtils.getItemsDS(this.server); + FeedUtils.log.debug("Feed.invalidateItems: for url - " + this.url); + let items = ds.GetSources(FeedUtils.FZ_FEED, this.resource, true); + let item; + + while (items.hasMoreElements()) + { + item = items.getNext(); + item = item.QueryInterface(Ci.nsIRDFResource); + FeedUtils.log.trace("Feed.invalidateItems: item - " + item.Value); + let valid = ds.GetTarget(item, FeedUtils.FZ_VALID, true); + if (valid) + ds.Unassert(item, FeedUtils.FZ_VALID, valid, true); + } + }, + + removeInvalidItems: function(aDeleteFeed) + { + let ds = FeedUtils.getItemsDS(this.server); + FeedUtils.log.debug("Feed.removeInvalidItems: for url - " + this.url); + let items = ds.GetSources(FeedUtils.FZ_FEED, this.resource, true); + let item; + let currentTime = new Date().getTime(); + while (items.hasMoreElements()) + { + item = items.getNext(); + item = item.QueryInterface(Ci.nsIRDFResource); + + if (ds.HasAssertion(item, FeedUtils.FZ_VALID, + FeedUtils.RDF_LITERAL_TRUE, true)) + continue; + + let lastSeenTime = ds.GetTarget(item, FeedUtils.FZ_LAST_SEEN_TIMESTAMP, true); + if (lastSeenTime) + lastSeenTime = parseInt(lastSeenTime.QueryInterface(Ci.nsIRDFLiteral).Value) + else + lastSeenTime = 0; + + if ((currentTime - lastSeenTime) < FeedUtils.INVALID_ITEM_PURGE_DELAY && + !aDeleteFeed) + // Don't immediately purge items in active feeds; do so for deleted feeds. + continue; + + FeedUtils.log.trace("Feed.removeInvalidItems: item - " + item.Value); + ds.Unassert(item, FeedUtils.FZ_FEED, this.resource, true); + if (ds.hasArcOut(item, FeedUtils.FZ_FEED)) + FeedUtils.log.debug("Feed.removeInvalidItems: " + item.Value + + " is from more than one feed; only the reference to" + + " this feed removed"); + else + FeedUtils.removeAssertions(ds, item); + } + }, + + createFolder: function() + { + if (this.folder) + return; + + try { + this.folder = this.server.rootMsgFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .createLocalSubfolder(this.folderName); + } + catch (ex) { + // An error creating. + 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, null); + } + }, + + // 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: function() + { + if (FeedUtils.CANCEL_REQUESTED) + { + FeedUtils.CANCEL_REQUESTED = false; + this.cleanupParsingState(this, FeedUtils.kNewsBlogCancel); + return; + } + + if (!this.itemsToStore || !this.itemsToStore.length) + { + let code = FeedUtils.kNewsBlogSuccess; + this.createFolder(); + if (!this.folder) + code = FeedUtils.kNewsBlogFileError; + this.cleanupParsingState(this, code); + return; + } + + let item = this.itemsToStore[this.itemsToStoreIndex]; + + if (item.store()) + this.itemsStored++; + + if (!this.folder) + { + this.cleanupParsingState(this, 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, FeedUtils.kNewsBlogSuccess); + } + }, + + cleanupParsingState: function(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 == FeedUtils.kNewsBlogSuccess && aFeed.mLastModified) + aFeed.lastModified = aFeed.mLastModified; + + // Flush any feed item changes to disk. + let ds = FeedUtils.getItemsDS(aFeed.server); + ds.Flush(); + 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.itemsStored = 0; + this.storeItemsTimer = null; + + if (aFeed.downloadCallback) + aFeed.downloadCallback.downloaded(aFeed, aCode); + }, + + // nsITimerCallback + notify: function(aTimer) + { + this.storeNextItem(); + } +}; diff --git a/mailnews/extensions/newsblog/content/FeedItem.js b/mailnews/extensions/newsblog/content/FeedItem.js new file mode 100644 index 000000000..09e4eb861 --- /dev/null +++ b/mailnews/extensions/newsblog/content/FeedItem.js @@ -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/. */ + +function FeedItem() +{ + this.mDate = FeedUtils.getValidRFC5322Date(); + this.mUnicodeConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Ci.nsIScriptableUnicodeConverter); + 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: "anonymous", + inReplyTo: "", + keywords: [], + mURL: null, + characterSet: "UTF-8", + + ENCLOSURE_BOUNDARY_PREFIX: "--------------", // 14 dashes + ENCLOSURE_HEADER_BOUNDARY_PREFIX: "------------", // 12 dashes + MESSAGE_TEMPLATE: '\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, null, null).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: function(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" + ">"; + + FeedUtils.log.trace("FeedItem.normalizeMessageID: messageID - " + messageID); + return messageID; + }, + + get itemUniqueURI() + { + return this.createURN(this.id); + }, + + get contentBase() + { + if(this.xmlContentBase) + return this.xmlContentBase + else + return this.mURL; + }, + + store: function() + { + // this.title and this.content contain HTML. + // this.mUrl and this.contentBase contain plain text. + + let stored = false; + let resource = this.findStoredResource(); + if (!this.feed.folder) + return stored; + + if (resource == null) + { + resource = FeedUtils.rdf.GetResource(this.itemUniqueURI); + if (!this.content) + { + 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); + return stored; + }, + + findStoredResource: function() + { + // Checks to see if the item has already been stored in its feed's + // message folder. + FeedUtils.log.trace("FeedItem.findStoredResource: checking if stored - " + + this.identity); + + let server = this.feed.server; + let folder = this.feed.folder; + + if (!folder) + { + 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 = FeedUtils.getItemsDS(server); + let itemURI = this.itemUniqueURI; + let itemResource = FeedUtils.rdf.GetResource(itemURI); + + let downloaded = ds.GetTarget(itemResource, FeedUtils.FZ_STORED, true); + + if (!downloaded || + downloaded.QueryInterface(Ci.nsIRDFLiteral).Value == "false") + { + FeedUtils.log.trace("FeedItem.findStoredResource: not stored"); + return null; + } + + FeedUtils.log.trace("FeedItem.findStoredResource: already stored"); + return itemResource; + }, + + markValid: function(resource) + { + let ds = FeedUtils.getItemsDS(this.feed.server); + + let newTimeStamp = FeedUtils.rdf.GetLiteral(new Date().getTime()); + let currentTimeStamp = ds.GetTarget(resource, + FeedUtils.FZ_LAST_SEEN_TIMESTAMP, + true); + if (currentTimeStamp) + ds.Change(resource, FeedUtils.FZ_LAST_SEEN_TIMESTAMP, + currentTimeStamp, newTimeStamp); + else + ds.Assert(resource, FeedUtils.FZ_LAST_SEEN_TIMESTAMP, + newTimeStamp, true); + + if (!ds.HasAssertion(resource, FeedUtils.FZ_FEED, + FeedUtils.rdf.GetResource(this.feed.url), true)) + ds.Assert(resource, FeedUtils.FZ_FEED, + FeedUtils.rdf.GetResource(this.feed.url), true); + + if (ds.hasArcOut(resource, FeedUtils.FZ_VALID)) + { + let currentValue = ds.GetTarget(resource, FeedUtils.FZ_VALID, true); + ds.Change(resource, FeedUtils.FZ_VALID, + currentValue, FeedUtils.RDF_LITERAL_TRUE); + } + else + ds.Assert(resource, FeedUtils.FZ_VALID, FeedUtils.RDF_LITERAL_TRUE, true); + }, + + markStored: function(resource) + { + let ds = FeedUtils.getItemsDS(this.feed.server); + + if (!ds.HasAssertion(resource, FeedUtils.FZ_FEED, + FeedUtils.rdf.GetResource(this.feed.url), true)) + ds.Assert(resource, FeedUtils.FZ_FEED, + FeedUtils.rdf.GetResource(this.feed.url), true); + + let currentValue; + if (ds.hasArcOut(resource, FeedUtils.FZ_STORED)) + { + currentValue = ds.GetTarget(resource, FeedUtils.FZ_STORED, true); + ds.Change(resource, FeedUtils.FZ_STORED, + currentValue, FeedUtils.RDF_LITERAL_TRUE); + } + else + ds.Assert(resource, FeedUtils.FZ_STORED, + FeedUtils.RDF_LITERAL_TRUE, true); + }, + + mimeEncodeSubject: function(aSubject, aCharset) + { + // This routine sometimes throws exceptions for mis-encoded data so + // wrap it with a try catch for now. + let newSubject; + try + { + newSubject = mailServices.mimeConverter.encodeMimePartIIStr_UTF8(aSubject, + false, + aCharset, 9, 72); + } + catch (ex) + { + newSubject = aSubject; + } + + return newSubject; + }, + + writeToFolder: function() + { + FeedUtils.log.trace("FeedItem.writeToFolder: " + this.identity + + " writing to message folder " + this.feed.name); + // Convert the title to UTF-16 before performing our HTML entity + // replacement reg expressions. + let title = this.title; + + // The subject may contain HTML entities. Convert these to their unencoded + // state. i.e. & becomes '&'. + 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. + title = title.replace(/[\t\r\n]+/g, " ").trim(); + + this.title = this.mimeEncodeSubject(title, this.characterSet); + + // 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") : ""; + + // If there are keywords (categories), create the headers. In the case of + // a longer than RFC5322 recommended line length, create multiple folded + // lines (easier to parse than multiple Keywords headers). + let keywordsStr = ""; + if (this.keywords.length) + { + let HEADER = "Keywords: "; + let MAXLEN = 78; + keywordsStr = HEADER; + let keyword; + let keywords = [].concat(this.keywords); + let lines = []; + while (keywords.length) + { + keyword = keywords.shift(); + if (keywordsStr.length + keyword.length > MAXLEN) + { + lines.push(keywordsStr) + keywordsStr = " ".repeat(HEADER.length); + } + keywordsStr += keyword + ","; + } + keywordsStr = keywordsStr.replace(/,$/,"\n"); + lines.push(keywordsStr) + keywordsStr = lines.join("\n"); + } + + // 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"; + + // The opening line of the message, mandated by standards to start + // with "From ". It's useful to construct this separately because + // we not only need to write it into the message, we also need to + // use it to calculate the offset of the X-Mozilla-Status lines from + // the front of the message for the statusOffset property of the + // DB header object. + let openingLine = 'From - ' + this.mDate + '\n'; + + let source = + openingLine + + 'X-Mozilla-Status: 0000\n' + + 'X-Mozilla-Status2: 00000000\n' + + 'X-Mozilla-Keys: ' + " ".repeat(80) + '\n' + + 'Received: by localhost; ' + FeedUtils.getValidRFC5322Date() + '\n' + + 'Date: ' + this.mDate + '\n' + + 'Message-Id: ' + this.normalizeMessageID(this.id) + '\n' + + 'From: ' + this.author + '\n' + + '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; + + 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 string, we want to save a char * string in + // the original charset. So convert back. + this.mUnicodeConverter.charset = this.characterSet; + let msgDBHdr = folder.addMessage(this.mUnicodeConverter.ConvertFromUnicode(source)); + msgDBHdr.OrFlags(Ci.nsMsgMessageFlags.FeedMsg); + msgFolder.gettingNewMessages = false; + this.tagItem(msgDBHdr, this.keywords); + }, + +/** + * Autotag messages. + * + * @param nsIMsgDBHdr aMsgDBHdr - message to tag + * @param array aKeywords - keywords (tags) + */ + tagItem: function(aMsgDBHdr, aKeywords) + { + let categoryPrefs = this.feed.categoryPrefs(); + if (!aKeywords.length || !categoryPrefs.enabled) + return; + + let msgArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + msgArray.appendElement(aMsgDBHdr, false); + + let prefix = categoryPrefs.prefixEnabled ? categoryPrefs.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, "", 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(msgArray, keys.join(" ")); + }, + + htmlEscape: function(s) + { + s = s.replace(/&/g, "&"); + s = s.replace(/>/g, ">"); + s = s.replace(/</g, "<"); + s = s.replace(/'/g, "'"); + s = s.replace(/"/g, """); + return s; + }, + + createURN: function(aName) + { + // Returns name as a URN in the 'feeditem' namespace. The returned URN is + // (or is intended to be) RFC2141 compliant. + // The builtin encodeURI provides nearly the exact encoding functionality + // required by the RFC. The exceptions are that NULL characters should not + // appear, and that #, /, ?, &, and ~ should be escaped. + // NULL characters are removed before encoding. + + let name = aName.replace(/\0/g, ""); + let encoded = encodeURI(name); + encoded = encoded.replace(/\#/g, "%23"); + encoded = encoded.replace(/\//g, "%2f"); + encoded = encoded.replace(/\?/g, "%3f"); + encoded = encoded.replace(/\&/g, "%26"); + encoded = encoded.replace(/\~/g, "%7e"); + + return FeedUtils.FZ_ITEM_NS + encoded; + } +}; + + +// 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 + { + this.mFileName = Services.io.newURI(this.mURL, null, null). + QueryInterface(Ci.nsIURL). + fileName; + } + 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: function(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' + + FeedUtils.strings.GetStringFromName("externalAttachmentMsg") + '\n'; + } +}; diff --git a/mailnews/extensions/newsblog/content/FeedUtils.jsm b/mailnews/extensions/newsblog/content/FeedUtils.jsm new file mode 100644 index 000000000..6d5e64dd2 --- /dev/null +++ b/mailnews/extensions/newsblog/content/FeedUtils.jsm @@ -0,0 +1,1608 @@ +/* -*- 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/. */ + +this.EXPORTED_SYMBOLS = ["Feed", "FeedItem", "FeedParser", "FeedUtils"]; + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource:///modules/gloda/log4moz.js"); +Cu.import("resource:///modules/mailServices.js"); +Cu.import("resource:///modules/MailUtils.js"); +Cu.import("resource:///modules/jsmime.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +Services.scriptloader.loadSubScript("chrome://messenger-newsblog/content/Feed.js"); +Services.scriptloader.loadSubScript("chrome://messenger-newsblog/content/FeedItem.js"); +Services.scriptloader.loadSubScript("chrome://messenger-newsblog/content/feed-parser.js"); + +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", + get RDF_TYPE() { return this.rdf.GetResource(this.RDF_SYNTAX_TYPE) }, + + RSS_090_NS: "http://my.netscape.com/rdf/simple/0.9/", + + RSS_NS: "http://purl.org/rss/1.0/", + get RSS_CHANNEL() { return this.rdf.GetResource(this.RSS_NS + "channel") }, + get RSS_TITLE() { return this.rdf.GetResource(this.RSS_NS + "title") }, + get RSS_DESCRIPTION() { return this.rdf.GetResource(this.RSS_NS + "description") }, + get RSS_ITEMS() { return this.rdf.GetResource(this.RSS_NS + "items") }, + get RSS_ITEM() { return this.rdf.GetResource(this.RSS_NS + "item") }, + get RSS_LINK() { return this.rdf.GetResource(this.RSS_NS + "link") }, + + RSS_CONTENT_NS: "http://purl.org/rss/1.0/modules/content/", + get RSS_CONTENT_ENCODED() { + return this.rdf.GetResource(this.RSS_CONTENT_NS + "encoded"); + }, + + DC_NS: "http://purl.org/dc/elements/1.1/", + get DC_CREATOR() { return this.rdf.GetResource(this.DC_NS + "creator") }, + get DC_SUBJECT() { return this.rdf.GetResource(this.DC_NS + "subject") }, + get DC_DATE() { return this.rdf.GetResource(this.DC_NS + "date") }, + get DC_TITLE() { return this.rdf.GetResource(this.DC_NS + "title") }, + get DC_LASTMODIFIED() { return this.rdf.GetResource(this.DC_NS + "lastModified") }, + get DC_IDENTIFIER() { return this.rdf.GetResource(this.DC_NS + "identifier") }, + + 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:", + get FZ_ROOT() { return this.rdf.GetResource(this.FZ_NS + "root") }, + get FZ_FEEDS() { return this.rdf.GetResource(this.FZ_NS + "feeds") }, + get FZ_FEED() { return this.rdf.GetResource(this.FZ_NS + "feed") }, + get FZ_QUICKMODE() { return this.rdf.GetResource(this.FZ_NS + "quickMode") }, + get FZ_DESTFOLDER() { return this.rdf.GetResource(this.FZ_NS + "destFolder") }, + get FZ_STORED() { return this.rdf.GetResource(this.FZ_NS + "stored") }, + get FZ_VALID() { return this.rdf.GetResource(this.FZ_NS + "valid") }, + get FZ_OPTIONS() { return this.rdf.GetResource(this.FZ_NS + "options"); }, + get FZ_LAST_SEEN_TIMESTAMP() { + return this.rdf.GetResource(this.FZ_NS + "last-seen-timestamp"); + }, + + get RDF_LITERAL_TRUE() { return this.rdf.GetLiteral("true") }, + get RDF_LITERAL_FALSE() { return this.rdf.GetLiteral("false") }, + + // 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, + + // The approximate amount of time, specified in milliseconds, to leave an + // item in the RDF cache after the item has dissappeared from feeds. + // The delay is currently one day. + INVALID_ITEM_PURGE_DELAY: 24 * 60 * 60 * 1000, + + kBiffMinutesDefault: 100, + 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", + +/** + * Get all rss account servers rootFolders. + * + * @return array of nsIMsgIncomingServer (empty array if none). + */ + getAllRssServerRootFolders: function() { + let rssRootFolders = []; + let allServers = MailServices.accounts.allServers; + for (let i = 0; i < allServers.length; i++) + { + let server = allServers.queryElementAt(i, Ci.nsIMsgIncomingServer); + 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. + * @return nsIMsgAccount. + */ + createRssAccount: function(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.findRealServer(userName, hostName, serverType, 0)) + // If "Feeds" exists, try "Feeds-2", then "Feeds-3", etc. + hostName = hostNamePref + "-" + i++; + + server = MailServices.accounts.createIncomingServer(userName, hostName, serverType); + server.biffMinutes = FeedUtils.kBiffMinutesDefault; + server.prettyName = aName ? aName : defaultName; + server.valid = true; + let account = MailServices.accounts.createAccount(); + account.incomingServer = 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); + + // Create "Local Folders" if none exist yet as it's guaranteed that + // those exist when any account exists. + let localFolders; + try { + localFolders = MailServices.accounts.localFoldersServer; + } + catch (ex) {} + + if (!localFolders) + MailServices.accounts.createLocalMailAccount(); + + // 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. + * @return boolean - true if exists else false. + */ + feedAlreadyExists: function(aUrl, aServer) { + let ds = this.getSubscriptionsDS(aServer); + let feeds = this.getSubscriptionsList(ds); + let resource = this.rdf.GetResource(aUrl); + if (feeds.IndexOf(resource) == -1) + return false; + + let folder = ds.GetTarget(resource, FeedUtils.FZ_DESTFOLDER, true) + .QueryInterface(Ci.nsIRDFResource).ValueUTF8; + 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 - folder + * @param nsIUrlListener aUrlListener - feed url + * @param bool aIsBiff - true if biff, false if manual get + * @param nsIDOMWindow aMsgWindow - window + */ + downloadFeed: function(aFolder, aUrlListener, aIsBiff, aMsgWindow) { + if (Services.io.offline) + 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 allFolders = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + if (!aFolder.isServer) { + // Add the base folder; it does not get returned by ListDescendants. Do not + // add the account folder as it doesn't have the feedUrl property or even + // a msgDatabase necessarily. + allFolders.appendElement(aFolder, false); + } + + aFolder.ListDescendants(allFolders); + + let folder; + function* feeder() { + let numFolders = allFolders.length; + for (let i = 0; i < numFolders; i++) { + folder = allFolders.queryElementAt(i, Ci.nsIMsgFolder); + FeedUtils.log.debug("downloadFeed: START x/# foldername:uri - " + + (i+1) + "/" + numFolders + " " + + folder.name + ":" + folder.URI); + + // Ensure folder's msgDatabase is openable for new message processing. + // If not, reparse. After the async reparse the folder will be ready + // for the next cycle; don't bother with a listener. Continue with + // the next folder, as attempting to add a message to a folder with + // an unavailable msgDatabase will throw later. + if (!FeedUtils.isMsgDatabaseOpenable(folder, true)) + continue; + + 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); + + FeedUtils.progressNotifier.init(aMsgWindow, false); + + // We need to kick off a download for each feed. + let id, feed; + for (let url of feedUrlArray) + { + id = FeedUtils.rdf.GetResource(url); + feed = new Feed(id, folder.server); + feed.folder = folder; + // Bump our pending feed download count. + FeedUtils.progressNotifier.mNumPendingFeedDownloads++; + feed.download(true, FeedUtils.progressNotifier); + FeedUtils.log.debug("downloadFeed: DOWNLOAD feed url - " + url); + + Services.tm.mainThread.dispatch(function() { + try { + let done = getFeed.next().done; + if (done) { + // Finished with all feeds in base folder 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({name: folder.name}, 0); + } + }, Ci.nsIThread.DISPATCH_NORMAL); + + yield undefined; + } + } + } + + let getFeed = 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({name: aFolder.name}, 0); + } + }, + +/** + * Subscribe a new feed url. + * + * @param string aUrl - feed url + * @param nsIMsgFolder aFolder - folder + * @param nsIDOMWindow aMsgWindow - window + */ + subscribeToFeed: function(aUrl, aFolder, aMsgWindow) { + // 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) + { + 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 (!aMsgWindow) + { + let wlist = Services.wm.getEnumerator("mail:3pane"); + if (wlist.hasMoreElements()) + { + let win = wlist.getNext().QueryInterface(Ci.nsIDOMWindow); + win.focus(); + aMsgWindow = win.msgWindow; + } + else + { + // If there are no open windows, open one, pass it the URL, and + // during opening it will subscribe to the feed. + let arg = Cc["@mozilla.org/supports-string;1"]. + createInstance(Ci.nsISupportsString); + arg.data = aUrl; + Services.ww.openWindow(null, "chrome://messenger/content/", + "_blank", "chrome,dialog=no,all", arg); + return; + } + } + + // 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, ""); + + // Make sure we aren't already subscribed to this feed before we attempt + // to subscribe to it. + if (FeedUtils.feedAlreadyExists(aUrl, aFolder.server)) + { + aMsgWindow.statusFeedback.showStatusString( + FeedUtils.strings.GetStringFromName("subscribe-feedAlreadySubscribed")); + return; + } + + let itemResource = FeedUtils.rdf.GetResource(aUrl); + let feed = new Feed(itemResource, aFolder.server); + feed.quickMode = feed.server.getBoolValue("quickMode"); + feed.options = FeedUtils.getOptionsAcct(feed.server); + + // If the root server, create a new folder for the feed. The user must + // want us to add this subscription url to an existing RSS folder. + if (!aFolder.isServer) + feed.folder = aFolder; + + FeedUtils.progressNotifier.init(aMsgWindow, true); + FeedUtils.progressNotifier.mNumPendingFeedDownloads++; + feed.download(true, FeedUtils.progressNotifier); + }, + +/** + * Add a feed record to the feeds.rdf database and update the folder's feedUrl + * property. + * + * @param object aFeed - our feed object + */ + addFeed: function(aFeed) { + let ds = this.getSubscriptionsDS(aFeed.folder.server); + let feeds = this.getSubscriptionsList(ds); + + // Generate a unique ID for the feed. + let id = aFeed.url; + let i = 1; + while (feeds.IndexOf(this.rdf.GetResource(id)) != -1 && ++i < 1000) + id = aFeed.url + i; + if (i == 1000) + throw new Error("FeedUtils.addFeed: couldn't generate a unique ID " + + "for feed " + aFeed.url); + + // Add the feed to the list. + id = this.rdf.GetResource(id); + feeds.AppendElement(id); + ds.Assert(id, this.RDF_TYPE, this.FZ_FEED, true); + ds.Assert(id, this.DC_IDENTIFIER, this.rdf.GetLiteral(aFeed.url), true); + if (aFeed.title) + ds.Assert(id, this.DC_TITLE, this.rdf.GetLiteral(aFeed.title), true); + ds.Assert(id, this.FZ_DESTFOLDER, aFeed.folder, true); + ds.Flush(); + + // Update folderpane. + this.setFolderPaneProperty(aFeed.folder, "favicon", null, "row"); + }, + +/** + * Delete a feed record from the feeds.rdf database and update the folder's + * feedUrl property. + * + * @param nsIRDFResource aId - feed url as rdf resource. + * @param nsIMsgIncomingServer aServer - folder's account server. + * @param nsIMsgFolder aParentFolder - owning folder. + */ + deleteFeed: function(aId, aServer, aParentFolder) { + let feed = new Feed(aId, aServer); + let ds = this.getSubscriptionsDS(aServer); + + if (!feed || !ds) + return; + + // Remove the feed from the subscriptions ds. + let feeds = this.getSubscriptionsList(ds); + let index = feeds.IndexOf(aId); + if (index != -1) + feeds.RemoveElementAt(index, false); + + // Remove all assertions about the feed from the subscriptions database. + this.removeAssertions(ds, aId); + ds.Flush(); + + // Remove all assertions about items in the feed from the items database. + let itemds = this.getItemsDS(aServer); + feed.invalidateItems(); + feed.removeInvalidItems(true); + itemds.Flush(); + + // Update folderpane. + this.setFolderPaneProperty(aParentFolder, "favicon", null, "row"); + }, + +/** + * Change an existing feed's url, as identified by FZ_FEED resource in the + * feeds.rdf subscriptions database. + * + * @param obj aFeed - the feed object + * @param string aNewUrl - new url + * @return bool - true if successful, else false + */ + changeUrlForFeed: function(aFeed, aNewUrl) { + if (!aFeed || !aFeed.folder || !aNewUrl) + return false; + + if (this.feedAlreadyExists(aNewUrl, aFeed.folder.server)) + { + this.log.info("FeedUtils.changeUrlForFeed: new feed url " + aNewUrl + + " already subscribed in account " + aFeed.folder.server.prettyName); + return false; + } + + let title = aFeed.title; + let link = aFeed.link; + let quickMode = aFeed.quickMode; + let options = aFeed.options; + + this.deleteFeed(this.rdf.GetResource(aFeed.url), + aFeed.folder.server, aFeed.folder); + aFeed.resource = this.rdf.GetResource(aNewUrl) + .QueryInterface(Ci.nsIRDFResource); + 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; + }, + +/** + * Get the list of feed urls for a folder, as identified by the FZ_DESTFOLDER + * tag, directly from the primary feeds.rdf subscriptions database. + * + * @param nsIMsgFolder - the folder. + * @return array of urls, or null if none. + */ + getFeedUrlsInFolder: function(aFolder) { + if (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 (nonexistant 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); + let enumerator = ds.GetSources(this.FZ_DESTFOLDER, aFolder, true); + while (enumerator.hasMoreElements()) + { + let containerArc = enumerator.getNext(); + let uri = containerArc.QueryInterface(Ci.nsIRDFResource).ValueUTF8; + feedUrlArray.push(uri); + } + } + catch(ex) + { + this.log.error("getFeedUrlsInFolder: feeds.rdf db error - " + ex); + this.log.error("getFeedUrlsInFolder: feeds.rdf 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 + * @return boolean - true if msgDb is available, else false + */ + isMsgDatabaseOpenable: function(aFolder, aReparse) { + 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(null, null); + } + catch (ex) {} + + return false; + }, + +/** + * Update a folderpane cached property. + * + * @param nsIMsgFolder aFolder - folder + * @param string aProperty - property + * @param string aValue - value + * @param string aInvalidate - "row" = folder's row. + * "all" = all rows. + */ + setFolderPaneProperty: function(aFolder, aProperty, aValue, aInvalidate) { + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if (!aFolder || !aProperty || !win || !("gFolderTreeView" in win)) + return; + + win.gFolderTreeView.setFolderCacheProperty(aFolder, aProperty, aValue); + + if (aInvalidate == "all") { + win.gFolderTreeView._tree.invalidate(); + } + if (aInvalidate == "row") { + let row = win.gFolderTreeView.getIndexOfFolder(aFolder); + win.gFolderTreeView._tree.invalidateRow(row); + } + }, + +/** + * 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 aFolder - the feed folder or null if aUrl + * @param string aUrl - a url (feed, message, other) or null if aFolder + * @param string aIconUrl - the icon url if already determined, else null + * @param nsIDOMWindow aWindow - null if requesting url without setting it + * @param function aCallback - null or callback + * @return string - the favicon url or empty string + */ + getFavicon: function(aFolder, aUrl, aIconUrl, aWindow, aCallback) { + // On any error, cache an empty string to show the default favicon, and + // don't try anymore in this session. + let useDefaultFavicon = (() => { + if (aCallback) + aCallback(""); + return ""; + }); + + if (!Services.prefs.getBoolPref("browser.chrome.site_icons") || + !Services.prefs.getBoolPref("browser.chrome.favicons")) + return useDefaultFavicon(); + + if (aIconUrl != null) + return aIconUrl; + + let onLoadSuccess = (aEvent => { + let iconUri = Services.io.newURI(aEvent.target.src, null, null); + aWindow.specialTabs.mFaviconService.setAndFetchFaviconForPage( + uri, iconUri, false, + aWindow.specialTabs.mFaviconService.FAVICON_LOAD_NON_PRIVATE, + null, Services.scriptSecurityManager.getSystemPrincipal()); + + if (aCallback) + aCallback(iconUri.spec); + }); + + let onLoadError = (aEvent => { + useDefaultFavicon(); + let url = aEvent.target.src; + aWindow.specialTabs.getFaviconFromPage(url, aCallback); + }); + + let url = aUrl; + if (!url) + { + // Get the proposed iconUrl from the folder's first subscribed feed's + // <link>. + if (!aFolder) + return useDefaultFavicon(); + + let feedUrls = this.getFeedUrlsInFolder(aFolder); + url = feedUrls ? feedUrls[0] : null; + if (!url) + return useDefaultFavicon(); + } + + if (aFolder) + { + let ds = this.getSubscriptionsDS(aFolder.server); + let resource = this.rdf.GetResource(url).QueryInterface(Ci.nsIRDFResource); + let feedLinkUrl = ds.GetTarget(resource, this.RSS_LINK, true); + feedLinkUrl = feedLinkUrl ? + feedLinkUrl.QueryInterface(Ci.nsIRDFLiteral).Value : null; + url = feedLinkUrl && feedLinkUrl.startsWith("http") ? feedLinkUrl : url; + } + + let uri, iconUri; + try { + uri = Services.io.newURI(url, null, null); + iconUri = Services.io.newURI(uri.prePath + "/favicon.ico", null, null); + } + catch (ex) { + return useDefaultFavicon(); + } + + if (!aWindow) + return iconUri.spec; + + aWindow.specialTabs.loadFaviconImageNode(onLoadSuccess, onLoadError, + iconUri.spec); + // Cache the favicon url initially. + if (aCallback) + aCallback(iconUri.spec); + + return iconUri.spec; + }, + +/** + * Update the feeds.rdf 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" + */ + updateSubscriptionsDS: function(aFolder, aOrigFolder, aAction) { + this.log.debug("FeedUtils.updateSubscriptionsDS: " + + "\nfolder changed - " + aAction + + "\nnew folder - " + aFolder.filePath.path + + "\norig folder - " + aOrigFolder.filePath.path); + + 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. + let newSubFolders = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + newFolder.ListDescendants(newSubFolders); + for (let i = 0; i < newSubFolders.length; i++) + { + let newSubFolder = newSubFolders.queryElementAt(i, Ci.nsIMsgFolder); + FeedUtils.updateFolderChangeInFeedsDS(newSubFolder, aOrigFolder, + newParentURI, origParentURI) + } + }, + +/** + * Update the feeds.rdf database with the new folder's or subfolder's location + * for rename and move/copy name changes. The feeds.rdf 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 + */ + updateFolderChangeInFeedsDS: function(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; + let origFolderRes = this.rdf.GetResource(origURI); + this.log.debug("updateFolderChangeInFeedsDS: urls origURI - " + origURI); + // Get the original folder's url list from the feeds database. + let feedUrlArray = []; + let dsSrc = this.getSubscriptionsDS(aOrigFolder.server); + try { + let enumerator = dsSrc.GetSources(this.FZ_DESTFOLDER, origFolderRes, true); + while (enumerator.hasMoreElements()) + { + let containerArc = enumerator.getNext(); + let uri = containerArc.QueryInterface(Ci.nsIRDFResource).ValueUTF8; + feedUrlArray.push(uri); + } + } + catch(ex) + { + this.log.error("updateFolderChangeInFeedsDS: feeds.rdf db error for account - " + + aOrigFolder.server.prettyName + " : " + ex); + } + + if (!feedUrlArray.length) + { + this.log.debug("updateFolderChangeInFeedsDS: no feedUrls in this folder"); + return; + } + + let id, resource, node; + let ds = this.getSubscriptionsDS(aFolder.server); + for (let feedUrl of feedUrlArray) + { + this.log.debug("updateFolderChangeInFeedsDS: feedUrl - " + feedUrl); + + id = this.rdf.GetResource(feedUrl); + // If move to trash, unsubscribe. + if (this.isInTrash(aFolder)) + { + this.deleteFeed(id, aFolder.server, aFolder); + } + else + { + resource = this.rdf.GetResource(aFolder.URI); + // Get the node for the current folder URI. + node = ds.GetTarget(id, this.FZ_DESTFOLDER, true); + if (node) + { + ds.Change(id, this.FZ_DESTFOLDER, node, resource); + } + else + { + // If adding a new feed it's a cross account action; make sure to + // preserve all properties from the original datasource where + // available. Otherwise use the new folder's name and default server + // quickMode; preserve link and options. + let feedTitle = dsSrc.GetTarget(id, this.DC_TITLE, true); + feedTitle = feedTitle ? feedTitle.QueryInterface(Ci.nsIRDFLiteral).Value : + resource.name; + let link = dsSrc.GetTarget(id, FeedUtils.RSS_LINK, true); + link = link ? link.QueryInterface(Ci.nsIRDFLiteral).Value : ""; + let quickMode = dsSrc.GetTarget(id, this.FZ_QUICKMODE, true); + quickMode = quickMode ? quickMode.QueryInterface(Ci.nsIRDFLiteral).Value : + null; + quickMode = quickMode == "true" ? true : + quickMode == "false" ? false : + aFeed.folder.server.getBoolValue("quickMode"); + let options = dsSrc.GetTarget(id, this.FZ_OPTIONS, true); + options = options ? JSON.parse(options.QueryInterface(Ci.nsIRDFLiteral).Value) : + this.optionsTemplate; + + let feed = new Feed(id, aFolder.server); + feed.folder = aFolder; + feed.title = feedTitle; + feed.link = link; + feed.quickMode = quickMode; + feed.options = options; + this.addFeed(feed); + } + } + } + + ds.Flush(); + }, + +/** + * 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 bool aUnique - if true, return a unique indexed name. + * @return string - sanitized unique name + */ + getSanitizedFolderName: function(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. + let folderName = aProposedName.replace(/[\n\r\t]+/g, " ") + .replace(/[\x00-\x1F]+/g, "") + .replace(/[*|\\\/:<>?"]+/g, "") + .replace(/[\.]+/g, "") + .trim(); + + // 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 will contain all feed specific properties. + */ + _optionsDefault: { + version: 1, + // 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: function(aServer) + { + let optionsAcctPref = "mail.server." + aServer.key + ".feed_options"; + try { + return JSON.parse(Services.prefs.getCharPref(optionsAcctPref)); + } + catch (ex) { + this.setOptionsAcct(aServer, this._optionsDefault); + return JSON.parse(Services.prefs.getCharPref(optionsAcctPref)); + } + }, + + setOptionsAcct: function(aServer, aOptions) + { + let optionsAcctPref = "mail.server." + aServer.key + ".feed_options"; + let newOptions = this.newOptions(aOptions); + Services.prefs.setCharPref(optionsAcctPref, JSON.stringify(newOptions)); + }, + + newOptions: function(aOptions) + { + // TODO: Clean options, so that only keys in the active template are stored. + return aOptions; + }, + + getSubscriptionsDS: function(aServer) { + if (this[aServer.serverURI] && this[aServer.serverURI]["FeedsDS"]) + return this[aServer.serverURI]["FeedsDS"]; + + let file = this.getSubscriptionsFile(aServer); + let url = Services.io.getProtocolHandler("file"). + QueryInterface(Ci.nsIFileProtocolHandler). + getURLSpecFromFile(file); + + // GetDataSourceBlocking has a cache, so it's cheap to do this again + // once we've already done it once. + let ds = this.rdf.GetDataSourceBlocking(url); + + if (!ds) + throw new Error("FeedUtils.getSubscriptionsDS: can't get feed " + + "subscriptions data source - " + url); + + if (!this[aServer.serverURI]) + this[aServer.serverURI] = {}; + return this[aServer.serverURI]["FeedsDS"] = + ds.QueryInterface(Ci.nsIRDFRemoteDataSource); + }, + + getSubscriptionsList: function(aDataSource) { + let list = aDataSource.GetTarget(this.FZ_ROOT, this.FZ_FEEDS, true); + list = list.QueryInterface(Ci.nsIRDFResource); + list = this.rdfContainerUtils.MakeSeq(aDataSource, list); + return list; + }, + + getSubscriptionsFile: function(aServer) { + aServer.QueryInterface(Ci.nsIRssIncomingServer); + let file = aServer.subscriptionsDataSourcePath; + + // If the file doesn't exist, create it. + if (!file.exists()) + this.createFile(file, this.FEEDS_TEMPLATE); + + return file; + }, + + FEEDS_TEMPLATE: '<?xml version="1.0"?>\n' + + '<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"\n' + + ' xmlns:fz="urn:forumzilla:"\n' + + ' xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n' + + ' <RDF:Description about="urn:forumzilla:root">\n' + + ' <fz:feeds>\n' + + ' <RDF:Seq>\n' + + ' </RDF:Seq>\n' + + ' </fz:feeds>\n' + + ' </RDF:Description>\n' + + '</RDF:RDF>\n', + + getItemsDS: function(aServer) { + if (this[aServer.serverURI] && this[aServer.serverURI]["FeedItemsDS"]) + return this[aServer.serverURI]["FeedItemsDS"]; + + let file = this.getItemsFile(aServer); + let url = Services.io.getProtocolHandler("file"). + QueryInterface(Ci.nsIFileProtocolHandler). + getURLSpecFromFile(file); + + // GetDataSourceBlocking has a cache, so it's cheap to do this again + // once we've already done it once. + let ds = this.rdf.GetDataSourceBlocking(url); + if (!ds) + throw new Error("FeedUtils.getItemsDS: can't get feed items " + + "data source - " + url); + + // Note that it this point the datasource may not be loaded yet. + // You have to QueryInterface it to nsIRDFRemoteDataSource and check + // its "loaded" property to be sure. You can also attach an observer + // which will get notified when the load is complete. + if (!this[aServer.serverURI]) + this[aServer.serverURI] = {}; + return this[aServer.serverURI]["FeedItemsDS"] = + ds.QueryInterface(Ci.nsIRDFRemoteDataSource); + }, + + getItemsFile: function(aServer) { + aServer.QueryInterface(Ci.nsIRssIncomingServer); + let file = aServer.feedItemsDataSourcePath; + + // If the file doesn't exist, create it. + if (!file.exists()) { + this.createFile(file, this.FEEDITEMS_TEMPLATE); + return file; + } + + // If feeditems.rdf is not sane, duplicate messages will occur repeatedly + // until the file is corrected; check that the file is valid XML. This is + // done lazily only once in a session. + let fileUrl = Services.io.getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .getURLSpecFromFile(file); + let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Ci.nsIXMLHttpRequest); + request.open("GET", fileUrl, false); + request.responseType = "document"; + request.send(); + let dom = request.responseXML; + if (dom instanceof Ci.nsIDOMXMLDocument && + dom.documentElement.namespaceURI != this.MOZ_PARSERERROR_NS) + return file; + + // Error on the file. Rename it and create a new one. + this.log.debug("FeedUtils.getItemsFile: error in feeditems.rdf"); + let errName = "feeditems_error_" + + (new Date().toISOString()).replace(/\D/g, "") + ".rdf"; + file.moveTo(file.parent, errName); + file = aServer.feedItemsDataSourcePath; + this.createFile(file, this.FEEDITEMS_TEMPLATE); + this.log.error("FeedUtils.getItemsFile: error in feeditems.rdf in account '" + + aServer.prettyName + "'; the file has been moved to " + + errName + " and a new file has been created. Recent messages " + + "may be duplicated."); + return file; + }, + + FEEDITEMS_TEMPLATE: '<?xml version="1.0"?>\n' + + '<RDF:RDF xmlns:dc="http://purl.org/dc/elements/1.1/"\n' + + ' xmlns:fz="urn:forumzilla:"\n' + + ' xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n' + + '</RDF:RDF>\n', + + createFile: function(aFile, aTemplate) { + let fos = FileUtils.openSafeFileOutputStream(aFile); + fos.write(aTemplate, aTemplate.length); + FileUtils.closeSafeFileOutputStream(fos); + }, + + getParentTargetForChildResource: function(aChildResource, aParentTarget, + aServer) { + // Generic get feed property, based on child value. Assumes 1 unique + // child value with 1 unique parent, valid for feeds.rdf structure. + let ds = this.getSubscriptionsDS(aServer); + let childRes = this.rdf.GetResource(aChildResource); + let parent = null; + + let arcsIn = ds.ArcLabelsIn(childRes); + while (arcsIn.hasMoreElements()) + { + let arc = arcsIn.getNext(); + if (arc instanceof Ci.nsIRDFResource) + { + parent = ds.GetSource(arc, childRes, true); + parent = parent.QueryInterface(Ci.nsIRDFResource); + break; + } + } + + if (parent) + { + let resource = this.rdf.GetResource(parent.Value); + return ds.GetTarget(resource, aParentTarget, true); + } + + return null; + }, + + removeAssertions: function(aDataSource, aResource) { + let properties = aDataSource.ArcLabelsOut(aResource); + let property; + while (properties.hasMoreElements()) + { + property = properties.getNext(); + let values = aDataSource.GetTargets(aResource, property, true); + let value; + while (values.hasMoreElements()) + { + value = values.getNext(); + aDataSource.Unassert(aResource, property, value, true); + } + } + }, + +/** + * 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 nsIDOMDataTransfer aDataTransfer - the dnd event's dataTransfer. + * @return nsIURI uri - a uri if valid, null if none. + */ + getFeedUriFromDataTransfer: function(aDataTransfer) { + let dt = aDataTransfer; + let types = ["text/x-moz-url-data", "text/x-moz-url"]; + let validUri = false; + let uri = Cc["@mozilla.org/network/standard-url;1"]. + createInstance(Ci.nsIURI); + + if (dt.getData(types[0])) + { + // The url is the data. + uri.spec = 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.spec = 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.spec = 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. + * @return array [string errType, string errName] (null if not determined). + */ + createTCPErrorFromFailedXHR: function(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. + * @return boolean - true if a valid scheme, false if not. + */ + _validSchemes: ["http", "https"], + isValidScheme: function(aUri) { + if (!(aUri instanceof Ci.nsIURI)) { + try { + aUri = Services.io.newURI(aUri, null, null); + } + catch (ex) { + return false; + } + } + + return (this._validSchemes.indexOf(aUri.scheme) != -1); + }, + +/** + * Is a folder Trash or in Trash. + * + * @param nsIMsgFolder aFolder - the folder. + * @return boolean - true if folder is Trash else false. + */ + isInTrash: function(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. + * @return string prettyName | null - name or null if not a disk folder. + */ + getFolderPrettyPath: function(aFolder) { + let msgFolder = MailUtils.getFolderForURI(aFolder.URI, true); + 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 = MailUtils.getFolderForURI(folderURI, true); + pathParts.push(msgFolder.name); + } + + // Leaf folder last. + pathParts.push(aFolder.name); + return pathParts.join("/"); + }, + +/** + * Date validator for feeds. + * + * @param string aDate - date string + * @return boolean - true if passes regex test, false if not + */ + isValidRFC822Date: function(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. + * @return string - an rfc5322 date string + */ + getValidRFC5322Date: function(aDateString) + { + let d = new Date(aDateString || new Date().getTime()); + d = isNaN(d.getTime()) ? new Date() : d; + return 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, + + init: function(aMsgWindow, aSubscribeMode) + { + if (!this.mNumPendingFeedDownloads) + { + // 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")); + } + } + }, + + downloaded: function(feed, aErrorCode) + { + let location = feed.folder ? feed.folder.filePath.path : ""; + FeedUtils.log.debug("downloaded: "+ + (this.mSubscribeMode ? "Subscribe " : "Update ") + + "errorCode:feedName:folder - " + + aErrorCode + " : " + feed.name + " : " + location); + if (this.mSubscribeMode) + { + if (aErrorCode == FeedUtils.kNewsBlogSuccess) + { + // Add the feed to the databases. + FeedUtils.addFeed(feed); + + // Nice touch: 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. + this.mMsgWindow.windowCommands.selectFolder(feed.folder.URI); + + // 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 + { + // Non success. Remove intermediate traces from the feeds database. + if (feed && feed.url && feed.server) + FeedUtils.deleteFeed(FeedUtils.rdf.GetResource(feed.url), + feed.server, + feed.server.rootFolder); + } + } + + if (feed.folder && aErrorCode != FeedUtils.kNewsBlogFeedIsBusy) + // Free msgDatabase after new mail biff is set; if busy let the next + // result do the freeing. Otherwise new messages won't be indicated. + feed.folder.msgDatabase = null; + + let message = ""; + if (feed.folder) + location = FeedUtils.getFolderPrettyPath(feed.folder) + " -> "; + 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], 1); + break; + case FeedUtils.kNewsBlogRequestFailure: + message = FeedUtils.strings.formatStringFromName( + "newsblog-networkError", [feed.url], 1); + break; + case FeedUtils.kNewsBlogFileError: + message = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile"); + break; + case FeedUtils.kNewsBlogBadCertError: + let host = Services.io.newURI(feed.url, null, null).host; + message = FeedUtils.strings.formatStringFromName( + "newsblog-badCertError", [host], 1); + break; + case FeedUtils.kNewsBlogNoAuthError: + message = FeedUtils.strings.formatStringFromName( + "newsblog-noAuthError", [feed.url], 1); + break; + } + if (message) + FeedUtils.log.info("downloaded: " + + (this.mSubscribeMode ? "Subscribe: " : "Update: ") + + location + message); + + if (this.mStatusFeedback) + { + this.mStatusFeedback.showStatusString(message); + this.mStatusFeedback.stopMeteors(); + } + + if (!--this.mNumPendingFeedDownloads) + { + FeedUtils.getSubscriptionsDS(feed.server).Flush(); + 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 doesnt 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 + onFeedItemStored: function (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], 2)); + this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems); + } + }, + + onProgress: function(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: function() + { + 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 Log4Moz.getConfiguredLogger("Feeds"); +}); + +XPCOMUtils.defineLazyGetter(FeedUtils, "strings", function() { + return Services.strings.createBundle( + "chrome://messenger-newsblog/locale/newsblog.properties"); +}); + +XPCOMUtils.defineLazyGetter(FeedUtils, "rdf", function() { + return Cc["@mozilla.org/rdf/rdf-service;1"]. + getService(Ci.nsIRDFService); +}); + +XPCOMUtils.defineLazyGetter(FeedUtils, "rdfContainerUtils", function() { + return Cc["@mozilla.org/rdf/container-utils;1"]. + getService(Ci.nsIRDFContainerUtils); +}); diff --git a/mailnews/extensions/newsblog/content/am-newsblog.js b/mailnews/extensions/newsblog/content/am-newsblog.js new file mode 100644 index 000000000..674280f81 --- /dev/null +++ b/mailnews/extensions/newsblog/content/am-newsblog.js @@ -0,0 +1,63 @@ +/* -*- 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/. */ + +Components.utils.import("resource:///modules/FeedUtils.jsm"); + +var gServer, autotagEnable, autotagUsePrefix, autotagPrefix; + +function onInit(aPageId, aServerId) +{ + var accountName = document.getElementById("server.prettyName"); + var title = document.getElementById("am-newsblog-title"); + var defaultTitle = title.getAttribute("defaultTitle"); + + var titleValue; + if (accountName.value) + titleValue = defaultTitle + " - <" + accountName.value + ">"; + else + titleValue = defaultTitle; + + title.setAttribute("title", titleValue); + document.title = titleValue; + + onCheckItem("server.biffMinutes", ["server.doBiff"]); + + autotagEnable = document.getElementById("autotagEnable"); + autotagUsePrefix = document.getElementById("autotagUsePrefix"); + autotagPrefix = document.getElementById("autotagPrefix"); + + let categoryPrefsAcct = FeedUtils.getOptionsAcct(gServer).category; + autotagEnable.checked = categoryPrefsAcct.enabled; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagUsePrefix.checked = categoryPrefsAcct.prefixEnabled; + autotagPrefix.disabled = autotagUsePrefix.disabled || !autotagUsePrefix.checked; + autotagPrefix.value = categoryPrefsAcct.prefix; +} + +function onPreInit(account, accountValues) +{ + gServer = account.incomingServer; +} + +function setCategoryPrefs(aNode) +{ + let options = FeedUtils.getOptionsAcct(gServer); + switch (aNode.id) { + case "autotagEnable": + options.category.enabled = aNode.checked; + autotagUsePrefix.disabled = !aNode.checked; + autotagPrefix.disabled = !aNode.checked || !autotagUsePrefix.checked; + break; + case "autotagUsePrefix": + options.category.prefixEnabled = aNode.checked; + autotagPrefix.disabled = aNode.disabled || !aNode.checked; + break; + case "autotagPrefix": + options.category.prefix = aNode.value; + break; + } + + FeedUtils.setOptionsAcct(gServer, options) +} diff --git a/mailnews/extensions/newsblog/content/am-newsblog.xul b/mailnews/extensions/newsblog/content/am-newsblog.xul new file mode 100644 index 000000000..19173d803 --- /dev/null +++ b/mailnews/extensions/newsblog/content/am-newsblog.xul @@ -0,0 +1,155 @@ +<?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 page [ +<!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; +]> + +<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="color-dialog" + title="&accountTitle.label;" + onload="parent.onPanelLoaded('am-newsblog.xul');"> + + <script type="application/javascript" + src="chrome://messenger/content/AccountManager.js"/> + <script type="application/javascript" + src="chrome://messenger-newsblog/content/am-newsblog.js"/> + <script type="application/javascript" + src="chrome://messenger-newsblog/content/newsblogOverlay.js"/> + <script type="application/javascript" + src="chrome://messenger/content/amUtils.js"/> + <script type="application/javascript" + src="chrome://messenger/content/am-prefs.js"/> + + <vbox flex="1" style="overflow: auto;"> + + <dialogheader id="am-newsblog-title" defaultTitle="&accountTitle.label;"/> + + <description class="secDesc">&accountSettingsDesc.label;</description> + + <hbox align="center"> + <label value="&accountName.label;" + accesskey="&accountName.accesskey;" + control="server.prettyName"/> + <textbox id="server.prettyName" + wsm_persist="true" + size="30" + prefstring="mail.server.%serverkey%.name"/> + </hbox> + + <separator class="thin"/> + + <groupbox> + <caption label="&serverSettings.label;"/> + + <checkbox id="server.loginAtStartUp" + wsm_persist="true" + label="&loginAtStartup.label;" + accesskey="&loginAtStartup.accesskey;" + prefattribute="value" + prefstring="mail.server.%serverkey%.login_at_startup"/> + + <hbox align="center"> + <checkbox id="server.doBiff" + wsm_persist="true" + label="&biffStart.label;" + accesskey="&biffStart.accesskey;" + oncommand="onCheckItem('server.biffMinutes', [this.id]);" + prefattribute="value" + prefstring="mail.server.%serverkey%.check_new_mail"/> + <textbox id="server.biffMinutes" + wsm_persist="true" + type="number" + size="3" + min="1" + increment="1" + preftype="int" + prefstring="mail.server.%serverkey%.check_time" + aria-labelledby="server.doBiff server.biffMinutes biffEnd"/> + <label id="biffEnd" + value="&biffEnd.label;" + control="server.biffMinutes"/> + </hbox> + + <checkbox id="server.quickMode" + wsm_persist="true" + genericattr="true" + label="&useQuickMode.label;" + accesskey="&useQuickMode.accesskey;" + preftype="bool" + prefattribute="value" + prefstring="mail.server.%serverkey%.quickMode"/> + + <checkbox id="autotagEnable" + accesskey="&autotagEnable.accesskey;" + label="&autotagEnable.label;" + oncommand="setCategoryPrefs(this)"/> + <hbox> + <checkbox id="autotagUsePrefix" + class="indent" + accesskey="&autotagUsePrefix.accesskey;" + label="&autotagUsePrefix.label;" + oncommand="setCategoryPrefs(this)"/> + <textbox id="autotagPrefix" + placeholder="&autoTagPrefix.placeholder;" + clickSelectsAll="true" + onchange="setCategoryPrefs(this)"/> + </hbox> + </groupbox> + + <separator class="thin"/> + + <groupbox> + <caption label="&messageStorage.label;"/> + + <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> + <label value="&localPath.label;" control="server.localPath"/> + <hbox align="center"> + <textbox readonly="true" + wsm_persist="true" + flex="1" + id="server.localPath" + datatype="nsIFile" + prefstring="mail.server.%serverkey%.directory" + class="uri-element"/> + <button id="browseForLocalFolder" + label="&browseFolder.label;" + filepickertitle="&localFolderPicker.label;" + accesskey="&browseFolder.accesskey;" + oncommand="BrowseForLocalFolders();"/> + </hbox> + </vbox> + + </groupbox> + + <separator class="thin"/> + + <hbox align="center"> + <spacer flex="1"/> + <button label="&manageSubscriptions.label;" + accesskey="&manageSubscriptions.accesskey;" + oncommand="openSubscriptionsDialog(gServer.rootFolder);"/> + </hbox> + </vbox> +</page> diff --git a/mailnews/extensions/newsblog/content/feed-parser.js b/mailnews/extensions/newsblog/content/feed-parser.js new file mode 100644 index 000000000..660333422 --- /dev/null +++ b/mailnews/extensions/newsblog/content/feed-parser.js @@ -0,0 +1,1034 @@ +/* -*- 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/. */ + +// The feed parser depends on FeedItem.js, Feed.js. +function FeedParser() { + this.mSerializer = Cc["@mozilla.org/xmlextras/xmlserializer;1"]. + createInstance(Ci.nsIDOMSerializer); +} + +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. + parseFeed: function (aFeed, aDOM) + { + if (!(aDOM instanceof Ci.nsIDOMXMLDocument)) + { + // No xml doc. + return aFeed.onParseError(aFeed); + } + + let doc = aDOM.documentElement; + if (doc.namespaceURI == FeedUtils.MOZ_PARSERERROR_NS) + { + // Gecko caught a basic parsing error. + let errStr = doc.firstChild.textContent + "\n" + + doc.firstElementChild.textContent; + FeedUtils.log.info("FeedParser.parseFeed: - " + errStr); + return aFeed.onParseError(aFeed); + } + else if (aDOM.querySelector("redirect")) + { + // Check for RSS2.0 redirect document. + let channel = aDOM.querySelector("redirect"); + if (this.isPermanentRedirect(aFeed, channel, null, null)) + return; + + return aFeed.onParseError(aFeed); + } + else if (doc.namespaceURI == FeedUtils.RDF_SYNTAX_NS && + doc.getElementsByTagNameNS(FeedUtils.RSS_NS, "channel")[0]) + { + aFeed.mFeedType = "RSS_1.xRDF" + FeedUtils.log.debug("FeedParser.parseFeed: type:url - " + + aFeed.mFeedType +" : " +aFeed.url); + // aSource can be misencoded (XMLHttpRequest converts to UTF-8 by default), + // but the DOM is almost always right because it uses the hints in the + // XML file. This is slower, but not noticably so. Mozilla doesn't have + // the XMLHttpRequest.responseBody property that IE has, which provides + // access to the unencoded response. + let xmlString = this.mSerializer.serializeToString(doc); + return this.parseAsRSS1(aFeed, xmlString, aFeed.request.channel.URI); + } + else if (doc.namespaceURI == FeedUtils.ATOM_03_NS) + { + aFeed.mFeedType = "ATOM_0.3" + FeedUtils.log.debug("FeedParser.parseFeed: type:url - " + + aFeed.mFeedType +" : " +aFeed.url); + return this.parseAsAtom(aFeed, aDOM); + } + else if (doc.namespaceURI == FeedUtils.ATOM_IETF_NS) + { + aFeed.mFeedType = "ATOM_IETF" + FeedUtils.log.debug("FeedParser.parseFeed: type:url - " + + aFeed.mFeedType +" : " +aFeed.url); + return this.parseAsAtomIETF(aFeed, aDOM); + } + else if (doc.getElementsByTagNameNS(FeedUtils.RSS_090_NS, "channel")[0]) + { + aFeed.mFeedType = "RSS_0.90" + FeedUtils.log.debug("FeedParser.parseFeed: type:url - " + + aFeed.mFeedType +" : " +aFeed.url); + return this.parseAsRSS2(aFeed, aDOM); + } + else + { + // 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?"; + FeedUtils.log.debug("FeedParser.parseFeed: type:url - " + + aFeed.mFeedType +" : " +aFeed.url); + return this.parseAsRSS2(aFeed, aDOM); + } + }, + + parseAsRSS2: function (aFeed, aDOM) + { + // Get the first channel (assuming there is only one per RSS File). + let parsedItems = new Array(); + + let channel = aDOM.querySelector("channel"); + if (!channel) + return aFeed.onParseError(aFeed); + + // Usually the empty string, unless this is RSS .90. + let nsURI = channel.namespaceURI || ""; + FeedUtils.log.debug("FeedParser.parseAsRSS2: channel nsURI - " + nsURI); + + if (this.isPermanentRedirect(aFeed, null, channel, null)) + 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) || !aFeed.link) + { + FeedUtils.log.error("FeedParser.parseAsRSS2: missing mandatory element " + + "<title> and <description>, or <link>"); + return aFeed.onParseError(aFeed); + } + + if (!aFeed.parseItems) + return parsedItems; + + 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 : []; + FeedUtils.log.debug("FeedParser.parseAsRSS2: items to parse - " + + itemNodes.length); + + for (let itemNode of itemNodes) + { + if (!itemNode.childElementCount) + continue; + let item = new FeedItem(); + item.feed = aFeed; + item.enclosures = []; + item.keywords = []; + + tags = this.childrenByTagNameNS(itemNode, 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, null, null); + if (Services.io.extractScheme(guid) == "tag") + isPermaLink = false; + } + catch (ex) + { + isPermaLink = false; + } + } + + item.id = guid; + } + + let guidLink = this.validLink(guid); + item.url = isPermaLink && guidLink ? guidLink : link ? link : 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)) + { + 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, FeedUtils.DC_NS, "creator"); + item.author = this.getNodeValue(tags ? tags[0] : null) || + aFeed.title || + item.author; + + tags = this.childrenByTagNameNS(itemNode, nsURI, "pubDate"); + if (!tags || !this.getNodeValue(tags[0])) + tags = this.childrenByTagNameNS(itemNode, 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 (!FeedUtils.isValidRFC822Date(item.date)) + { + // XXX Use this on the other formats as well. + item.date = this.dateRescue(item.date); + } + } + + tags = this.childrenByTagNameNS(itemNode, 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.indexOf(url) == -1) + { + let type = this.removeUnprintableASCII(tag.getAttribute("type")); + let length = this.removeUnprintableASCII(tag.getAttribute("length")); + item.enclosures.push(new FeedEnclosure(url, type, length)); + encUrls.push(url); + } + } + + tags = itemNode.getElementsByTagNameNS(FeedUtils.MRSS_NS, "content"); + if (tags) + for (let tag of tags) + { + let url = this.validLink(tag.getAttribute("url")); + if (url && encUrls.indexOf(url) == -1) + { + let type = this.removeUnprintableASCII(tag.getAttribute("type")); + let fileSize = this.removeUnprintableASCII(tag.getAttribute("fileSize")); + item.enclosures.push(new 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, 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 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.indexOf(term) == -1) + item.keywords.push(term); + } + } + + parsedItems.push(item); + } + + return parsedItems; + }, + + parseAsRSS1 : function(aFeed, aSource, aBaseURI) + { + let parsedItems = new Array(); + + // RSS 1.0 is valid RDF, so use the RDF parser/service to extract data. + // Create a new RDF data source and parse the feed into it. + let ds = Cc["@mozilla.org/rdf/datasource;1?name=in-memory-datasource"]. + createInstance(Ci.nsIRDFDataSource); + + let rdfparser = Cc["@mozilla.org/rdf/xml-parser;1"]. + createInstance(Ci.nsIRDFXMLParser); + rdfparser.parseString(ds, aBaseURI, aSource); + + // Get information about the feed as a whole. + let channel = ds.GetSource(FeedUtils.RDF_TYPE, FeedUtils.RSS_CHANNEL, true); + if (!channel) + return aFeed.onParseError(aFeed); + + if (this.isPermanentRedirect(aFeed, null, channel, ds)) + return; + + aFeed.title = aFeed.title || + this.getRDFTargetValue(ds, channel, FeedUtils.RSS_TITLE) || + aFeed.url; + aFeed.description = this.getRDFTargetValueFormatted(ds, channel, FeedUtils.RSS_DESCRIPTION) || + ""; + aFeed.link = this.validLink(this.getRDFTargetValue(ds, channel, FeedUtils.RSS_LINK)) || + aFeed.url; + + if (!(aFeed.title || aFeed.description) || !aFeed.link) + { + FeedUtils.log.error("FeedParser.parseAsRSS1: missing mandatory element " + + "<title> and <description>, or <link>"); + return aFeed.onParseError(aFeed); + } + + if (!aFeed.parseItems) + return parsedItems; + + aFeed.invalidateItems(); + + // Ignore the <items> list and just get the <item>s. + let items = ds.GetSources(FeedUtils.RDF_TYPE, FeedUtils.RSS_ITEM, true); + + let index = 0; + while (items.hasMoreElements()) + { + let itemResource = items.getNext().QueryInterface(Ci.nsIRDFResource); + let item = new FeedItem(); + item.feed = aFeed; + + // Prefer the value of the link tag to the item URI since the URI could be + // a relative URN. + let uri = itemResource.ValueUTF8; + let link = this.validLink(this.getRDFTargetValue(ds, itemResource, FeedUtils.RSS_LINK)); + item.url = link || uri; + item.description = this.getRDFTargetValueFormatted(ds, itemResource, + FeedUtils.RSS_DESCRIPTION); + item.title = this.getRDFTargetValue(ds, itemResource, FeedUtils.RSS_TITLE) || + this.getRDFTargetValue(ds, itemResource, FeedUtils.DC_SUBJECT) || + (item.description ? + (this.stripTags(item.description).substr(0, 150)) : null); + if (!item.url || !item.title) + { + FeedUtils.log.info("FeedParser.parseAsRSS1: <item> missing mandatory " + + "element <item rdf:about> and <link>, or <title> and " + + "no <description>; skipping"); + continue; + } + + item.id = item.url; + item.url = this.validLink(item.url); + + item.author = this.getRDFTargetValue(ds, itemResource, FeedUtils.DC_CREATOR) || + this.getRDFTargetValue(ds, channel, FeedUtils.DC_CREATOR) || + aFeed.title || + item.author; + item.date = this.getRDFTargetValue(ds, itemResource, FeedUtils.DC_DATE) || + item.date; + item.content = this.getRDFTargetValueFormatted(ds, itemResource, + FeedUtils.RSS_CONTENT_ENCODED); + + parsedItems[index++] = item; + } + FeedUtils.log.debug("FeedParser.parseAsRSS1: items parsed - " + index); + + return parsedItems; + }, + + parseAsAtom: function(aFeed, aDOM) + { + let parsedItems = new Array(); + + // Get the first channel (assuming there is only one per Atom File). + let channel = aDOM.querySelector("feed"); + if (!channel) + return aFeed.onParseError(aFeed); + + if (this.isPermanentRedirect(aFeed, null, channel, null)) + return; + + let tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_03_NS, "title"); + aFeed.title = aFeed.title || + this.stripTags(this.getNodeValue(tags ? tags[0] : null)); + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_03_NS, "tagline"); + aFeed.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_03_NS, "link"); + aFeed.link = this.validLink(this.findAtomLink("alternate", tags)); + + if (!aFeed.title) + { + FeedUtils.log.error("FeedParser.parseAsAtom: missing mandatory element " + + "<title>"); + return aFeed.onParseError(aFeed); + } + + if (!aFeed.parseItems) + return parsedItems; + + aFeed.invalidateItems(); + let items = this.childrenByTagNameNS(channel, FeedUtils.ATOM_03_NS, "entry"); + items = items ? items : []; + FeedUtils.log.debug("FeedParser.parseAsAtom: items to parse - " + + items.length); + + for (let itemNode of items) + { + if (!itemNode.childElementCount) + continue; + let item = new FeedItem(); + item.feed = aFeed; + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "link"); + item.url = this.validLink(this.findAtomLink("alternate", tags)); + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "id"); + item.id = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "summary"); + item.description = this.getNodeValueFormatted(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(itemNode, 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. + FeedUtils.log.info("FeedParser.parseAsAtom: <entry> missing mandatory " + + "element <id>, or <title> and no <summary>; skipping"); + continue; + } + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "author"); + if (!tags) + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "contributor"); + if (!tags) + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_03_NS, "author"); + + let authorEl = tags ? tags[0] : null; + + let author = ""; + if (authorEl) + { + tags = this.childrenByTagNameNS(authorEl, FeedUtils.ATOM_03_NS, "name"); + let name = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(authorEl, 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, FeedUtils.ATOM_03_NS, "modified"); + if (!tags || !this.getNodeValue(tags[0])) + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_03_NS, "issued"); + if (!tags || !this.getNodeValue(tags[0])) + tags = this.childrenByTagNameNS(channel, 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, FeedUtils.ATOM_03_NS, "content"); + let contentNode = tags ? tags[0] : null; + + let content; + if (contentNode) + { + content = ""; + for (let j = 0; j < contentNode.childNodes.length; j++) + { + let node = contentNode.childNodes.item(j); + 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; + parsedItems.push(item); + } + + return parsedItems; + }, + + parseAsAtomIETF: function(aFeed, aDOM) + { + let parsedItems = new Array(); + + // Get the first channel (assuming there is only one per Atom File). + let channel = this.childrenByTagNameNS(aDOM, FeedUtils.ATOM_IETF_NS, "feed")[0]; + if (!channel) + return aFeed.onParseError(aFeed); + + if (this.isPermanentRedirect(aFeed, null, channel, null)) + return; + + let tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_IETF_NS, "title"); + aFeed.title = aFeed.title || + this.stripTags(this.serializeTextConstruct(tags ? tags[0] : null)); + + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_IETF_NS, "subtitle"); + aFeed.description = this.serializeTextConstruct(tags ? tags[0] : null); + + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_IETF_NS, "link"); + aFeed.link = this.findAtomLink("alternate", tags); + aFeed.link = this.validLink(aFeed.link); + + if (!aFeed.title) + { + FeedUtils.log.error("FeedParser.parseAsAtomIETF: missing mandatory element " + + "<title>"); + return aFeed.onParseError(aFeed); + } + + if (!aFeed.parseItems) + return parsedItems; + + aFeed.invalidateItems(); + let items = this.childrenByTagNameNS(channel, FeedUtils.ATOM_IETF_NS, "entry"); + items = items ? items : []; + FeedUtils.log.debug("FeedParser.parseAsAtomIETF: items to parse - " + + items.length); + + for (let itemNode of items) + { + if (!itemNode.childElementCount) + continue; + let item = new FeedItem(); + item.feed = aFeed; + item.enclosures = []; + item.keywords = []; + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.FEEDBURNER_NS, "origLink"); + item.url = this.validLink(this.getNodeValue(tags ? tags[0] : null)); + if (!item.url) + { + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "link"); + item.url = this.validLink(this.findAtomLink("alternate", tags)) || + aFeed.link; + } + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "id"); + item.id = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "summary"); + item.description = this.serializeTextConstruct(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(itemNode, 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. + FeedUtils.log.info("FeedParser.parseAsAtomIETF: <entry> missing mandatory " + + "element <id>, or <title> and no <summary>; skipping"); + continue; + } + + // XXX Support multiple authors. + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "source"); + let source = tags ? tags[0] : null; + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "author"); + if (!tags) + tags = this.childrenByTagNameNS(source, FeedUtils.ATOM_IETF_NS, "author"); + if (!tags) + tags = this.childrenByTagNameNS(channel, FeedUtils.ATOM_IETF_NS, "author"); + + let authorEl = tags ? tags[0] : null; + + let author = ""; + if (authorEl) + { + tags = this.childrenByTagNameNS(authorEl, FeedUtils.ATOM_IETF_NS, "name"); + let name = this.getNodeValue(tags ? tags[0] : null); + tags = this.childrenByTagNameNS(authorEl, FeedUtils.ATOM_IETF_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, FeedUtils.ATOM_IETF_NS, "updated"); + if (!tags || !this.getNodeValue(tags[0])) + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "published"); + if (!tags || !this.getNodeValue(tags[0])) + tags = this.childrenByTagNameNS(source, FeedUtils.ATOM_IETF_NS, "published"); + item.date = this.getNodeValue(tags ? tags[0] : null) || item.date; + + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "content"); + item.content = this.serializeTextConstruct(tags ? tags[0] : null); + + if (item.content) + item.xmlContentBase = tags ? tags[0].baseURI : null; + else if (item.description) + { + tags = this.childrenByTagNameNS(itemNode, FeedUtils.ATOM_IETF_NS, "summary"); + item.xmlContentBase = tags ? tags[0].baseURI : null; + } + else + item.xmlContentBase = itemNode.baseURI; + + item.xmlContentBase = this.validLink(item.xmlContentBase); + + // Handle <link rel="enclosure"> (if present). + tags = this.childrenByTagNameNS(itemNode, 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.indexOf(url) == -1) + { + 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 FeedEnclosure(url, type, length, title)); + encUrls.push(url); + } + } + + tags = this.childrenByTagNameNS(itemNode, 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 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, 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, 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.indexOf(term) == -1) + item.keywords.push(term); + } + } + + parsedItems.push(item); + } + + return parsedItems; + }, + + isPermanentRedirect: function(aFeed, aRedirDocChannel, aFeedChannel, aDS) + { + // 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"; + if (aDS) + { + tags = FeedUtils.rdf.GetResource(FeedUtils.ITUNES_NS + tagName); + newUrl = this.getRDFTargetValue(aDS, aFeedChannel, tags); + } + else + { + tags = this.childrenByTagNameNS(aFeedChannel, FeedUtils.ITUNES_NS, tagName); + newUrl = this.getNodeValue(tags ? tags[0] : null); + } + tagName = "itunes:" + tagName; + } + + if (newUrl && newUrl != oldUrl && FeedUtils.isValidScheme(newUrl) && + FeedUtils.changeUrlForFeed(aFeed, newUrl)) + { + FeedUtils.log.info("FeedParser.isPermanentRedirect: found <" + tagName + + "> tag; updated feed url from: " + oldUrl + " to: " + newUrl + + " in folder: " + FeedUtils.getFolderPrettyPath(aFeed.folder)); + aFeed.onUrlChange(aFeed, oldUrl); + return true; + } + + return false; + }, + + serializeTextConstruct: function(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 j = 0; j < textElement.childNodes.length; j++) + { + let node = textElement.childNodes.item(j); + 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; + }, + + getRDFTargetValue: function(ds, source, property) + { + let nodeValue = this.getRDFTargetValueRaw(ds, source, property); + if (!nodeValue) + return null; + + nodeValue = nodeValue.replace(/[\n\r\t]+/g, " "); + return this.removeUnprintableASCII(nodeValue); + + }, + + getRDFTargetValueFormatted: function(ds, source, property) + { + let nodeValue = this.getRDFTargetValueRaw(ds, source, property); + if (!nodeValue) + return null; + + return this.removeUnprintableASCIIexCRLFTAB(nodeValue); + + }, + + getRDFTargetValueRaw: function(ds, source, property) + { + let node = ds.GetTarget(source, property, true); + if (node) + { + try + { + node = node.QueryInterface(Ci.nsIRDFLiteral); + if (node) + return node.Value.trim(); + } + catch (e) + { + // If the RDF was bogus, do nothing. Rethrow if it's some other problem. + if (!((e instanceof Ci.nsIXPCException) && + e.result == Cr.NS_ERROR_NO_INTERFACE)) + throw new Error("FeedParser.getRDFTargetValue: " + e); + } + } + + return null; + }, + + /** + * 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. + * @return {String} - A clean string value or null. + */ + getNodeValue: function(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. + * @return {String} - A clean string value or null. + */ + getNodeValueFormatted: function(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. + * @return {String} - A string value or null. + */ + getNodeValueRaw: function(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: function(aElement, aNamespace, aTagName) + { + if (!aElement) + return null; + + let matches = aElement.getElementsByTagNameNS(aNamespace, aTagName); + let matchingChildren = new Array(); + for (let match of matches) + { + if (match.parentNode == aElement) + matchingChildren.push(match) + } + + return matchingChildren.length ? matchingChildren : null; + }, + + /** + * 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. + * @return {String} - A clean string starting with http, ftp or magnet, + * else null. + */ + validLink: function(link) + { + if (/^((https?|ftp):\/\/|magnet:)/.test(link)) + return this.removeUnprintableASCII(link.trim()); + + return null; + }, + + findAtomLink: function(linkRel, linkElements) + { + 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. + try { + return Services.io.newURI(alink.baseURI, null, null). + resolve(alink.getAttribute("href")); + } + catch (ex) {} + } + } + + return null; + }, + + /** + * Remove unprintable ascii, particularly CR/LF, for non formatted tag values. + * + * @param {String} s - String to clean. + * @return {String} + */ + removeUnprintableASCII: function(s) + { + 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. + * @return {String} + */ + removeUnprintableASCIIexCRLFTAB: function(s) + { + return s ? s.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]+/g, "") : ""; + }, + + stripTags: function(someHTML) + { + return someHTML ? someHTML.replace(/<[^>]+>/g, "") : someHTML; + }, + + xmlUnescape: function(s) + { + s = s.replace(/</g, "<"); + s = s.replace(/>/g, ">"); + s = s.replace(/&/g, "&"); + return s; + }, + + xmlEscape: function(s) + { + s = s.replace(/&/g, "&"); + s = s.replace(/>/g, ">"); + s = s.replace(/</g, "<"); + return s; + }, + + dateRescue: function(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(); + 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 FeedUtils.getValidRFC5322Date(dateString); + } +}; diff --git a/mailnews/extensions/newsblog/content/feed-subscriptions.js b/mailnews/extensions/newsblog/content/feed-subscriptions.js new file mode 100644 index 000000000..2b77e60c4 --- /dev/null +++ b/mailnews/extensions/newsblog/content/feed-subscriptions.js @@ -0,0 +1,2703 @@ +/* -*- 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/. */ + +Components.utils.import("resource:///modules/FeedUtils.jsm"); +Components.utils.import("resource:///modules/gloda/log4moz.js"); +Components.utils.import("resource:///modules/mailServices.js"); +Components.utils.import("resource://gre/modules/FileUtils.jsm"); +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +var {classes: Cc, interfaces: Ci} = Components; + +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: function () + { + // 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(function() { + FeedSubscriptions.refreshSubscriptionView(folder) + }, 100); + let message = FeedUtils.strings.GetStringFromName("subscribe-loading"); + this.updateStatusItem("statusText", message); + + FeedUtils.CANCEL_REQUESTED = false; + + if (this.mMainWin) + { + this.mMainWin.FeedFolderNotificationService = MailServices.mfn; + this.mMainWin.FeedFolderNotificationService + .addListener(this.FolderListener, this.FOLDER_ACTIONS); + } + }, + + onClose: function () + { + 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) + { + this.mMainWin.FeedFolderNotificationService + .removeListener(this.FolderListener, this.FOLDER_ACTIONS); + delete this.mMainWin.FeedFolderNotificationService; + } + } + + return dismissDialog; + }, + + refreshSubscriptionView: function(aSelectFolder, aSelectFeedUrl) + { + let item = this.mView.currentItem; + this.loadSubscriptions(); + this.mTree.view = this.mView; + + if (aSelectFolder && !aSelectFeedUrl) + this.selectFolder(aSelectFolder); + else + { + // If no folder to select, try to select the pre rebuild selection, in + // an existing window. For folderpane changes in a feed account. + if (item) + { + 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: url }, null); + } + } + } + + this.mView.treeBox.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 */ + treeBox: null, + + mRowCount: 0, + get rowCount() { return this.mRowCount; }, + + _selection: null, + get selection () { return this._selection; }, + set selection (val) { return this._selection = val; }, + + setTree: function(aTreebox) { this.treeBox = aTreebox; }, + isSeparator: function(aRow) { return false; }, + isSorted: function() { return false; }, + isSelectable: function(aRow, aColumn) { return false; }, + isEditable: function (aRow, aColumn) { return false; }, + + getProgressMode : function(aRow, aCol) {}, + cycleHeader: function(aCol) {}, + cycleCell: function(aRow, aCol) {}, + selectionChanged: function() {}, + performAction: function(aAction) {}, + performActionOnRow: function (aAction, aRow) {}, + performActionOnCell: function(aAction, aRow, aCol) {}, + getRowProperties: function(aRow) { return ""; }, + getColumnProperties: function(aCol) { return ""; }, + getCellValue: function (aRow, aColumn) {}, + setCellValue: function (aRow, aColumn, aValue) {}, + setCellText: function (aRow, aColumn, aValue) {}, + + getCellProperties: function (aRow, aColumn) { + let item = this.getItemAtIndex(aRow); + let folder = item && item.folder ? item.folder : null; +#ifdef MOZ_THUNDERBIRD + let properties = ["folderNameCol"]; + let hasFeeds = folder ? FeedUtils.getFeedUrlsInFolder(folder) : false; + let prop = !folder ? "isFeed-true" : + hasFeeds ? "isFeedFolder-true" : + folder.isServer ? "serverType-rss isServer-true" : null; + if (prop) + properties.push(prop); + return properties.join(" "); +#else + return !folder ? "serverType-rss" : + folder.isServer ? "serverType-rss isServer-true" : "livemark"; +#endif + }, + + isContainer: function (aRow) + { + let item = this.getItemAtIndex(aRow); + return item ? item.container : false; + }, + + isContainerOpen: function (aRow) + { + let item = this.getItemAtIndex(aRow); + return item ? item.open : false; + }, + + isContainerEmpty: function (aRow) + { + let item = this.getItemAtIndex(aRow); + if (!item) + return false; + + return item.children.length == 0; + }, + + getItemAtIndex: function (aRow) + { + if (aRow < 0 || aRow >= FeedSubscriptions.mFeedContainers.length) + return null; + + return FeedSubscriptions.mFeedContainers[aRow]; + }, + + getItemInViewIndex: function(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: function (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.treeBox.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: function (aRow, aColumn) + { + let item = this.getItemAtIndex(aRow); + return (item && aColumn.id == "folderNameCol") ? item.name : ""; + }, + + getImageSrc: function(aRow, aCol) + { + let item = this.getItemAtIndex(aRow); + if ((item.folder && item.folder.isServer) || item.open) + return ""; + + if (item.favicon != null) + return item.favicon; + + if (item.folder && FeedSubscriptions.mMainWin && + "gFolderTreeView" in FeedSubscriptions.mMainWin) { + let favicon = FeedSubscriptions.mMainWin.gFolderTreeView + .getFolderCacheProperty(item.folder, "favicon"); + if (favicon != null) + return item.favicon = 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(() => { + FeedUtils.getFavicon(child.parentFolder, child.url, null, + window, callback); + }, 0); + break; + } + } + } + else + { + // A feed. + setTimeout(() => { + FeedUtils.getFavicon(item.parentFolder, item.url, null, + window, callback); + }, 0); + } + + // Store empty string to return default while favicons are retrieved. + return item.favicon = ""; + }, + + canDrop: function (aRow, aOrientation) + { + let dropResult = this.extractDragData(aRow); + return aOrientation == Ci.nsITreeView.DROP_ON && dropResult.canDrop && + (dropResult.dropUrl || dropResult.dropOnIndex != this.kRowIndexUndefined); + }, + + drop: function (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(function() { + 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: function(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: function (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: function (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: function(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: function (aRow) + { + let item = this.getItemAtIndex(aRow); + if (item && aRow) + return this.getItemAtIndex(aRow - 1).level == item.level; + else + return false; + }, + + getLevel: function (aRow) + { + let item = this.getItemAtIndex(aRow); + if (!item) + return 0; + + return item.level; + }, + + toggleOpenState: function (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: function (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.treeBox.rowCountChanged(aRow, delta); + return delta; + } + }, + + makeFolderObject: function (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 ? true : false + let folderObject = { children : [], + folder : aFolder, + name : aFolder.prettyName, + level : aCurrentLevel, + url : aFolder.URI, + quickMode: defaultQuickMode, + options : optionsAcct, + open : open, + container: true, + favicon : null }; + + // If a feed has any sub folders, add them to the list of children. + let folderEnumerator = aFolder.subFolders; + + while (folderEnumerator.hasMoreElements()) + { + let folder = folderEnumerator.getNext(); + 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: function (aArray) + { + return aArray.sort(function(a, b) { return a.name.toLowerCase() > + b.name.toLowerCase() }). + sort(function(a, b) { return a.container < b.container }); + }, + + getFeedsInFolder: function (aFolder) + { + let feeds = new Array(); + let feedUrlArray = FeedUtils.getFeedUrlsInFolder(aFolder); + if (!feedUrlArray) + // No feedUrls in this folder. + return feeds; + + for (let url of feedUrlArray) + { + let feedResource = FeedUtils.rdf.GetResource(url); + let feed = new Feed(feedResource, aFolder.server); + feeds.push(feed); + } + + return feeds; + }, + + makeFeedObject: function (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: function () + { + // 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 aFolder nsIMsgFolder - the folder to find. + * @param [aParams] object - params object, containing: + * + * [parentIndex] int - index of folder to start the search; if + * null (default), the index of the folder's + * rootFolder will be used. + * [select] boolean - if true (default) the folder's ancestors + * will be opened and the folder selected. + * [open] boolean - if true (default) the folder is opened. + * [remove] boolean - delete the item from tree row cache if true, + * false (default) otherwise. + * [newFolder] nsIMsgFolder - if not null (default) the new folder, + * for add or rename. + * + * @return bool found - true if found, false if not. + */ + selectFolder: function(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.treeBox) + firstVisRow = this.mView.treeBox.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 occurences 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 + { + // For ancestor folders. + if (selectIt || openIt) + 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.treeBox.getFirstVisibleRow(); + curLastVisRow = this.mView.treeBox.getLastVisibleRow(); + if (firstVisRow >= 0 && + this.mView.rowCount - curLastVisRow > firstVisRow - curFirstVisRow) + this.mView.treeBox.scrollToRow(firstVisRow); + else + this.mView.treeBox.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 aFeed {Feed object} - the feed to find. + * @param [aParentIndex] integer - index to start the folder search. + * + * @return found bool - true if found, false if not. + */ + selectFeed: function(aFeed, aParentIndex) + { + let folder = aFeed.folder; + 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 itemResource = FeedUtils.rdf.GetResource(aFeed.url); + let ds = FeedUtils.getSubscriptionsDS(aFeed.folder.server); + folder = ds.GetTarget(itemResource, FeedUtils.FZ_DESTFOLDER, true); + } + + 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.treeBox.ensureRowIsVisible(i); + found = true; + break; + } + } + } + } + + return found; + }, + + updateFeedData: function (aItem) + { + if (!aItem) + return; + + let nameValue = document.getElementById("nameValue"); + let locationValue = document.getElementById("locationValue"); + let locationValidate = document.getElementById("locationValidate"); + let selectFolder = document.getElementById("selectFolder"); + let selectFolderValue = document.getElementById("selectFolderValue"); + 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; + + // Autotag items. + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + let categoryPrefsAcct = FeedUtils.getOptionsAcct(server).category; + if (isServer) + aItem.options = FeedUtils.getOptionsAcct(server); + let categoryPrefs = aItem.options ? aItem.options.category : null; + + autotagEnable.checked = categoryPrefs && categoryPrefs.enabled; + autotagUsePrefix.checked = categoryPrefs && categoryPrefs.prefixEnabled; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = autotagUsePrefix.disabled || !autotagUsePrefix.checked; + autotagPrefix.value = categoryPrefs && categoryPrefs.prefix ? + categoryPrefs.prefix : ""; + }, + + setFolderPicker: function(aFolder, aIsFeed) + { + let editFeed = document.getElementById("editFeed"); + let folderPrettyPath = FeedUtils.getFolderPrettyPath(aFolder); + if (!folderPrettyPath) + return editFeed.disabled = true; + + let selectFolder = document.getElementById("selectFolder"); + let selectFolderPopup = document.getElementById("selectFolderPopup"); + let selectFolderValue = document.getElementById("selectFolderValue"); + + selectFolder.setAttribute("hidden", !aIsFeed); + selectFolder._folder = aFolder; + selectFolderValue.setAttribute("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); + } + + return editFeed.disabled = false; + }, + + onClickSelectFolderValue: function(aEvent) + { + let target = aEvent.target; + if ((("button" in aEvent) && + (aEvent.button != 0 || + aEvent.originalTarget.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"); + } + }, + + setNewFolder: function(aEvent) + { + aEvent.stopPropagation(); + this.setFolderPicker(aEvent.target._folder, true); + this.editFeed(); + }, + + setSummary: function(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; }); + let ds = FeedUtils.getSubscriptionsDS(item.folder.server); + ds.Flush(); + } + + // Update the folder in the tree map. + item.quickMode = aChecked; + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + setCategoryPrefs: function(aNode) + { + let item = this.mView.currentItem; + if (!item) + return; + + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + 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. + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = autotagUsePrefix.disabled || !autotagUsePrefix.checked; + return; + } + + switch (aNode.id) { + case "autotagEnable": + item.options.category.enabled = aNode.checked; + break; + case "autotagUsePrefix": + item.options.category.prefixEnabled = aNode.checked; + item.options.category.prefix = autotagPrefix.value; + break; + } + + if (isServer) + { + FeedUtils.setOptionsAcct(item.folder.server, item.options) + } + else + { + let feedResource = FeedUtils.rdf.GetResource(item.url); + let feed = new Feed(feedResource, item.parentFolder.server); + feed.options = item.options; + let ds = FeedUtils.getSubscriptionsDS(item.parentFolder.server); + ds.Flush(); + } + + this.updateFeedData(item); + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + onKeyPress: function(aEvent) + { + if (aEvent.keyCode == aEvent.DOM_VK_DELETE && + aEvent.target.id == "rssSubscriptionsList") + this.removeFeed(true); + + this.clearStatusInfo(); + }, + + onSelect: function () + { + let item = this.mView.currentItem; + this.updateFeedData(item); + this.setSummaryFocus(); + this.updateButtons(item); + }, + + updateButtons: function (aSelectedItem) + { + let item = aSelectedItem; + let isServer = item && item.folder && item.folder.isServer; + let disable = !item || !item.container || isServer || + this.mActionMode == this.kImportingOPML; + document.getElementById("addFeed").disabled = disable; + disable = !item || (item.container && !isServer) || + this.mActionMode == this.kImportingOPML; + document.getElementById("editFeed").disabled = disable; + disable = !item || item.container || + this.mActionMode == this.kImportingOPML; + document.getElementById("removeFeed").disabled = disable; + disable = !item || !isServer || + this.mActionMode == this.kImportingOPML; + document.getElementById("importOPML").disabled = disable; + document.getElementById("exportOPML").disabled = disable; + }, + + onMouseDown: function (aEvent) + { + if (aEvent.button != 0 || + aEvent.target.id == "validationText" || + aEvent.target.id == "addCertException") + return; + + this.clearStatusInfo(); + }, + + setSummaryFocus: function () + { + let item = this.mView.currentItem; + if (!item) + return; + + let locationValue = document.getElementById("locationValue"); + let quickMode = document.getElementById("quickMode"); + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + let isFeed = !item.container; + + // Enable summary/autotag by default. + quickMode.disabled = autotagEnable.disabled = false; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = autotagUsePrefix.disabled || !autotagUsePrefix.checked; + + if (isServer) + { + let disable = locationValue.hasAttribute("focused") || locationValue.value; + document.getElementById("addFeed").disabled = !disable; + document.getElementById("editFeed").disabled = disable; + + } + else if (isFolder) + { + if (!locationValue.hasAttribute("focused") && !locationValue.value) + { + // Enabled for a folder with feeds. Autotag disabled unless intent is + // to add a feed. + quickMode.disabled = !FeedUtils.getFeedUrlsInFolder(item.folder); + autotagEnable.disabled = autotagUsePrefix.disabled = + autotagPrefix.disabled = true; + } + } + else + { + // Summary is per folder. + quickMode.disabled = true; + } + }, + + removeFeed: function (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], 1); + if (Services.prompt.confirmEx(window, pTitle, pMessage, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, null, null, null, { })) + return; + } + + FeedUtils.deleteFeed(FeedUtils.rdf.GetResource(itemToRemove.url), + itemToRemove.parentFolder.server, + itemToRemove.parentFolder); + + // 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 [aFeedLocation] string - the feed url; get the url from the + * input field if null. + * @param [aFolder] nsIMsgFolder - folder to subscribe, current selected + * folder if null. + * @param [aParse] boolean - if true (default) parse and download + * the feed's articles. + * @param [aParams] object - additional params. + * @param [aMode] integer - action mode (default is kSubscribeMode) + * of the add. + * + * @return success boolean - true if edit checks passed and an + * async download has been initiated. + */ + addFeed: function(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) + { + message = locationValue.getAttribute("placeholder"); + this.updateStatusItem("statusText", message); + return false; + } + + if (!FeedUtils.isValidScheme(feedLocation)) + { + 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)) + { + 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.category.enabled = document.getElementById("autotagEnable").checked; + options.category.prefixEnabled = document.getElementById("autotagUsePrefix").checked; + options.category.prefix = document.getElementById("autotagPrefix").value; + } + + let folderURI = addFolder.isServer ? null : addFolder.URI; + let feedProperties = { feedName : name, + feedLocation : feedLocation, + folderURI : folderURI, + server : addFolder.server, + quickMode : quickMode, + options : 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", 0); + document.getElementById("addFeed").setAttribute("disabled", true); + this.mActionMode = mode; + feed.download(parse, this.mFeedDownloadCallback); + return true; + }, + + // Helper routine used by addFeed and importOPMLFile. + storeFeed: function(feedProperties) + { + let itemResource = FeedUtils.rdf.GetResource(feedProperties.feedLocation); + let feed = new Feed(itemResource, feedProperties.server); + + // If the user specified a folder to add the feed to, then set it here. + if (feedProperties.folderURI) + { + let folderResource = FeedUtils.rdf.GetResource(feedProperties.folderURI); + if (folderResource) + { + let folder = folderResource.QueryInterface(Ci.nsIMsgFolder); + if (folder && !folder.isServer) + feed.folder = folder; + } + } + + feed.title = feedProperties.feedName; + feed.quickMode = feedProperties.quickMode; + feed.options = feedProperties.options; + return feed; + }, + + updateAccount: function(aItem) + { + // Check to see if the categoryPrefs custom prefix string value changed. + let editAutotagPrefix = document.getElementById("autotagPrefix").value; + if (aItem.options.category.prefix != editAutotagPrefix) + { + aItem.options.category.prefix = editAutotagPrefix; + FeedUtils.setOptionsAcct(aItem.folder.server, aItem.options) + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + } + }, + + editFeed: function() + { + let seln = this.mView.selection; + if (seln.count != 1) + return; + + let itemToEdit = this.mView.getItemAtIndex(seln.currentIndex); + if (itemToEdit.folder && itemToEdit.folder.isServer) + { + this.updateAccount(itemToEdit) + return; + } + + if (!itemToEdit || itemToEdit.container || !itemToEdit.parentFolder) + return; + + let resource = FeedUtils.rdf.GetResource(itemToEdit.url); + let currentFolderServer = itemToEdit.parentFolder.server; + let ds = FeedUtils.getSubscriptionsDS(currentFolderServer); + let currentFolderURI = itemToEdit.parentFolder.URI; + let feed = new Feed(resource, currentFolderServer); + feed.folder = itemToEdit.parentFolder; + + let editNameValue = document.getElementById("nameValue").value; + let editFeedLocation = document.getElementById("locationValue").value.trim(); + let selectFolder = document.getElementById("selectFolder"); + let editQuickMode = document.getElementById("quickMode").checked; + let editAutotagPrefix = document.getElementById("autotagPrefix").value; + + if (feed.url != editFeedLocation) + { + // Updating a url. We need to add the new url and delete the old, to + // ensure everything is cleaned up correctly. + this.addFeed(null, itemToEdit.parentFolder, false, null, this.kUpdateMode) + return; + } + + // Did the user change the folder URI for storing the feed? + let editFolderURI = selectFolder.getAttribute("uri"); + if (currentFolderURI != editFolderURI) + { + // 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"); + + return; + } + + let updated = false; + let message = ""; + // Disable the button until the update completes and we process the async + // verify response. + document.getElementById("editFeed").setAttribute("disabled", true); + + // Check to see if the title value changed, no blank title allowed. + if (feed.title != editNameValue) + { + if (!editNameValue) + { + document.getElementById("nameValue").value = feed.title; + } + else + { + feed.title = editNameValue; + itemToEdit.name = editNameValue; + seln.tree.invalidateRow(seln.currentIndex); + updated = true; + } + } + + // Check to see if the quickMode value changed. + if (feed.quickMode != editQuickMode) + { + feed.quickMode = editQuickMode; + itemToEdit.quickMode = editQuickMode; + updated = true; + } + + // Check to see if the categoryPrefs custom prefix string value changed. + if (itemToEdit.options.category.prefix != editAutotagPrefix && + itemToEdit.options.category.prefix != null && + editAutotagPrefix != "") + { + itemToEdit.options.category.prefix = editAutotagPrefix; + feed.options = itemToEdit.options; + updated = true; + } + + let verifyDelay = 0; + if (updated) { + ds.Flush(); + message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + verifyDelay = 1500; + } + + // Now we want to verify if the stored feed url still works. If it + // doesn't, show the error. Delay a bit to leave Updated message visible. + message = FeedUtils.strings.GetStringFromName("subscribe-validating-feed"); + this.mActionMode = this.kVerifyUrlMode; + setTimeout(() => { + this.updateStatusItem("statusText", message); + this.updateStatusItem("progressMeter", "?"); + feed.download(false, this.mFeedDownloadCallback); + }, verifyDelay); + }, + +/** + * Moves or copies a feed to another folder or account. + * + * @param int aOldFeedIndex - index in tree of target feed item. + * @param int aNewParentIndex - index in tree of target parent folder item. + * @param string aMoveCopy - either "move" or "copy". + */ + moveCopyFeed: function(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 currentParentResource = FeedUtils.rdf.GetResource(currentParentItem.url); + let currentFolder = currentParentResource.QueryInterface(Ci.nsIMsgFolder); + + let newParentItem = this.mView.getItemAtIndex(aNewParentIndex); + let newParentResource = FeedUtils.rdf.GetResource(newParentItem.url); + let newFolder = newParentResource.QueryInterface(Ci.nsIMsgFolder); + + let ds = FeedUtils.getSubscriptionsDS(currentItem.parentFolder.server); + let resource = FeedUtils.rdf.GetResource(currentItem.url); + + 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; + + // Unassert the older URI, add an assertion for the new parent URI. + ds.Change(resource, FeedUtils.FZ_DESTFOLDER, + currentParentResource, newParentResource); + ds.Flush(); + + // Update folderpane favicons. + FeedUtils.setFolderPaneProperty(currentFolder, "favicon", null, "row"); + FeedUtils.setFolderPaneProperty(newFolder, "favicon", null, "row"); + } + 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 successfull, and doing a move. + if (moveFeed) + FeedUtils.deleteFeed(FeedUtils.rdf.GetResource(currentItem.url), + currentItem.parentFolder.server, + currentItem.parentFolder); + } + + // 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: function (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 feedResource = FeedUtils.rdf.GetResource(feedItem.url); + let feed = new Feed(feedResource, feedItem.parentFolder.server); + feed.quickMode = parentItem.quickMode; + feedItem.quickMode = parentItem.quickMode; + } + else + parentItem.quickMode = feedItem.quickMode; + } + }, + + onDragStart: function (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: function (aEvent) + { + this.mView._currentDataTransfer = aEvent.dataTransfer; + }, + + mFeedDownloadCallback: + { + mSubscribeMode: true, + downloaded: function(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(); + message = FeedUtils.strings.GetStringFromName("subscribe-feedVerified"); + win.updateStatusItem("statusText", message); + document.getElementById("editFeed").removeAttribute("disabled"); + return; + } + + // Add the feed to the databases. + FeedUtils.addFeed(feed); + + // 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; + let rows = win.mFeedContainers; + 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(FeedUtils.rdf.GetResource(feed.url), + feed.server, + feed.server.rootFolder); + + 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, null, null).host; + message = FeedUtils.strings.formatStringFromName( + "newsblog-badCertError", [host], 1); + } + if (aErrorCode == FeedUtils.kNewsBlogNoAuthError) + message = FeedUtils.strings.GetStringFromName( + "subscribe-noAuthError"); + + if (win.mActionMode != win.kUpdateMode && + win.mActionMode != win.kVerifyUrlMode) + // Re-enable the add button if subscribe failed. + document.getElementById("addFeed").removeAttribute("disabled"); + if (win.mActionMode == win.kVerifyUrlMode) + // Re-enable the update button if verify failed. + document.getElementById("editFeed").removeAttribute("disabled"); + } + + 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: function (feed, aCurrentFeedItems, aMaxFeedItems) + { + window.focus(); + let message = FeedUtils.strings.formatStringFromName( + "subscribe-gettingFeedItems", + [aCurrentFeedItems, aMaxFeedItems], 2); + FeedSubscriptions.updateStatusItem("statusText", message); + this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems); + }, + + onProgress: function(feed, aProgress, aProgressMax, aLengthComputable) + { + FeedSubscriptions.updateStatusItem("progressMeter", + (aProgress * 100) / aProgressMax); + } + }, + + // Status routines. + updateStatusItem: function(aID, aValue, aErrorCode) + { + let el = document.getElementById(aID); + if (el.getAttribute("collapsed")) + el.removeAttribute("collapsed"); + + if (aID == "progressMeter") + el.setAttribute("mode", aValue == "?" ? "undetermined" : "determined"); + + if (aID == "statusText") + el.textContent = aValue; + else + el.value = 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: function() + { + document.getElementById("statusText").textContent = ""; + document.getElementById("progressMeter").collapsed = true; + document.getElementById("validationText").collapsed = true; + document.getElementById("addCertException").collapsed = true; + }, + + checkValidation: function(aEvent) + { + if (aEvent.button != 0) + return; + + let validationSite = "http://validator.w3.org"; + 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, "tab", "^" + validationSite); + FeedUtils.log.debug("checkValidation: query url - " + url); + } + } + aEvent.stopPropagation(); + }, + + addCertExceptionDialog: function() + { + let feedURL = document.getElementById("locationValue").value.trim(); + let params = { exceptionAdded : false, + location: feedURL, + prefetchCert: true }; + window.openDialog("chrome://pippki/content/exceptionDialog.xul", + "", "chrome,centerscreen,modal", params); + if (params.exceptionAdded) + this.clearStatusInfo(); + }, + + // 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: function(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 curSelIndex = this.currentSelectedIndex; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.treeBox.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.treeBox.scrollToRow(firstVisRow); + return; + } + } + + // Rebuild the added folder's parent item in the tree row cache. + feedWindow.selectFolder(parentFolder, { select: false, + open: 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.treeBox.scrollToRow(firstVisRow); + + if (curSelItem.container) + feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open }); + else + feedWindow.selectFeed({ folder: curSelItem.parentFolder, + url: curSelItem.url }, parentIndex); + }, + + folderDeleted: function(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: function(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.treeBox.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: 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.treeBox.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: function(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.treeBox.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(function() { + // 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.treeBox.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 bool aList - if true, exporting as list; if false (default) + * exporting feeds in folder structure - used for title. + * @return nsILocalFile or null. + */ + opmlPickSaveAsFile: function(aList) + { + let accountName = this.mRSSServer.rootFolder.prettyName; + let fileName = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDefaultFileName", + [this.brandShortName, accountName], 2); + let title = aList ? FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleList", [accountName], 1) : + FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleStruct", [accountName], 1); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + fp.defaultString = fileName; + fp.defaultExtension = "opml"; + if (this.opmlLastSaveAsDir && (this.opmlLastSaveAsDir instanceof Ci.nsILocalFile)) + 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); + + if (fp.show() != Ci.nsIFilePicker.returnCancel && fp.file) + { + this.opmlLastSaveAsDir = fp.file.parent; + return fp.file; + } + + return null; + }, + +/** + * Import feeds opml file Open filepicker function. + * + * @return nsILocalFile or null. + */ + opmlPickOpenFile: function() + { + 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.nsILocalFile)) + 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); + + if (fp.show() != Ci.nsIFilePicker.returnCancel && fp.file) + { + this.opmlLastOpenDir = fp.file.parent; + return fp.file; + } + + return null; + }, + + exportOPML: function(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], 2); + 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, ""); + + let serializer = new XMLSerializer(); + + if (FeedUtils.log.level <= Log4Moz.Level.Debug) + FeedUtils.log.debug("exportOPML: opmlDoc -\n" + + serializer.serializeToString(opmlDoc) + "\n"); + + // Get file to save from filepicker. + let saveAsFile = this.opmlPickSaveAsFile(exportAsList); + if (!saveAsFile) + return; + + let fos = FileUtils.openSafeFileOutputStream(saveAsFile); + serializer.serializeToStream(opmlDoc, fos, "utf-8"); + FileUtils.closeSafeFileOutputStream(fos); + + let statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDone", [saveAsFile.path], 1); + this.updateStatusItem("statusText", statusReport); + } + }, + + generatePPSpace: function(aNode, indentString) + { + aNode.appendChild(aNode.ownerDocument.createTextNode("\n")); + aNode.appendChild(aNode.ownerDocument.createTextNode(indentString)); + }, + + generateOutlineList: function(baseFolder, parent, indentLevel) + { + // Pretty printing. + let indentString = " ".repeat(indentLevel - 2); + + let feedOutline; + let folderEnumerator = baseFolder.subFolders; + while (folderEnumerator.hasMoreElements()) + { + let folder = folderEnumerator.getNext().QueryInterface(Ci.nsIMsgFolder); + 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: function(baseFolder, parent, indentLevel) + { + // Pretty printing. + function indentString(len) { return " ".repeat(len - 2); }; + + let folderOutline, feedOutline; + let folderEnumerator = baseFolder.subFolders; + while (folderEnumerator.hasMoreElements()) + { + let folder = folderEnumerator.getNext().QueryInterface(Ci.nsIMsgFolder); + 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: function(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; + }, + + importOPML: function() + { + // 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 to open from filepicker. + let openFile = this.opmlPickOpenFile(); + if (!openFile) + return; + + 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 (!this.importOPMLFile(openFile, 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 nsILocalFile aFile - the opml file. + * @param nsIMsgIncomingServer aServer - the account server. + * @param func aCallback - callback function. + * + * @return bool - false if error. + */ + importOPMLFile: function(aFile, aServer, aCallback) + { + if (aServer && (aServer instanceof Ci.nsIMsgIncomingServer)) + this.mRSSServer = aServer; + + if (!aFile || !this.mRSSServer || !aCallback) + return false; + + let opmlDom, statusReport; + let stream = Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(Ci.nsIFileInputStream); + + // Read in file as raw bytes, so Expat can do the decoding for us. + try { + stream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + let parser = new DOMParser(); + opmlDom = parser.parseFromStream(stream, null, stream.available(), + "application/xml"); + } + catch(e) { + statusReport = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile"); + Services.prompt.alert(window, null, statusReport); + return false; + } + finally { + stream.close(); + } + + let body = opmlDom ? opmlDom.querySelector("body") : null; + + // Return if the OPML file is invalid or empty. + if (!body || !body.childElementCount || + opmlDom.documentElement.tagName != "opml") + { + statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLImportInvalidFile", [aFile.leafName], 1); + Services.prompt.alert(window, null, statusReport); + return false; + } + + this.importOPMLOutlines(body, this.mRSSServer, aCallback); + return true; + }, + + importOPMLOutlines: function(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; + let folderOutlines = 0; + + function processor(aParentNode, aParentFolder) + { + FeedUtils.log.trace("importOPMLOutlines: PROCESSOR tag:name:childs - " + + 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, folderURI; + + 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. + folderURI = feedFolder.URI; + 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. + } + + // 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, + server : rssServer, + folderURI : folderURI, + quickMode : quickMode, + options : 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(FeedUtils.rdf.GetResource(feed.url), + feed.server, + feed.server.rootFolder); + 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 defaultName = FeedUtils.strings.GetStringFromName("ImportFeedsNew"); + let folderName = FeedUtils.getSanitizedFolderName(aParentFolder, + outlineName, + defaultName, + false); + 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); + folderOutlines++; + } + 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, null); + 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(function() { + processor(outline, feedFolder); + }, 0); + } + + processor(aBody, rssServer.rootFolder); + }, + + importOPMLStatus: function(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: function(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/mailnews/extensions/newsblog/content/feed-subscriptions.xul b/mailnews/extensions/newsblog/content/feed-subscriptions.xul new file mode 100644 index 000000000..d6f4ea18f --- /dev/null +++ b/mailnews/extensions/newsblog/content/feed-subscriptions.xul @@ -0,0 +1,235 @@ +<?xml version="1.0"?> +<!-- -*- Mode: Java; 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderPane.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"?> + +<!DOCTYPE window [ + <!ENTITY % feedDTD SYSTEM "chrome://messenger-newsblog/locale/feed-subscriptions.dtd"> + %feedDTD; + <!ENTITY % certDTD SYSTEM "chrome://pippki/locale/certManager.dtd"> + %certDTD; +]> + +<window id="subscriptionsDialog" + flex="1" + title="&feedSubscriptions.label;" + windowtype="Mail:News-BlogSubscriptions" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:nc="http://home.netscape.com/NC-rdf#" + persist="width height screenX screenY sizemode" + onload="FeedSubscriptions.onLoad();" + onclose="return FeedSubscriptions.onClose();" + onkeypress="FeedSubscriptions.onKeyPress(event);" + onmousedown="FeedSubscriptions.onMouseDown(event);"> + + <script type="application/javascript" + src="chrome://messenger/content/specialTabs.js"/> + <script type="application/javascript" + src="chrome://messenger-newsblog/content/feed-subscriptions.js"/> + + <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 align="right"> + <label id="learnMore" + class="text-link" + 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" + flex="2" + primary="true" + hideheader="true"/> + </treecols> + <treechildren id="subscriptionChildren" + ondragstart="FeedSubscriptions.onDragStart(event);" + ondragover="FeedSubscriptions.onDragOver(event);"/> + </tree> + + <hbox id="rssFeedInfoBox"> + <vbox flex="1"> + <grid flex="1"> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row> + <hbox align="right" valign="middle"> + <label id="nameLabel" + accesskey="&feedTitle.accesskey;" + control="nameValue" + value="&feedTitle.label;"/> + </hbox> + <textbox id="nameValue" + clickSelectsAll="true"/> + </row> + <row> + <hbox align="right" valign="middle"> + <label id="locationLabel" + accesskey="&feedLocation.accesskey;" + control="locationValue" + value="&feedLocation.label;"/> + </hbox> + <hbox> + <textbox id="locationValue" + flex="1" + class="uri-element" + placeholder="&feedLocation.placeholder;" + clickSelectsAll="true" + onfocus="FeedSubscriptions.setSummaryFocus();" + onblur="FeedSubscriptions.setSummaryFocus();"/> + <hbox align="center"> + <label id="locationValidate" + collapsed="true" + class="text-link" + crop="end" + value="&locationValidate.label;" + onclick="FeedSubscriptions.checkValidation(event);"/> + </hbox> + </hbox> + </row> + <row> + <hbox align="right" valign="middle"> + <label id="feedFolderLabel" + value="&feedFolder.label;" + accesskey="&feedFolder.accesskey;" + control="selectFolder"/> + </hbox> + <hbox> + <menulist id="selectFolder" + flex="1" + class="folderMenuItem" + hidden="true"> + <menupopup id="selectFolderPopup" + class="menulist-menupopup" + type="folder" + mode="feeds" + showFileHereLabel="true" + showAccountsFileHere="true" + oncommand="FeedSubscriptions.setNewFolder(event)"/> + </menulist> + <textbox id="selectFolderValue" + flex="1" + readonly="true" + onkeypress="FeedSubscriptions.onClickSelectFolderValue(event)" + onclick="FeedSubscriptions.onClickSelectFolderValue(event)"/> + </hbox> + </row> + </rows> + </grid> + <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.setCategoryPrefs(this)"/> + <hbox> + <checkbox id="autotagUsePrefix" + class="indent" + accesskey="&autotagUsePrefix.accesskey;" + label="&autotagUsePrefix.label;" + oncommand="FeedSubscriptions.setCategoryPrefs(this)"/> + <textbox id="autotagPrefix" + placeholder="&autoTagPrefix.placeholder;" + clickSelectsAll="true"/> + </hbox> + <separator class="thin"/> + </vbox> + </hbox> + + <hbox id="statusContainerBox" + align="center" + valign="middle"> + <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" + label="&certmgr.addException.label;" + accesskey="&certmgr.addException.accesskey;" + oncommand="FeedSubscriptions.addCertExceptionDialog();"/> + <progressmeter id="progressMeter" + collapsed="true" + mode="determined" + value="0"/> + </hbox> + + <hbox align="end"> + <hbox class="actionButtons" flex="1"> + <button id="addFeed" + label="&button.addFeed.label;" + accesskey="&button.addFeed.accesskey;" + oncommand="FeedSubscriptions.addFeed();"/> + + <button id="editFeed" + disabled="true" + label="&button.updateFeed.label;" + accesskey="&button.updateFeed.accesskey;" + oncommand="FeedSubscriptions.editFeed();"/> + + <button id="removeFeed" + disabled="true" + label="&button.removeFeed.label;" + accesskey="&button.removeFeed.accesskey;" + oncommand="FeedSubscriptions.removeFeed(true);"/> + + <button id="importOPML" + label="&button.importOPML.label;" + accesskey="&button.importOPML.accesskey;" + oncommand="FeedSubscriptions.importOPML();"/> + + <button id="exportOPML" + label="&button.exportOPML.label;" + accesskey="&button.exportOPML.accesskey;" + tooltiptext="&button.exportOPML.tooltip;" + oncommand="FeedSubscriptions.exportOPML(event);"/> + + <spacer flex="1"/> + + <button id="close" + label="&button.close.label;" + icon="close" + oncommand="if (FeedSubscriptions.onClose()) window.close();"/> + </hbox> + </hbox> + </vbox> +</window> diff --git a/mailnews/extensions/newsblog/content/feedAccountWizard.js b/mailnews/extensions/newsblog/content/feedAccountWizard.js new file mode 100644 index 000000000..a79da073a --- /dev/null +++ b/mailnews/extensions/newsblog/content/feedAccountWizard.js @@ -0,0 +1,45 @@ +/* -*- Mode: Java; 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/. */
+
+Components.utils.import("resource:///modules/FeedUtils.jsm");
+
+/* Feed account standalone wizard functions */
+var FeedAccountWizard = {
+ accountName: "",
+
+ accountSetupPageInit: function() {
+ this.accountSetupPageValidate();
+ },
+
+ accountSetupPageValidate: function() {
+ this.accountName = document.getElementById("prettyName").value.trim();
+ document.documentElement.canAdvance = this.accountName;
+ },
+
+ accountSetupPageUnload: function() {
+ return;
+ },
+
+ donePageInit: function() {
+ document.getElementById("account.name.text").value = this.accountName;
+ },
+
+ onCancel: function() {
+ return true;
+ },
+
+ onFinish: function() {
+ let account = FeedUtils.createRssAccount(this.accountName);
+ if ("gFolderTreeView" in window.opener.top)
+ // Opened from 3pane File->New or Appmenu New Message, or
+ // Account Central link.
+ window.opener.top.gFolderTreeView.selectFolder(account.incomingServer.rootMsgFolder);
+ else if ("selectServer" in window.opener)
+ // Opened from Account Settings.
+ window.opener.selectServer(account.incomingServer);
+
+ window.close();
+ }
+}
diff --git a/mailnews/extensions/newsblog/content/feedAccountWizard.xul b/mailnews/extensions/newsblog/content/feedAccountWizard.xul new file mode 100644 index 000000000..0535fb237 --- /dev/null +++ b/mailnews/extensions/newsblog/content/feedAccountWizard.xul @@ -0,0 +1,79 @@ +<?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"?>
+
+<!DOCTYPE wizard [
+ <!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;
+]>
+
+<wizard id="FeedAccountWizard"
+ title="&feedWindowTitle.label;"
+ onwizardcancel="return FeedAccountWizard.onCancel();"
+ onwizardfinish="return FeedAccountWizard.onFinish();"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <script type="application/javascript"
+ src="chrome://messenger-newsblog/content/feedAccountWizard.js"/>
+
+ <!-- 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;"
+ onpageshow="return FeedAccountWizard.accountSetupPageInit();"
+ onpageadvanced="return FeedAccountWizard.accountSetupPageUnload();">
+ <vbox flex="1">
+ <description>&accnameDesc.label;</description>
+ <separator class="thin"/>
+ <hbox align="center">
+ <label class="label"
+ value="&accnameLabel.label;"
+ accesskey="&accnameLabel.accesskey;"
+ control="prettyName"/>
+ <textbox id="prettyName"
+ flex="1"
+ value="&feeds.accountName;"
+ oninput="FeedAccountWizard.accountSetupPageValidate();"/>
+ </hbox>
+ </vbox>
+ </wizardpage>
+
+ <!-- Done page : Summarizes information collected to create a feed account -->
+ <wizardpage id="done"
+ pageid="done"
+ label="&accountSummaryTitle.label;"
+ onpageshow="return FeedAccountWizard.donePageInit();">
+ <vbox flex="1">
+ <description>&accountSummaryInfo.label;</description>
+ <separator class="thin"/>
+ <grid>
+ <columns>
+ <column/>
+ <column flex="1"/>
+ </columns>
+ <rows>
+ <row id="account.name"
+ align="center">
+ <label id="account.name.label"
+ class="label"
+ flex="1"
+ value="&accnameLabel.label;"/>
+ <label id="account.name.text"
+ class="label"/>
+ </row>
+ </rows>
+ </grid>
+ <separator/>
+ <spacer flex="1"/>
+ </vbox>
+ </wizardpage>
+
+</wizard>
diff --git a/mailnews/extensions/newsblog/content/newsblogOverlay.js b/mailnews/extensions/newsblog/content/newsblogOverlay.js new file mode 100644 index 000000000..f7e08ec95 --- /dev/null +++ b/mailnews/extensions/newsblog/content/newsblogOverlay.js @@ -0,0 +1,363 @@ +/* -*- 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/. */ + +Components.utils.import("resource:///modules/gloda/mimemsg.js"); +Components.utils.import("resource:///modules/mailServices.js"); +Components.utils.import("resource://gre/modules/Services.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, + + /** + * 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") ? true : false; + }, + + /** + * 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 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. + * + * @return true if message is a feed, false if not. + */ + isFeedMessage: function(aMsgHdr) { + return (aMsgHdr instanceof Components.interfaces.nsIMsgDBHdr) && + ((aMsgHdr.flags & Components.interfaces.nsMsgMessageFlags.FeedMsg) || + (aMsgHdr.folder && aMsgHdr.folder.server.type == "rss")); + }, + + /** + * Determine whether to show a feed message summary or load a web page in the + * message pane. + * + * @param nsIMsgDBHdr aMsgHdr - the message. + * @param bool aToggle - true if in toggle mode, false otherwise. + * + * @return true if summary is to be displayed, false if web page. + */ + shouldShowSummary: function(aMsgHdr, aToggle) { + // Not a feed message, always show summary (the message). + if (!this.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 = getBrowser(); + 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 feeds.rdf. If the feed + // message is not in a feed account folder (hence the folder is not in + // the feeds database), or FZ_QUICKMODE property is not found (possible + // in pre renovation urls), 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, targetRes; + try { + targetRes = FeedUtils.getParentTargetForChildResource( + folder.URI, FeedUtils.FZ_QUICKMODE, folder.server); + } + catch (ex) { + // Not in a feed account folder or other error. + FeedUtils.log.info("FeedMessageHandler.shouldShowSummary: could not " + + "get summary pref for this folder"); + } + + showSummary = targetRes && targetRes.QueryInterface(Ci.nsIRDFLiteral). + Value == "false" ? false : true; + 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 {obj} aWhere - name value=true pair, where name is in: + * 'messagepane', 'browser', 'tab', 'window'. + */ + loadWebPage: function(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 { + let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + url = converter.ConvertToUnicode(url); + uri = Services.io.newURI(url, null, null); + url = uri.spec; + } + catch (ex) { + FeedUtils.log.info("FeedMessageHandler.loadWebPage: " + + "invalid Content-Base header url - " + url); + return; + } + if (aWhere.browser) + Components.classes["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Components.interfaces.nsIExternalProtocolService) + .loadURI(uri); + else if (aWhere.messagepane) { + let loadFlag = getBrowser().webNavigation.LOAD_FLAGS_NONE; + getBrowser().webNavigation.loadURI(url, loadFlag, null, null, null); + } + else if (aWhere.tab) + openContentTab(url, "tab", "^"); + else if (aWhere.window) + openContentTab(url, "window", "^"); + } + 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 bool aShowSummary - true if summary is to be displayed, false if + * web page. + */ + setContent: function(aMsgHdr, aShowSummary) { + if (aShowSummary) { + // Only here if toggling to summary in 3pane. + if (this.gToggle && gDBView && GetNumSelectedMessages() == 1) + ReloadMessage(); + } + else { + let browser = getBrowser(); + 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. + if ("msgNotificationBar" in window) + 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.treeBox.ensureRowIsVisible( + subscriptionsWindow.FeedSubscriptions.mView.selection.currentIndex); + } + + subscriptionsWindow.focus(); + } + else + { + window.openDialog("chrome://messenger-newsblog/content/feed-subscriptions.xul", + "", "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, aMsgWindow); + + } + else + { + // Set up the compose message and get the feed message's web page link. + let Cc = Components.classes; + let Ci = Components.interfaces; + 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) + { + subject = "Re: " + subject; + } + else if (type == msgComposeType.ForwardInline || + type == msgComposeType.ForwardAsAttachment) + { + subject = fwdPrefix + subject; + } + + params.composeFields = composeFields; + params.composeFields.subject = subject; + params.composeFields.characterSet = msgHdr.Charset; + 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 converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let url = converter.ConvertToUnicode(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, aMsgWindow); + + }, false, {saneBodySize: true}); + } + catch (ex) + { + // Error getting header, use the summary. + MailServices.compose.OpenComposeWindow(aMsgComposeWindow, aMsgHdr, aMessageUri, + aType, aFormat, aIdentity, aMsgWindow); + } + } +} diff --git a/mailnews/extensions/newsblog/jar.mn b/mailnews/extensions/newsblog/jar.mn new file mode 100644 index 000000000..aa16a0100 --- /dev/null +++ b/mailnews/extensions/newsblog/jar.mn @@ -0,0 +1,16 @@ +# 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 (content/newsblogOverlay.js) + content/messenger-newsblog/Feed.js (content/Feed.js) + content/messenger-newsblog/FeedItem.js (content/FeedItem.js) + content/messenger-newsblog/feed-parser.js (content/feed-parser.js) +* content/messenger-newsblog/feed-subscriptions.js (content/feed-subscriptions.js) + content/messenger-newsblog/feed-subscriptions.xul (content/feed-subscriptions.xul) + content/messenger-newsblog/am-newsblog.js (content/am-newsblog.js) + content/messenger-newsblog/am-newsblog.xul (content/am-newsblog.xul) + content/messenger-newsblog/feedAccountWizard.js (content/feedAccountWizard.js) + content/messenger-newsblog/feedAccountWizard.xul (content/feedAccountWizard.xul) diff --git a/mailnews/extensions/newsblog/js/newsblog.js b/mailnews/extensions/newsblog/js/newsblog.js new file mode 100644 index 000000000..364038ee5 --- /dev/null +++ b/mailnews/extensions/newsblog/js/newsblog.js @@ -0,0 +1,99 @@ +/* -*- Mode: Java; 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/. */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource:///modules/FeedUtils.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +var nsNewsBlogFeedDownloader = +{ + downloadFeed: function(aFolder, aUrlListener, aIsBiff, aMsgWindow) + { + FeedUtils.downloadFeed(aFolder, aUrlListener, aIsBiff, aMsgWindow); + }, + + subscribeToFeed: function(aUrl, aFolder, aMsgWindow) + { + FeedUtils.subscribeToFeed(aUrl, aFolder, aMsgWindow); + }, + + updateSubscriptionsDS: function(aFolder, aOrigFolder, aAction) + { + FeedUtils.updateSubscriptionsDS(aFolder, aOrigFolder, aAction); + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsINewsBlogFeedDownloader) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +} + +var nsNewsBlogAcctMgrExtension = +{ + name: "newsblog", + chromePackageName: "messenger-newsblog", + showPanel: function (server) + { + return false; + }, + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIMsgAccountManagerExtension) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +} + +function FeedDownloader() {} + +FeedDownloader.prototype = +{ + classID: Components.ID("{5c124537-adca-4456-b2b5-641ab687d1f6}"), + _xpcom_factory: + { + createInstance: function (aOuter, aIID) + { + if (aOuter != null) + throw Cr.NS_ERROR_NO_AGGREGATION; + if (!aIID.equals(Ci.nsINewsBlogFeedDownloader) && + !aIID.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_INVALID_ARG; + + // return the singleton + return nsNewsBlogFeedDownloader.QueryInterface(aIID); + } + } // factory +}; // feed downloader + +function AcctMgrExtension() {} + +AcctMgrExtension.prototype = +{ + classID: Components.ID("{E109C05F-D304-4ca5-8C44-6DE1BFAF1F74}"), + _xpcom_factory: + { + createInstance: function (aOuter, aIID) + { + if (aOuter != null) + throw Cr.NS_ERROR_NO_AGGREGATION; + if (!aIID.equals(Ci.nsIMsgAccountManagerExtension) && + !aIID.equals(Ci.nsISupports)) + throw Cr.NS_ERROR_INVALID_ARG; + + // return the singleton + return nsNewsBlogAcctMgrExtension.QueryInterface(aIID); + } + } // factory +}; // account manager extension + +var components = [FeedDownloader, AcctMgrExtension]; +var NSGetFactory = XPCOMUtils.generateNSGetFactory(components); diff --git a/mailnews/extensions/newsblog/js/newsblog.manifest b/mailnews/extensions/newsblog/js/newsblog.manifest new file mode 100644 index 000000000..5a24df5e7 --- /dev/null +++ b/mailnews/extensions/newsblog/js/newsblog.manifest @@ -0,0 +1,5 @@ +component {5c124537-adca-4456-b2b5-641ab687d1f6} newsblog.js +contract @mozilla.org/newsblog-feed-downloader;1 {5c124537-adca-4456-b2b5-641ab687d1f6} +component {E109C05F-D304-4ca5-8C44-6DE1BFAF1F74} newsblog.js +contract @mozilla.org/accountmanager/extension;1?name=newsblog {E109C05F-D304-4ca5-8C44-6DE1BFAF1F74} +category mailnews-accountmanager-extensions newsblog @mozilla.org/accountmanager/extension;1?name=newsblog diff --git a/mailnews/extensions/newsblog/moz.build b/mailnews/extensions/newsblog/moz.build new file mode 100644 index 000000000..367f60574 --- /dev/null +++ b/mailnews/extensions/newsblog/moz.build @@ -0,0 +1,18 @@ +# 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_COMPONENTS += [ + 'js/newsblog.js', + 'js/newsblog.manifest', +] + +EXTRA_JS_MODULES += [ + 'content/FeedUtils.jsm', +] +JAR_MANIFESTS += ['jar.mn'] + +FINAL_TARGET_FILES.isp += [ + 'rss.rdf', +] diff --git a/mailnews/extensions/newsblog/rss.rdf b/mailnews/extensions/newsblog/rss.rdf new file mode 100644 index 000000000..c7223c01b --- /dev/null +++ b/mailnews/extensions/newsblog/rss.rdf @@ -0,0 +1,43 @@ +<?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/. --> + +<!DOCTYPE RDF SYSTEM "chrome://messenger-newsblog/locale/am-newsblog.dtd"> +<RDF:RDF + xmlns:NC="http://home.netscape.com/NC-rdf#" + xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <RDF:Description about="NC:ispinfo"> + <NC:providers> + <NC:nsIMsgAccount about="newsblog"> + + <!-- server info --> + <NC:incomingServer> + <NC:nsIMsgIncomingServer> + <NC:hostName>Feeds</NC:hostName> + <NC:type>rss</NC:type> + <NC:biffMinutes>100</NC:biffMinutes> + <NC:username>nobody</NC:username> + </NC:nsIMsgIncomingServer> + </NC:incomingServer> + + <!-- identity defaults --> + <NC:identity> + <NC:nsIMsgIdentity> + </NC:nsIMsgIdentity> + </NC:identity> + + <NC:wizardAutoGenerateUniqueHostname>true</NC:wizardAutoGenerateUniqueHostname> + <NC:wizardHideIncoming>true</NC:wizardHideIncoming> + <NC:wizardAccountName>&feeds.accountName;</NC:wizardAccountName> + <NC:wizardSkipPanels>identitypage,incomingpage,outgoingpage</NC:wizardSkipPanels> + <NC:wizardShortName>&feeds.wizardShortName;</NC:wizardShortName> + <NC:wizardLongName>&feeds.wizardLongName;</NC:wizardLongName> + <NC:wizardLongNameAccesskey>&feeds.wizardLongName.accesskey;</NC:wizardLongNameAccesskey> + <NC:wizardShow>true</NC:wizardShow> + <NC:emailProviderName>RSS</NC:emailProviderName> + <NC:showServerDetailsOnWizardSummary>false</NC:showServerDetailsOnWizardSummary> + </NC:nsIMsgAccount> + </NC:providers> + </RDF:Description> +</RDF:RDF> |