diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mailnews/extensions/newsblog/feed-subscriptions.js | 3120 |
1 files changed, 3120 insertions, 0 deletions
diff --git a/comm/mailnews/extensions/newsblog/feed-subscriptions.js b/comm/mailnews/extensions/newsblog/feed-subscriptions.js new file mode 100644 index 0000000000..43ce61b11b --- /dev/null +++ b/comm/mailnews/extensions/newsblog/feed-subscriptions.js @@ -0,0 +1,3120 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * @file + * GUI-side code for managing folder subscriptions. + */ + +var { Feed } = ChromeUtils.import("resource:///modules/Feed.jsm"); +var { FeedUtils } = ChromeUtils.import("resource:///modules/FeedUtils.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); + +var FeedSubscriptions = { + get mMainWin() { + return Services.wm.getMostRecentWindow("mail:3pane"); + }, + + get mTree() { + return document.getElementById("rssSubscriptionsList"); + }, + + mFeedContainers: [], + mRSSServer: null, + mActionMode: null, + kSubscribeMode: 1, + kUpdateMode: 2, + kMoveMode: 3, + kCopyMode: 4, + kImportingOPML: 5, + kVerifyUrlMode: 6, + + get FOLDER_ACTIONS() { + return ( + Ci.nsIMsgFolderNotificationService.folderAdded | + Ci.nsIMsgFolderNotificationService.folderDeleted | + Ci.nsIMsgFolderNotificationService.folderRenamed | + Ci.nsIMsgFolderNotificationService.folderMoveCopyCompleted + ); + }, + + onLoad() { + // Extract the folder argument. + let folder; + if (window.arguments && window.arguments[0].folder) { + folder = window.arguments[0].folder; + } + + // Ensure dialog is fully loaded before selecting, to get visible row. + setTimeout(() => { + FeedSubscriptions.refreshSubscriptionView(folder); + }, 100); + let message = FeedUtils.strings.GetStringFromName("subscribe-loading"); + this.updateStatusItem("statusText", message); + + FeedUtils.CANCEL_REQUESTED = false; + + if (this.mMainWin) { + MailServices.mfn.addListener(this.FolderListener, this.FOLDER_ACTIONS); + } + }, + + onDialogAccept() { + let dismissDialog = true; + + // If we are in the middle of subscribing to a feed, inform the user that + // dismissing the dialog right now will abort the feed subscription. + if (this.mActionMode == this.kSubscribeMode) { + let pTitle = FeedUtils.strings.GetStringFromName( + "subscribe-cancelSubscriptionTitle" + ); + let pMessage = FeedUtils.strings.GetStringFromName( + "subscribe-cancelSubscription" + ); + dismissDialog = !Services.prompt.confirmEx( + window, + pTitle, + pMessage, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ); + } + + if (dismissDialog) { + FeedUtils.CANCEL_REQUESTED = this.mActionMode == this.kSubscribeMode; + if (this.mMainWin) { + MailServices.mfn.removeListener( + this.FolderListener, + this.FOLDER_ACTIONS + ); + } + } + + return dismissDialog; + }, + + refreshSubscriptionView(aSelectFolder, aSelectFeedUrl) { + let item = this.mView.currentItem; + this.loadSubscriptions(); + this.mTree.view = this.mView; + + if (aSelectFolder && !aSelectFeedUrl) { + this.selectFolder(aSelectFolder); + } else if (item) { + // If no folder to select, try to select the pre rebuild selection, in + // an existing window. For folderpane changes in a feed account. + let rootFolder = item.container + ? item.folder.rootFolder + : item.parentFolder.rootFolder; + if (item.container) { + if (!this.selectFolder(item.folder, { open: item.open })) { + // The item no longer exists, an ancestor folder was deleted or + // renamed/moved. + this.selectFolder(rootFolder); + } + } else { + let url = + item.parentFolder == aSelectFolder ? aSelectFeedUrl : item.url; + this.selectFeed({ folder: rootFolder, url }, null); + } + } + + this.mView.tree.ensureRowIsVisible(this.mView.selection.currentIndex); + this.clearStatusInfo(); + }, + + mView: { + kRowIndexUndefined: -1, + + get currentItem() { + // Get the current selection, if any. + let seln = this.selection; + let currentSelectionIndex = seln ? seln.currentIndex : null; + let item; + if (currentSelectionIndex != null) { + item = this.getItemAtIndex(currentSelectionIndex); + } + + return item; + }, + + /* nsITreeView */ + /* eslint-disable no-multi-spaces */ + tree: null, + + mRowCount: 0, + get rowCount() { + return this.mRowCount; + }, + + _selection: null, + get selection() { + return this._selection; + }, + set selection(val) { + this._selection = val; + }, + + setTree(aTree) { + this.tree = aTree; + }, + isSeparator(aRow) { + return false; + }, + isSorted() { + return false; + }, + isEditable(aRow, aColumn) { + return false; + }, + + getProgressMode(aRow, aCol) {}, + cycleHeader(aCol) {}, + cycleCell(aRow, aCol) {}, + selectionChanged() {}, + getRowProperties(aRow) { + return ""; + }, + getColumnProperties(aCol) { + return ""; + }, + getCellValue(aRow, aColumn) {}, + setCellValue(aRow, aColumn, aValue) {}, + setCellText(aRow, aColumn, aValue) {}, + /* eslint-enable no-multi-spaces */ + + getCellProperties(aRow, aColumn) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return ""; + } + + if (AppConstants.MOZ_APP_NAME != "thunderbird") { + if (!item.folder) { + return "serverType-rss"; + } else if (item.folder.isServer) { + return "serverType-rss isServer-true"; + } + + return "livemark"; + } + + let folder = item.folder; + let properties = "folderNameCol"; + let mainWin = FeedSubscriptions.mMainWin; + if (!mainWin) { + let hasFeeds = FeedUtils.getFeedUrlsInFolder(folder); + if (!folder) { + properties += " isFeed-true"; + } else if (hasFeeds) { + properties += " isFeedFolder-true"; + } else if (folder.isServer) { + properties += " serverType-rss isServer-true"; + } + } else { + let url = folder ? null : item.url; + folder = folder || item.parentFolder; + properties = mainWin.FolderUtils.getFolderProperties(folder, item.open); + properties += mainWin.FeedUtils.getFolderProperties(folder, url); + if ( + this.selection.currentIndex == aRow && + url && + item.options.updates.enabled && + properties.includes("isPaused") + ) { + item.options.updates.enabled = false; + FeedSubscriptions.updateFeedData(item); + } + } + + item.properties = properties; + return properties; + }, + + isContainer(aRow) { + let item = this.getItemAtIndex(aRow); + return item ? item.container : false; + }, + + isContainerOpen(aRow) { + let item = this.getItemAtIndex(aRow); + return item ? item.open : false; + }, + + isContainerEmpty(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return false; + } + + return item.children.length == 0; + }, + + getItemAtIndex(aRow) { + if (aRow < 0 || aRow >= FeedSubscriptions.mFeedContainers.length) { + return null; + } + + return FeedSubscriptions.mFeedContainers[aRow]; + }, + + getItemInViewIndex(aFolder) { + if (!aFolder || !(aFolder instanceof Ci.nsIMsgFolder)) { + return null; + } + + for (let index = 0; index < this.rowCount; index++) { + // Find the visible folder in the view. + let item = this.getItemAtIndex(index); + if (item && item.container && item.url == aFolder.URI) { + return index; + } + } + + return null; + }, + + removeItemAtIndex(aRow, aNoSelect) { + let itemToRemove = this.getItemAtIndex(aRow); + if (!itemToRemove) { + return; + } + + if (itemToRemove.container && itemToRemove.open) { + // Close it, if open container. + this.toggleOpenState(aRow); + } + + let parentIndex = this.getParentIndex(aRow); + let hasNextSibling = this.hasNextSibling(aRow, aRow); + if (parentIndex != this.kRowIndexUndefined) { + let parent = this.getItemAtIndex(parentIndex); + if (parent) { + for (let index = 0; index < parent.children.length; index++) { + if (parent.children[index] == itemToRemove) { + parent.children.splice(index, 1); + break; + } + } + } + } + + // Now remove it from our view. + FeedSubscriptions.mFeedContainers.splice(aRow, 1); + + // Now invalidate the correct tree rows. + this.mRowCount--; + this.tree.rowCountChanged(aRow, -1); + + // Now update the selection position, unless noSelect (selection is + // done later or not at all). If the item is the last child, select the + // parent. Otherwise select the next sibling. + if (!aNoSelect) { + if (aRow <= FeedSubscriptions.mFeedContainers.length) { + this.selection.select(hasNextSibling ? aRow : aRow - 1); + } else { + this.selection.clearSelection(); + } + } + + // Now refocus the tree. + FeedSubscriptions.mTree.focus(); + }, + + getCellText(aRow, aColumn) { + let item = this.getItemAtIndex(aRow); + return item && aColumn.id == "folderNameCol" ? item.name : ""; + }, + + getImageSrc(aRow, aCol) { + let item = this.getItemAtIndex(aRow); + if ((item.folder && item.folder.isServer) || item.open) { + return ""; + } + + if ( + !item.open && + (item.properties.includes("hasError") || + item.properties.includes("isBusy")) + ) { + return ""; + } + + if (item.favicon != null) { + return item.favicon; + } + + let callback = iconUrl => { + item.favicon = iconUrl; + if (item.folder) { + for (let child of item.children) { + if (!child.container) { + child.favicon = iconUrl; + break; + } + } + } + + this.selection.tree.invalidateRow(aRow); + }; + + // A closed non server folder. + if (item.folder) { + for (let child of item.children) { + if (!child.container) { + if (child.favicon != null) { + return child.favicon; + } + + setTimeout(async () => { + let iconUrl = await FeedUtils.getFavicon( + child.parentFolder, + child.url + ); + if (iconUrl) { + callback(iconUrl); + } + }, 0); + break; + } + } + } else { + // A feed. + setTimeout(async () => { + let iconUrl = await FeedUtils.getFavicon(item.parentFolder, item.url); + if (iconUrl) { + callback(iconUrl); + } + }, 0); + } + + // Store empty string to return default while favicons are retrieved. + return (item.favicon = ""); + }, + + canDrop(aRow, aOrientation) { + let dropResult = this.extractDragData(aRow); + return ( + aOrientation == Ci.nsITreeView.DROP_ON && + dropResult.canDrop && + (dropResult.dropUrl || + dropResult.dropOnIndex != this.kRowIndexUndefined) + ); + }, + + drop(aRow, aOrientation) { + let win = FeedSubscriptions; + let results = this.extractDragData(aRow); + if (!results.canDrop) { + return; + } + + // Preselect the drop folder. + this.selection.select(aRow); + + if (results.dropUrl) { + // Don't freeze the app that initiated the drop just because we are + // in a loop waiting for the user to dimisss the add feed dialog. + setTimeout(() => { + win.addFeed(results.dropUrl, null, true, null, win.kSubscribeMode); + }, 0); + + let folderItem = this.getItemAtIndex(aRow); + FeedUtils.log.debug( + "drop: folder, url - " + + folderItem.folder.name + + ", " + + results.dropUrl + ); + } else if (results.dropOnIndex != this.kRowIndexUndefined) { + win.moveCopyFeed(results.dropOnIndex, aRow, results.dropEffect); + } + }, + + // Helper function for drag and drop. + extractDragData(aRow) { + let dt = this._currentDataTransfer; + let dragDataResults = { + canDrop: false, + dropUrl: null, + dropOnIndex: this.kRowIndexUndefined, + dropEffect: dt.dropEffect, + }; + + if (dt.getData("text/x-moz-feed-index")) { + // Dragging a feed in the tree. + if (this.selection) { + dragDataResults.dropOnIndex = this.selection.currentIndex; + + let curItem = this.getItemAtIndex(this.selection.currentIndex); + let newItem = this.getItemAtIndex(aRow); + let curServer = + curItem && curItem.parentFolder + ? curItem.parentFolder.server + : null; + let newServer = + newItem && newItem.folder ? newItem.folder.server : null; + + // No copying within the same account and no moving to the account + // folder in the same account. + if ( + !( + curServer == newServer && + (dragDataResults.dropEffect == "copy" || + newItem.folder == curItem.parentFolder || + newItem.folder.isServer) + ) + ) { + dragDataResults.canDrop = true; + } + } + } else { + // Try to get a feed url. + let validUri = FeedUtils.getFeedUriFromDataTransfer(dt); + + if (validUri) { + dragDataResults.canDrop = true; + dragDataResults.dropUrl = validUri.spec; + } + } + + return dragDataResults; + }, + + getParentIndex(aRow) { + let item = this.getItemAtIndex(aRow); + + if (item) { + for (let index = aRow; index >= 0; index--) { + if (FeedSubscriptions.mFeedContainers[index].level < item.level) { + return index; + } + } + } + + return this.kRowIndexUndefined; + }, + + isIndexChildOfParentIndex(aRow, aChildRow) { + // For visible tree rows, not if items are children of closed folders. + let item = this.getItemAtIndex(aRow); + if (!item || aChildRow <= aRow) { + return false; + } + + let targetLevel = this.getItemAtIndex(aRow).level; + let rows = FeedSubscriptions.mFeedContainers; + + for (let i = aRow + 1; i < rows.length; i++) { + if (this.getItemAtIndex(i).level <= targetLevel) { + break; + } + if (aChildRow == i) { + return true; + } + } + + return false; + }, + + hasNextSibling(aRow, aAfterIndex) { + let targetLevel = this.getItemAtIndex(aRow).level; + let rows = FeedSubscriptions.mFeedContainers; + for (let i = aAfterIndex + 1; i < rows.length; i++) { + if (this.getItemAtIndex(i).level == targetLevel) { + return true; + } + if (this.getItemAtIndex(i).level < targetLevel) { + return false; + } + } + + return false; + }, + + hasPreviousSibling(aRow) { + let item = this.getItemAtIndex(aRow); + if (item && aRow) { + return this.getItemAtIndex(aRow - 1).level == item.level; + } + + return false; + }, + + getLevel(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return 0; + } + + return item.level; + }, + + toggleOpenState(aRow) { + let item = this.getItemAtIndex(aRow); + if (!item) { + return; + } + + // Save off the current selection item. + let seln = this.selection; + let currentSelectionIndex = seln.currentIndex; + + let rowsChanged = this.toggle(aRow); + + // Now restore selection, ensuring selection is maintained on toggles. + if (currentSelectionIndex > aRow) { + seln.currentIndex = currentSelectionIndex + rowsChanged; + } else { + seln.select(currentSelectionIndex); + } + + seln.selectEventsSuppressed = false; + }, + + toggle(aRow) { + // Collapse the row, or build sub rows based on open states in the map. + let item = this.getItemAtIndex(aRow); + if (!item) { + return null; + } + + let rows = FeedSubscriptions.mFeedContainers; + let rowCount = 0; + let multiplier; + + function addDescendants(aItem) { + for (let i = 0; i < aItem.children.length; i++) { + rowCount++; + let child = aItem.children[i]; + rows.splice(aRow + rowCount, 0, child); + if (child.open) { + addDescendants(child); + } + } + } + + if (item.open) { + // Close the container. Add up all subfolders and their descendants + // who may be open. + multiplier = -1; + let nextRow = aRow + 1; + let nextItem = rows[nextRow]; + while (nextItem && nextItem.level > item.level) { + rowCount++; + nextItem = rows[++nextRow]; + } + + rows.splice(aRow + 1, rowCount); + } else { + // Open the container. Restore the open state of all subfolder and + // their descendants. + multiplier = 1; + addDescendants(item); + } + + let delta = multiplier * rowCount; + this.mRowCount += delta; + + item.open = !item.open; + // Suppress the select event caused by rowCountChanged. + this.selection.selectEventsSuppressed = true; + // Add or remove the children from our view. + this.tree.rowCountChanged(aRow, delta); + return delta; + }, + }, + + makeFolderObject(aFolder, aCurrentLevel) { + let defaultQuickMode = aFolder.server.getBoolValue("quickMode"); + let optionsAcct = aFolder.isServer + ? FeedUtils.getOptionsAcct(aFolder.server) + : null; + let open = + !aFolder.isServer && + aFolder.server == this.mRSSServer && + this.mActionMode == this.kImportingOPML; + let folderObject = { + children: [], + folder: aFolder, + name: aFolder.prettyName, + level: aCurrentLevel, + url: aFolder.URI, + quickMode: defaultQuickMode, + options: optionsAcct, + open, + container: true, + favicon: null, + }; + + // If a feed has any sub folders, add them to the list of children. + for (let folder of aFolder.subFolders) { + if ( + folder instanceof Ci.nsIMsgFolder && + !folder.getFlag(Ci.nsMsgFolderFlags.Trash) && + !folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + folderObject.children.push( + this.makeFolderObject(folder, aCurrentLevel + 1) + ); + } + } + + let feeds = this.getFeedsInFolder(aFolder); + for (let feed of feeds) { + // Now add any feed urls for the folder. + folderObject.children.push( + this.makeFeedObject(feed, aFolder, aCurrentLevel + 1) + ); + } + + // Finally, set the folder's quickMode based on the its first feed's + // quickMode, since that is how the view determines summary mode, and now + // quickMode is updated to be the same for all feeds in a folder. + if (feeds && feeds[0]) { + folderObject.quickMode = feeds[0].quickMode; + } + + folderObject.children = this.folderItemSorter(folderObject.children); + + return folderObject; + }, + + folderItemSorter(aArray) { + return aArray + .sort(function (a, b) { + return a.name.toLowerCase() > b.name.toLowerCase(); + }) + .sort(function (a, b) { + return a.container < b.container; + }); + }, + + getFeedsInFolder(aFolder) { + let feeds = []; + let feedUrlArray = FeedUtils.getFeedUrlsInFolder(aFolder); + if (!feedUrlArray) { + // No feedUrls in this folder. + return feeds; + } + + for (let url of feedUrlArray) { + let feed = new Feed(url, aFolder); + feeds.push(feed); + } + + return feeds; + }, + + makeFeedObject(aFeed, aFolder, aLevel) { + // Look inside the data source for the feed properties. + let feed = { + children: [], + parentFolder: aFolder, + name: aFeed.title || aFeed.description || aFeed.url, + url: aFeed.url, + quickMode: aFeed.quickMode, + options: aFeed.options || FeedUtils.optionsTemplate, + level: aLevel, + open: false, + container: false, + favicon: null, + }; + return feed; + }, + + loadSubscriptions() { + // Put together an array of folders. Each feed account level folder is + // included as the root. + let numFolders = 0; + let feedContainers = []; + // Get all the feed account folders. + let feedRootFolders = FeedUtils.getAllRssServerRootFolders(); + + feedRootFolders.forEach(function (rootFolder) { + feedContainers.push(this.makeFolderObject(rootFolder, 0)); + numFolders++; + }, this); + + this.mFeedContainers = feedContainers; + this.mView.mRowCount = numFolders; + + FeedSubscriptions.mTree.focus(); + }, + + /** + * Find the folder in the tree. The search may be limited to subfolders of + * a known folder, or expanded to include the entire tree. This function is + * also used to insert/remove folders without rebuilding the tree view cache + * (to avoid position/toggle state loss). + * + * @param {nsIMsgFolder} aFolder - The folder to find. + * @param {object} aParms - The params object, containing: + * + * {Integer} parentIndex - index of folder to start the search; if + * null (default), the index of the folder's + * rootFolder will be used. + * {boolean} select - if true (default) the folder's ancestors + * will be opened and the folder selected. + * {boolean} open - if true (default) the folder is opened. + * {boolean} remove - delete the item from tree row cache if true, + * false (default) otherwise. + * {nsIMsgFolder} newFolder - if not null (default) the new folder, + * for add or rename. + * + * @returns {Boolean} found - true if found, false if not. + */ + selectFolder(aFolder, aParms) { + let folderURI = aFolder.URI; + let parentIndex = + aParms && "parentIndex" in aParms ? aParms.parentIndex : null; + let selectIt = aParms && "select" in aParms ? aParms.select : true; + let openIt = aParms && "open" in aParms ? aParms.open : true; + let removeIt = aParms && "remove" in aParms ? aParms.remove : false; + let newFolder = aParms && "newFolder" in aParms ? aParms.newFolder : null; + let startIndex, startItem; + let found = false; + + let firstVisRow, curFirstVisRow, curLastVisRow; + if (this.mView.tree) { + firstVisRow = this.mView.tree.getFirstVisibleRow(); + } + + if (parentIndex != null) { + // Use the parentIndex if given. + startIndex = parentIndex; + if (aFolder.isServer) { + // Fake item for account root folder. + startItem = { + name: "AccountRoot", + children: [this.mView.getItemAtIndex(startIndex)], + container: true, + open: false, + url: null, + level: -1, + }; + } else { + startItem = this.mView.getItemAtIndex(startIndex); + } + } else { + // Get the folder's root parent index. + let index = 0; + for (index; index < this.mView.rowCount; index++) { + let item = this.mView.getItemAtIndex(index); + if (item.url == aFolder.server.rootFolder.URI) { + break; + } + } + + startIndex = index; + if (aFolder.isServer) { + // Fake item for account root folder. + startItem = { + name: "AccountRoot", + children: [this.mView.getItemAtIndex(startIndex)], + container: true, + open: false, + url: null, + level: -1, + }; + } else { + startItem = this.mView.getItemAtIndex(startIndex); + } + } + + function containsFolder(aItem) { + // Search for the folder. If it's found, set the open state on all + // ancestor folders. A toggle() rebuilds the view rows to match the map. + if (aItem.url == folderURI) { + return (found = true); + } + + for (let i = 0; i < aItem.children.length; i++) { + if (aItem.children[i].container && containsFolder(aItem.children[i])) { + if (removeIt && aItem.children[i].url == folderURI) { + // Get all occurrences in the tree cache arrays. + FeedUtils.log.debug( + "selectFolder: delete in cache, " + + "parent:children:item:index - " + + aItem.name + + ":" + + aItem.children.length + + ":" + + aItem.children[i].name + + ":" + + i + ); + aItem.children.splice(i, 1); + FeedUtils.log.debug( + "selectFolder: deleted in cache, " + + "parent:children - " + + aItem.name + + ":" + + aItem.children.length + ); + removeIt = false; + return true; + } + if (newFolder) { + let newItem = FeedSubscriptions.makeFolderObject( + newFolder, + aItem.level + 1 + ); + newItem.open = aItem.children[i].open; + if (newFolder.isServer) { + FeedSubscriptions.mFeedContainers[startIndex] = newItem; + } else { + aItem.children[i] = newItem; + aItem.children = FeedSubscriptions.folderItemSorter( + aItem.children + ); + } + FeedUtils.log.trace( + "selectFolder: parentName:newFolderName:newFolderItem - " + + aItem.name + + ":" + + newItem.name + + ":" + + newItem.toSource() + ); + newFolder = null; + return true; + } + if (!found) { + // For the folder to find. + found = true; + aItem.children[i].open = openIt; + } else if (selectIt || openIt) { + // For ancestor folders. + aItem.children[i].open = true; + } + + return true; + } + } + + return false; + } + + if (startItem) { + // Find a folder with a specific parent. + containsFolder(startItem); + if (!found) { + return false; + } + + if (!selectIt) { + return true; + } + + if (startItem.open) { + this.mView.toggle(startIndex); + } + + this.mView.toggleOpenState(startIndex); + } + + for (let index = 0; index < this.mView.rowCount && selectIt; index++) { + // The desired folder is now in the view. + let item = this.mView.getItemAtIndex(index); + if (!item.container) { + continue; + } + + if (item.url == folderURI) { + if ( + item.children.length && + ((!item.open && openIt) || (item.open && !openIt)) + ) { + this.mView.toggleOpenState(index); + } + + this.mView.selection.select(index); + found = true; + break; + } + } + + // Ensure tree position does not jump unnecessarily. + curFirstVisRow = this.mView.tree.getFirstVisibleRow(); + curLastVisRow = this.mView.tree.getLastVisibleRow(); + if ( + firstVisRow >= 0 && + this.mView.rowCount - curLastVisRow > firstVisRow - curFirstVisRow + ) { + this.mView.tree.scrollToRow(firstVisRow); + } else { + this.mView.tree.ensureRowIsVisible(this.mView.rowCount - 1); + } + + FeedUtils.log.debug( + "selectFolder: curIndex:firstVisRow:" + + "curFirstVisRow:curLastVisRow:rowCount - " + + this.mView.selection.currentIndex + + ":" + + firstVisRow + + ":" + + curFirstVisRow + + ":" + + curLastVisRow + + ":" + + this.mView.rowCount + ); + return found; + }, + + /** + * Find the feed in the tree. The search first gets the feed's folder, + * then selects the child feed. + * + * @param {Feed} aFeed - The feed to find. + * @param {Integer} aParentIndex - Index to start the folder search. + * + * @returns {Boolean} found - true if found, false if not. + */ + selectFeed(aFeed, aParentIndex) { + let folder = aFeed.folder; + let server = aFeed.server || aFeed.folder.server; + let found = false; + + if (aFeed.folder.isServer) { + // If passed the root folder, the caller wants to get the feed's folder + // from the db (for cases of an ancestor folder rename/move). + let destFolder = FeedUtils.getSubscriptionAttr( + aFeed.url, + server, + "destFolder" + ); + folder = server.rootFolder.getChildWithURI(destFolder, true, false); + } + + if (this.selectFolder(folder, { parentIndex: aParentIndex })) { + let seln = this.mView.selection; + let item = this.mView.currentItem; + if (item) { + for (let i = seln.currentIndex + 1; i < this.mView.rowCount; i++) { + if (this.mView.getItemAtIndex(i).url == aFeed.url) { + this.mView.selection.select(i); + this.mView.tree.ensureRowIsVisible(i); + found = true; + break; + } + } + } + } + + return found; + }, + + updateFeedData(aItem) { + if (!aItem) { + return; + } + + let nameValue = document.getElementById("nameValue"); + let locationValue = document.getElementById("locationValue"); + let locationValidate = document.getElementById("locationValidate"); + let isServer = aItem.folder && aItem.folder.isServer; + let isFolder = aItem.folder && !aItem.folder.isServer; + let isFeed = !aItem.container; + let server, displayFolder; + + if (isFeed) { + // A feed item. Set the feed location and title info. + nameValue.value = aItem.name; + locationValue.value = aItem.url; + locationValidate.removeAttribute("collapsed"); + + // Root the location picker to the news & blogs server. + server = aItem.parentFolder.server; + displayFolder = aItem.parentFolder; + } else { + // A folder/container item. + nameValue.value = ""; + nameValue.disabled = true; + locationValue.value = ""; + locationValidate.setAttribute("collapsed", true); + + server = aItem.folder.server; + displayFolder = aItem.folder; + } + + // Common to both folder and feed items. + nameValue.disabled = aItem.container; + this.setFolderPicker(displayFolder, isFeed); + + // Set quick mode value. + document.getElementById("quickMode").checked = aItem.quickMode; + + if (isServer) { + aItem.options = FeedUtils.getOptionsAcct(server); + } + + // Update items. + let updateEnabled = document.getElementById("updateEnabled"); + let updateValue = document.getElementById("updateValue"); + let biffUnits = document.getElementById("biffUnits"); + let recommendedUnits = document.getElementById("recommendedUnits"); + let recommendedUnitsVal = document.getElementById("recommendedUnitsVal"); + let updates = aItem.options + ? aItem.options.updates + : FeedUtils._optionsDefault.updates; + + updateEnabled.checked = updates.enabled; + updateValue.disabled = !updateEnabled.checked || isFolder; + biffUnits.disabled = !updateEnabled.checked || isFolder; + biffUnits.value = updates.updateUnits; + let minutes = + updates.updateUnits == FeedUtils.kBiffUnitsMinutes + ? updates.updateMinutes + : updates.updateMinutes / (24 * 60); + updateValue.value = Number(minutes); + if (isFeed) { + recommendedUnitsVal.value = this.getUpdateMinutesRec(updates); + } else { + recommendedUnitsVal.value = ""; + } + + let hideRec = recommendedUnitsVal.value == ""; + recommendedUnits.hidden = hideRec; + recommendedUnitsVal.hidden = hideRec; + + // Autotag items. + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + let category = aItem.options ? aItem.options.category : null; + + autotagEnable.checked = category && category.enabled; + autotagUsePrefix.checked = category && category.prefixEnabled; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + autotagPrefix.value = category && category.prefix ? category.prefix : ""; + }, + + setFolderPicker(aFolder, aIsFeed) { + let folderPrettyPath = FeedUtils.getFolderPrettyPath(aFolder); + if (!folderPrettyPath) { + return; + } + + let selectFolder = document.getElementById("selectFolder"); + let selectFolderPopup = document.getElementById("selectFolderPopup"); + let selectFolderValue = document.getElementById("selectFolderValue"); + + selectFolder.setAttribute("hidden", !aIsFeed); + selectFolder._folder = aFolder; + selectFolderValue.toggleAttribute("hidden", aIsFeed); + selectFolderValue.setAttribute("showfilepath", false); + + if (aIsFeed) { + selectFolderPopup._ensureInitialized(); + selectFolderPopup.selectFolder(aFolder); + selectFolder.setAttribute("label", folderPrettyPath); + selectFolder.setAttribute("uri", aFolder.URI); + } else { + selectFolderValue.value = folderPrettyPath; + selectFolderValue.setAttribute("prettypath", folderPrettyPath); + selectFolderValue.setAttribute("filepath", aFolder.filePath.path); + } + }, + + onClickSelectFolderValue(aEvent) { + let target = aEvent.target; + if ( + ("button" in aEvent && + (aEvent.button != 0 || + aEvent.target.localName != "div" || + target.selectionStart != target.selectionEnd)) || + (aEvent.keyCode && aEvent.keyCode != aEvent.DOM_VK_RETURN) + ) { + return; + } + + // Toggle between showing prettyPath and absolute filePath. + if (target.getAttribute("showfilepath") == "true") { + target.setAttribute("showfilepath", false); + target.value = target.getAttribute("prettypath"); + } else { + target.setAttribute("showfilepath", true); + target.value = target.getAttribute("filepath"); + } + }, + + /** + * The user changed the folder for storing the feed. + * + * @param {Event} aEvent - Event. + * @returns {void} + */ + setNewFolder(aEvent) { + aEvent.stopPropagation(); + this.setFolderPicker(aEvent.target._folder, true); + + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container || !item.parentFolder) { + return; + } + + let selectFolder = document.getElementById("selectFolder"); + let editFolderURI = selectFolder.getAttribute("uri"); + if (item.parentFolder.URI == editFolderURI) { + return; + } + + let feed = new Feed(item.url, item.parentFolder); + + // Make sure the new folderpicked folder is visible. + this.selectFolder(selectFolder._folder); + // Now go back to the feed item. + this.selectFeed(feed, null); + // We need to find the index of the new parent folder. + let newParentIndex = this.mView.kRowIndexUndefined; + for (let index = 0; index < this.mView.rowCount; index++) { + let item = this.mView.getItemAtIndex(index); + if (item && item.container && item.url == editFolderURI) { + newParentIndex = index; + break; + } + } + + if (newParentIndex != this.mView.kRowIndexUndefined) { + this.moveCopyFeed(seln.currentIndex, newParentIndex, "move"); + } + }, + + setSummary(aChecked) { + let item = this.mView.currentItem; + if (!item || !item.folder) { + // Not a folder. + return; + } + + if (item.folder.isServer) { + if (document.getElementById("locationValue").value) { + // Intent is to add a feed/folder to the account, so return. + return; + } + + // An account folder. If it changes, all non feed containing subfolders + // need to be updated with the new default. + item.folder.server.setBoolValue("quickMode", aChecked); + this.FolderListener.folderAdded(item.folder); + } else if (!FeedUtils.getFeedUrlsInFolder(item.folder)) { + // Not a folder with feeds. + return; + } else { + let feedsInFolder = this.getFeedsInFolder(item.folder); + // Update the feeds database, for each feed in the folder. + feedsInFolder.forEach(function (feed) { + feed.quickMode = aChecked; + }); + // Update the folder's feeds properties in the tree map. + item.children.forEach(function (feed) { + feed.quickMode = aChecked; + }); + } + + // Update the folder in the tree map. + item.quickMode = aChecked; + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + setPrefs(aNode) { + let item = this.mView.currentItem; + if (!item) { + return; + } + + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + let updateEnabled = document.getElementById("updateEnabled"); + let updateValue = document.getElementById("updateValue"); + let biffUnits = document.getElementById("biffUnits"); + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + if ( + isFolder || + (isServer && document.getElementById("locationValue").value) + ) { + // Intend to subscribe a feed to a folder, a value must be in the url + // field. Update states for addFeed() and return. + updateValue.disabled = !updateEnabled.checked; + biffUnits.disabled = !updateEnabled.checked; + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + return; + } + + switch (aNode.id) { + case "nameValue": + // Check to see if the title value changed, no blank title allowed. + if (!aNode.value) { + aNode.value = item.name; + return; + } + + item.name = aNode.value; + let seln = this.mView.selection; + seln.tree.invalidateRow(seln.currentIndex); + break; + case "locationValue": + let updateFeedButton = document.getElementById("updateFeed"); + // Change label based on whether feed url has beed edited. + updateFeedButton.label = + aNode.value == item.url + ? updateFeedButton.getAttribute("verifylabel") + : updateFeedButton.getAttribute("updatelabel"); + updateFeedButton.setAttribute( + "accesskey", + aNode.value == item.url + ? updateFeedButton.getAttribute("verifyaccesskey") + : updateFeedButton.getAttribute("updateaccesskey") + ); + // Disable the Update button if no feed url value is entered. + updateFeedButton.disabled = !aNode.value; + return; + case "updateEnabled": + case "updateValue": + case "biffUnits": + item.options.updates.enabled = updateEnabled.checked; + let minutes = + biffUnits.value == FeedUtils.kBiffUnitsMinutes + ? updateValue.value + : updateValue.value * 24 * 60; + item.options.updates.updateMinutes = Number(minutes); + item.options.updates.updateUnits = biffUnits.value; + break; + case "autotagEnable": + item.options.category.enabled = aNode.checked; + break; + case "autotagUsePrefix": + item.options.category.prefixEnabled = aNode.checked; + item.options.category.prefix = autotagPrefix.value; + break; + case "autotagPrefix": + item.options.category.prefix = aNode.value; + break; + } + + if (isServer) { + FeedUtils.setOptionsAcct(item.folder.server, item.options); + } else { + let feed = new Feed(item.url, item.parentFolder); + feed.title = item.name; + feed.options = item.options; + + if (aNode.id == "updateEnabled") { + FeedUtils.setStatus( + item.parentFolder, + item.url, + "enabled", + aNode.checked + ); + this.mView.selection.tree.invalidateRow( + this.mView.selection.currentIndex + ); + } + if (aNode.id == "updateValue") { + FeedUtils.setStatus( + item.parentFolder, + item.url, + "updateMinutes", + item.options.updates.updateMinutes + ); + } + } + + this.updateFeedData(item); + let message = FeedUtils.strings.GetStringFromName("subscribe-feedUpdated"); + this.updateStatusItem("statusText", message); + }, + + getUpdateMinutesRec(aUpdates) { + // Assume the parser has stored correct/valid values for the spec. If the + // feed doesn't use any of these tags, updatePeriod will be null. + if (aUpdates.updatePeriod == null) { + return ""; + } + + let biffUnits = document.getElementById("biffUnits").value; + let units = biffUnits == FeedUtils.kBiffUnitsDays ? 1 : 24 * 60; + let frequency = aUpdates.updateFrequency; + let val; + switch (aUpdates.updatePeriod) { + case "hourly": + val = + biffUnits == FeedUtils.kBiffUnitsDays + ? 1 / frequency / 24 + : 60 / frequency; + break; + case "daily": + val = units / frequency; + break; + case "weekly": + val = (7 * units) / frequency; + break; + case "monthly": + val = (30 * units) / frequency; + break; + case "yearly": + val = (365 * units) / frequency; + break; + } + + return val ? Math.round(val * 1000) / 1000 : ""; + }, + + onKeyPress(aEvent) { + if ( + aEvent.keyCode == aEvent.DOM_VK_DELETE && + aEvent.target.id == "rssSubscriptionsList" + ) { + this.removeFeed(true); + } + + this.clearStatusInfo(); + }, + + onSelect() { + let item = this.mView.currentItem; + this.updateFeedData(item); + this.setFocus(); + this.updateButtons(item); + }, + + updateButtons(aSelectedItem) { + let item = aSelectedItem; + let isServer = item && item.folder && item.folder.isServer; + let isFeed = item && !item.container; + document.getElementById("addFeed").hidden = !item || isFeed; + document.getElementById("updateFeed").hidden = !isFeed; + document.getElementById("removeFeed").hidden = !isFeed; + document.getElementById("importOPML").hidden = !isServer; + document.getElementById("exportOPML").hidden = !isServer; + + document.getElementById("importOPML").disabled = document.getElementById( + "exportOPML" + ).disabled = this.mActionMode == this.kImportingOPML; + }, + + onMouseDown(aEvent) { + if ( + aEvent.button != 0 || + aEvent.target.id == "validationText" || + aEvent.target.id == "addCertException" + ) { + return; + } + + this.clearStatusInfo(); + }, + + onFocusChange() { + setTimeout(() => { + this.setFocus(); + }, 0); + }, + + setFocus() { + let item = this.mView.currentItem; + if (!item || this.mActionMode == this.kImportingOPML) { + return; + } + + let locationValue = document.getElementById("locationValue"); + let updateEnabled = document.getElementById("updateEnabled"); + + let quickMode = document.getElementById("quickMode"); + let autotagEnable = document.getElementById("autotagEnable"); + let autotagUsePrefix = document.getElementById("autotagUsePrefix"); + let autotagPrefix = document.getElementById("autotagPrefix"); + + let addFeedButton = document.getElementById("addFeed"); + let updateFeedButton = document.getElementById("updateFeed"); + + let isServer = item.folder && item.folder.isServer; + let isFolder = item.folder && !item.folder.isServer; + + // Enabled by default. + updateEnabled.disabled = + quickMode.disabled = + autotagEnable.disabled = + false; + + updateEnabled.parentNode + .querySelectorAll("input,radio,label") + .forEach(item => { + item.disabled = !updateEnabled.checked; + }); + + autotagUsePrefix.disabled = !autotagEnable.checked; + autotagPrefix.disabled = + autotagUsePrefix.disabled || !autotagUsePrefix.checked; + + let focusedElement = window.document.commandDispatcher.focusedElement; + + if (isServer) { + addFeedButton.disabled = + addFeedButton != focusedElement && + locationValue != document.activeElement && + !locationValue.value; + } else if (isFolder) { + let disable = + locationValue != document.activeElement && !locationValue.value; + // Summary is enabled for a folder with feeds or if adding a feed. + quickMode.disabled = + disable && !FeedUtils.getFeedUrlsInFolder(item.folder); + // All other options disabled unless intent is to add a feed. + updateEnabled.disabled = disable; + updateEnabled.parentNode + .querySelectorAll("input,radio,label") + .forEach(item => { + item.disabled = disable; + }); + + autotagEnable.disabled = disable; + + addFeedButton.disabled = + addFeedButton != focusedElement && + locationValue != document.activeElement && + !locationValue.value; + } else { + // Summary is disabled; applied per folder to apply to all feeds in it. + quickMode.disabled = true; + // Ensure the current feed url is restored if the user did not update. + if ( + locationValue.value != item.url && + locationValue != document.activeElement && + focusedElement != updateFeedButton && + focusedElement.id != "addCertException" + ) { + locationValue.value = item.url; + } + this.setPrefs(locationValue); + // Set button state. + updateFeedButton.disabled = !locationValue.value; + } + }, + + removeFeed(aPrompt) { + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let itemToRemove = this.mView.getItemAtIndex(seln.currentIndex); + + if (!itemToRemove || itemToRemove.container) { + return; + } + + if (aPrompt) { + // Confirm unsubscribe prompt. + let pTitle = FeedUtils.strings.GetStringFromName( + "subscribe-confirmFeedDeletionTitle" + ); + let pMessage = FeedUtils.strings.formatStringFromName( + "subscribe-confirmFeedDeletion", + [itemToRemove.name] + ); + if ( + Services.prompt.confirmEx( + window, + pTitle, + pMessage, + Ci.nsIPromptService.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) + ) { + return; + } + } + + let feed = new Feed(itemToRemove.url, itemToRemove.parentFolder); + FeedUtils.deleteFeed(feed); + + // Now that we have removed the feed from the datasource, it is time to + // update our view layer. Update parent folder's quickMode if necessary + // and remove the child from its parent folder object. + let parentIndex = this.mView.getParentIndex(seln.currentIndex); + let parentItem = this.mView.getItemAtIndex(parentIndex); + this.updateFolderQuickModeInView(itemToRemove, parentItem, true); + this.mView.removeItemAtIndex(seln.currentIndex, false); + let message = FeedUtils.strings.GetStringFromName("subscribe-feedRemoved"); + this.updateStatusItem("statusText", message); + }, + + /** + * This addFeed is used by 1) Add button, 1) Update button, 3) Drop of a + * feed url on a folder (which can be an add or move). If Update, the new + * url is added and the old removed; thus aParse is false and no new messages + * are downloaded, the feed is only validated and stored in the db. If dnd, + * the drop folder is selected and the url is prefilled, so proceed just as + * though the url were entered manually. This allows a user to see the dnd + * url better in case of errors. + * + * @param {String} aFeedLocation - the feed url; get the url from the + * input field if null. + * @param {nsIMsgFolder} aFolder - folder to subscribe, current selected + * folder if null. + * @param {Boolean} aParse - if true (default) parse and download + * the feed's articles. + * @param {Object} aParams - additional params. + * @param {Integer} aMode - action mode (default is kSubscribeMode) + * of the add. + * + * @returns {Boolean} success - true if edit checks passed and an + * async download has been initiated. + */ + addFeed(aFeedLocation, aFolder, aParse, aParams, aMode) { + let message; + let parse = aParse == null ? true : aParse; + let mode = aMode == null ? this.kSubscribeMode : aMode; + let locationValue = document.getElementById("locationValue"); + let quickMode = + aParams && "quickMode" in aParams + ? aParams.quickMode + : document.getElementById("quickMode").checked; + let name = + aParams && "name" in aParams + ? aParams.name + : document.getElementById("nameValue").value; + let options = aParams && "options" in aParams ? aParams.options : null; + + if (aFeedLocation) { + locationValue.value = aFeedLocation; + } + let feedLocation = locationValue.value.trim(); + + if (!feedLocation) { + locationValue.focus(); + message = locationValue.getAttribute("placeholder"); + this.updateStatusItem("statusText", message); + return false; + } + + if (!FeedUtils.isValidScheme(feedLocation)) { + locationValue.focus(); + message = FeedUtils.strings.GetStringFromName("subscribe-feedNotValid"); + this.updateStatusItem("statusText", message); + return false; + } + + let addFolder; + if (aFolder) { + // For Update or if passed a folder. + if (aFolder instanceof Ci.nsIMsgFolder) { + addFolder = aFolder; + } + } else { + // A folder must be selected for Add and Drop. + let index = this.mView.selection.currentIndex; + let item = this.mView.getItemAtIndex(index); + if (item && item.container) { + addFolder = item.folder; + } + } + + // Shouldn't happen. Or else not passed an nsIMsgFolder. + if (!addFolder) { + return false; + } + + // Before we go any further, make sure the user is not already subscribed + // to this feed. + if (FeedUtils.feedAlreadyExists(feedLocation, addFolder.server)) { + locationValue.focus(); + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedAlreadySubscribed" + ); + this.updateStatusItem("statusText", message); + return false; + } + + if (!options) { + // Not passed a param, get values from the ui. + options = FeedUtils.optionsTemplate; + options.updates.enabled = + document.getElementById("updateEnabled").checked; + let biffUnits = document.getElementById("biffUnits").value; + let units = document.getElementById("updateValue").value; + let minutes = + biffUnits == FeedUtils.kBiffUnitsMinutes ? units : units * 24 * 60; + options.updates.updateUnits = biffUnits; + options.updates.updateMinutes = Number(minutes); + options.category.enabled = + document.getElementById("autotagEnable").checked; + options.category.prefixEnabled = + document.getElementById("autotagUsePrefix").checked; + options.category.prefix = document.getElementById("autotagPrefix").value; + } + + let feedProperties = { + feedName: name, + feedLocation, + feedFolder: addFolder, + quickMode, + options, + }; + + let feed = this.storeFeed(feedProperties); + if (!feed) { + return false; + } + + // Now validate and start downloading the feed. + message = FeedUtils.strings.GetStringFromName("subscribe-validating-feed"); + this.updateStatusItem("statusText", message); + this.updateStatusItem("progressMeter", "?"); + document.getElementById("addFeed").disabled = true; + this.mActionMode = mode; + feed.download(parse, this.mFeedDownloadCallback); + return true; + }, + + // Helper routine used by addFeed and importOPMLFile. + storeFeed(feedProperties) { + let feed = new Feed(feedProperties.feedLocation, feedProperties.feedFolder); + feed.title = feedProperties.feedName; + feed.quickMode = feedProperties.quickMode; + feed.options = feedProperties.options; + return feed; + }, + + /** + * When a feed item is selected, the Update button is used to verify the + * existing feed url, or to verify and update the feed url if the field + * has been edited. This is the only use of the Update button. + * + * @returns {void} + */ + updateFeed() { + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container || !item.parentFolder) { + return; + } + + let feed = new Feed(item.url, item.parentFolder); + + // Disable the button. + document.getElementById("updateFeed").disabled = true; + + let feedLocation = document.getElementById("locationValue").value.trim(); + if (feed.url != feedLocation) { + // Updating a url. We need to add the new url and delete the old, to + // ensure everything is cleaned up correctly. + this.addFeed(null, item.parentFolder, false, null, this.kUpdateMode); + return; + } + + // Now we want to verify if the stored feed url still works. If it + // doesn't, show the error. + let message = FeedUtils.strings.GetStringFromName( + "subscribe-validating-feed" + ); + this.mActionMode = this.kVerifyUrlMode; + this.updateStatusItem("statusText", message); + this.updateStatusItem("progressMeter", "?"); + feed.download(false, this.mFeedDownloadCallback); + }, + + /** + * Moves or copies a feed to another folder or account. + * + * @param {Integer} aOldFeedIndex - Index in tree of target feed item. + * @param {Integer} aNewParentIndex - Index in tree of target parent folder item. + * @param {String} aMoveCopy - Either "move" or "copy". + * + * @returns {void} + */ + moveCopyFeed(aOldFeedIndex, aNewParentIndex, aMoveCopy) { + let moveFeed = aMoveCopy == "move"; + let currentItem = this.mView.getItemAtIndex(aOldFeedIndex); + if ( + !currentItem || + this.mView.getParentIndex(aOldFeedIndex) == aNewParentIndex + ) { + // If the new parent is the same as the current parent, then do nothing. + return; + } + + let currentParentIndex = this.mView.getParentIndex(aOldFeedIndex); + let currentParentItem = this.mView.getItemAtIndex(currentParentIndex); + let currentFolder = currentParentItem.folder; + + let newParentItem = this.mView.getItemAtIndex(aNewParentIndex); + let newFolder = newParentItem.folder; + + let accountMoveCopy = false; + if (currentFolder.rootFolder.URI == newFolder.rootFolder.URI) { + // Moving within the same account/feeds db. + if (newFolder.isServer || !moveFeed) { + // No moving to account folder if already in the account; can only move, + // not copy, to folder in the same account. + return; + } + + // Update the destFolder for this feed's subscription. + FeedUtils.setSubscriptionAttr( + currentItem.url, + currentItem.parentFolder.server, + "destFolder", + newFolder.URI + ); + + // Update folderpane favicons. + Services.obs.notifyObservers(currentFolder, "folder-properties-changed"); + Services.obs.notifyObservers(newFolder, "folder-properties-changed"); + } else { + // Moving/copying to a new account. If dropping on the account folder, + // a new subfolder is created if necessary. + accountMoveCopy = true; + let mode = moveFeed ? this.kMoveMode : this.kCopyMode; + let params = { + quickMode: currentItem.quickMode, + name: currentItem.name, + options: currentItem.options, + }; + // Subscribe to the new folder first. If it already exists in the + // account or on error, return. + if (!this.addFeed(currentItem.url, newFolder, false, params, mode)) { + return; + } + // Unsubscribe the feed from the old folder, if add to the new folder + // is successful, and doing a move. + if (moveFeed) { + let feed = new Feed(currentItem.url, currentItem.parentFolder); + FeedUtils.deleteFeed(feed); + } + } + + // Update local favicons. + currentParentItem.favicon = newParentItem.favicon = null; + + // Finally, update our view layer. Update old parent folder's quickMode + // and remove the old row, if move. Otherwise no change to the view. + if (moveFeed) { + this.updateFolderQuickModeInView(currentItem, currentParentItem, true); + this.mView.removeItemAtIndex(aOldFeedIndex, true); + if (aNewParentIndex > aOldFeedIndex) { + aNewParentIndex--; + } + } + + if (accountMoveCopy) { + // If a cross account move/copy, download callback will update the view + // with the new location. Preselect folder/mode for callback. + this.selectFolder(newFolder, { parentIndex: aNewParentIndex }); + return; + } + + // Add the new row location to the view. + currentItem.level = newParentItem.level + 1; + currentItem.parentFolder = newFolder; + this.updateFolderQuickModeInView(currentItem, newParentItem, false); + newParentItem.children.push(currentItem); + + if (newParentItem.open) { + // Close the container, selecting the feed will rebuild the view rows. + this.mView.toggle(aNewParentIndex); + } + + this.selectFeed( + { folder: newParentItem.folder, url: currentItem.url }, + aNewParentIndex + ); + + let message = FeedUtils.strings.GetStringFromName("subscribe-feedMoved"); + this.updateStatusItem("statusText", message); + }, + + updateFolderQuickModeInView(aFeedItem, aParentItem, aRemove) { + let feedItem = aFeedItem; + let parentItem = aParentItem; + let feedUrlArray = FeedUtils.getFeedUrlsInFolder(feedItem.parentFolder); + let feedsInFolder = feedUrlArray ? feedUrlArray.length : 0; + + if (aRemove && feedsInFolder < 1) { + // Removed only feed in folder; set quickMode to server default. + parentItem.quickMode = parentItem.folder.server.getBoolValue("quickMode"); + } + + if (!aRemove) { + // Just added a feed to a folder. If there are already feeds in the + // folder, the feed must reflect the parent's quickMode. If it is the + // only feed, update the parent folder to the feed's quickMode. + if (feedsInFolder > 1) { + let feed = new Feed(feedItem.url, feedItem.parentFolder); + feed.quickMode = parentItem.quickMode; + feedItem.quickMode = parentItem.quickMode; + } else { + parentItem.quickMode = feedItem.quickMode; + } + } + }, + + onDragStart(aEvent) { + // Get the selected feed article (if there is one). + let seln = this.mView.selection; + if (seln.count != 1) { + return; + } + + // Only initiate a drag if the item is a feed (ignore folders/containers). + let item = this.mView.getItemAtIndex(seln.currentIndex); + if (!item || item.container) { + return; + } + + aEvent.dataTransfer.setData("text/x-moz-feed-index", seln.currentIndex); + aEvent.dataTransfer.effectAllowed = "copyMove"; + }, + + onDragOver(aEvent) { + this.mView._currentDataTransfer = aEvent.dataTransfer; + }, + + mFeedDownloadCallback: { + mSubscribeMode: true, + async downloaded(feed, aErrorCode) { + // Offline check is done in the context of 3pane, return to the subscribe + // window once the modal prompt is dispatched. + window.focus(); + // Feed is null if our attempt to parse the feed failed. + let message = ""; + let win = FeedSubscriptions; + if ( + aErrorCode == FeedUtils.kNewsBlogSuccess || + aErrorCode == FeedUtils.kNewsBlogNoNewItems + ) { + win.updateStatusItem("progressMeter", 100); + + if (win.mActionMode == win.kVerifyUrlMode) { + // Just checking for errors, if none bye. The (non error) code + // kNewsBlogNoNewItems can only happen in verify mode. + win.mActionMode = null; + win.clearStatusInfo(); + if (Services.io.offline) { + return; + } + + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedVerified" + ); + win.updateStatusItem("statusText", message); + return; + } + + // Update lastUpdateTime if successful. + let options = feed.options; + options.updates.lastUpdateTime = Date.now(); + feed.options = options; + + // Add the feed to the databases. + FeedUtils.addFeed(feed); + + // Set isBusy status, and clear it after getting favicon. This makes + // sure the folder icon is redrawn to reflect what we got. + FeedUtils.setStatus( + feed.folder, + feed.url, + "code", + FeedUtils.kNewsBlogFeedIsBusy + ); + await FeedUtils.getFavicon(feed.folder, feed.url); + FeedUtils.setStatus(feed.folder, feed.url, "code", aErrorCode); + + // Now add the feed to our view. If adding, the current selection will + // be a folder; if updating it will be a feed. No need to rebuild the + // entire view, that is too jarring. + let curIndex = win.mView.selection.currentIndex; + let curItem = win.mView.getItemAtIndex(curIndex); + if (curItem) { + let parentIndex, parentItem, newItem, level; + if (curItem.container) { + // Open the container, if it exists. + let folderExists = win.selectFolder(feed.folder, { + parentIndex: curIndex, + }); + if (!folderExists) { + // This means a new folder was created. + parentIndex = curIndex; + parentItem = curItem; + level = curItem.level + 1; + newItem = win.makeFolderObject(feed.folder, level); + } else { + // If a folder happens to exist which matches one that would + // have been created, the feed system reuses it. Get the + // current item again if reusing a previously unselected folder. + curIndex = win.mView.selection.currentIndex; + curItem = win.mView.getItemAtIndex(curIndex); + parentIndex = curIndex; + parentItem = curItem; + level = curItem.level + 1; + newItem = win.makeFeedObject(feed, feed.folder, level); + } + } else { + // Adding a feed. + parentIndex = win.mView.getParentIndex(curIndex); + parentItem = win.mView.getItemAtIndex(parentIndex); + level = curItem.level; + newItem = win.makeFeedObject(feed, feed.folder, level); + } + + if (!newItem.container) { + win.updateFolderQuickModeInView(newItem, parentItem, false); + } + + parentItem.children.push(newItem); + parentItem.children = win.folderItemSorter(parentItem.children); + parentItem.favicon = null; + + if (win.mActionMode == win.kSubscribeMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedAdded" + ); + } + if (win.mActionMode == win.kUpdateMode) { + win.removeFeed(false); + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedUpdated" + ); + } + if (win.mActionMode == win.kMoveMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedMoved" + ); + } + if (win.mActionMode == win.kCopyMode) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedCopied" + ); + } + + win.selectFeed(feed, parentIndex); + } + } else { + // Non success. Remove intermediate traces from the feeds database. + // But only if we're not in verify mode. + if ( + win.mActionMode != win.kVerifyUrlMode && + feed && + feed.url && + feed.server + ) { + FeedUtils.deleteFeed(feed); + } + + if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-feedNotValid" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogRequestFailure) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-networkError" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogFileError) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + } + if (aErrorCode == FeedUtils.kNewsBlogBadCertError) { + let host = Services.io.newURI(feed.url).host; + message = FeedUtils.strings.formatStringFromName( + "newsblog-badCertError", + [host] + ); + } + if (aErrorCode == FeedUtils.kNewsBlogNoAuthError) { + message = FeedUtils.strings.GetStringFromName( + "subscribe-noAuthError" + ); + } + + // Focus the url if verify/update failed. + if ( + win.mActionMode == win.kUpdateMode || + win.mActionMode == win.kVerifyUrlMode + ) { + document.getElementById("locationValue").focus(); + } + } + + win.mActionMode = null; + win.clearStatusInfo(); + let code = feed.url.startsWith("http") ? aErrorCode : null; + win.updateStatusItem("statusText", message, code); + }, + + // This gets called after the RSS parser finishes storing a feed item to + // disk. aCurrentFeedItems is an integer corresponding to how many feed + // items have been downloaded so far. aMaxFeedItems is an integer + // corresponding to the total number of feed items to download. + onFeedItemStored(feed, aCurrentFeedItems, aMaxFeedItems) { + window.focus(); + let message = FeedUtils.strings.formatStringFromName( + "subscribe-gettingFeedItems", + [aCurrentFeedItems, aMaxFeedItems] + ); + FeedSubscriptions.updateStatusItem("statusText", message); + this.onProgress(feed, aCurrentFeedItems, aMaxFeedItems); + }, + + onProgress(feed, aProgress, aProgressMax, aLengthComputable) { + FeedSubscriptions.updateStatusItem( + "progressMeter", + (aProgress * 100) / (aProgressMax || 100) + ); + }, + }, + + // Status routines. + updateStatusItem(aID, aValue, aErrorCode) { + let el = document.getElementById(aID); + if (el.getAttribute("collapsed")) { + el.removeAttribute("collapsed"); + } + if (el.hidden) { + el.hidden = false; + } + + if (aID == "progressMeter") { + if (aValue == "?") { + el.removeAttribute("value"); + } else { + el.value = aValue; + } + } else if (aID == "statusText") { + el.textContent = aValue; + } + + el = document.getElementById("validationText"); + if (aErrorCode == FeedUtils.kNewsBlogInvalidFeed) { + el.removeAttribute("collapsed"); + } else { + el.setAttribute("collapsed", true); + } + + el = document.getElementById("addCertException"); + if (aErrorCode == FeedUtils.kNewsBlogBadCertError) { + el.removeAttribute("collapsed"); + } else { + el.setAttribute("collapsed", true); + } + }, + + clearStatusInfo() { + document.getElementById("statusText").textContent = ""; + document.getElementById("progressMeter").hidden = true; + document.getElementById("validationText").collapsed = true; + document.getElementById("addCertException").collapsed = true; + }, + + checkValidation(aEvent) { + if (aEvent.button != 0) { + return; + } + + let validationQuery = "http://validator.w3.org/feed/check.cgi?url="; + + if (this.mMainWin) { + let tabmail = this.mMainWin.document.getElementById("tabmail"); + if (tabmail) { + let feedLocation = document.getElementById("locationValue").value; + let url = validationQuery + encodeURIComponent(feedLocation); + + this.mMainWin.focus(); + this.mMainWin.openContentTab(url); + FeedUtils.log.debug("checkValidation: query url - " + url); + } + } + aEvent.stopPropagation(); + }, + + addCertExceptionDialog() { + let locationValue = document.getElementById("locationValue"); + let feedURL = locationValue.value.trim(); + let params = { + exceptionAdded: false, + location: feedURL, + prefetchCert: true, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + if (params.exceptionAdded) { + this.clearStatusInfo(); + } + + locationValue.focus(); + }, + + // Listener for folder pane changes. + FolderListener: { + get feedWindow() { + let subscriptionsWindow = Services.wm.getMostRecentWindow( + "Mail:News-BlogSubscriptions" + ); + return subscriptionsWindow ? subscriptionsWindow.FeedSubscriptions : null; + }, + + get currentSelectedIndex() { + return this.feedWindow + ? this.feedWindow.mView.selection.currentIndex + : -1; + }, + + get currentSelectedItem() { + return this.feedWindow ? this.feedWindow.mView.currentItem : null; + }, + + folderAdded(aFolder) { + if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) { + return; + } + + let parentFolder = aFolder.isServer ? aFolder : aFolder.parent; + FeedUtils.log.debug( + "folderAdded: folder:parent - " + + aFolder.name + + ":" + + (parentFolder ? parentFolder.filePath.path : "(null)") + ); + + if (!parentFolder || !this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(parentFolder); + let open = indexInView != null; + + if (aFolder.isServer) { + if (indexInView != null) { + // Existing account root folder in the view. + open = feedWindow.mView.getItemAtIndex(indexInView).open; + } else { + // Add the account root folder to the view. + feedWindow.mFeedContainers.push( + feedWindow.makeFolderObject(parentFolder, 0) + ); + feedWindow.mView.mRowCount++; + feedWindow.mTree.view = feedWindow.mView; + feedWindow.mView.tree.scrollToRow(firstVisRow); + return; + } + } + + // Rebuild the added folder's parent item in the tree row cache. + feedWindow.selectFolder(parentFolder, { + select: false, + open, + newFolder: parentFolder, + }); + + if (indexInView == null || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let parentIndex = feedWindow.mView.getParentIndex(indexInView); + if (parentIndex == feedWindow.mView.kRowIndexUndefined) { + // Root folder is its own parent. + parentIndex = indexInView; + } + + if (open) { + // Close an open parent (or root) folder. + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + } + + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open }); + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder, url: curSelItem.url }, + parentIndex + ); + } + }, + + folderDeleted(aFolder) { + if (aFolder.server.type != "rss" || FeedUtils.isInTrash(aFolder)) { + return; + } + + FeedUtils.log.debug("folderDeleted: folder - " + aFolder.name); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let indexInView = feedWindow.mView.getItemInViewIndex(aFolder); + let open = indexInView != null; + + // Delete the folder from the tree row cache. + feedWindow.selectFolder(aFolder, { + select: false, + open: false, + remove: true, + }); + + if (!open || curSelIndex < 0) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + feedWindow.mView.removeItemAtIndex(indexInView, !select); + }, + + folderRenamed(aOrigFolder, aNewFolder) { + if (aNewFolder.server.type != "rss" || FeedUtils.isInTrash(aNewFolder)) { + return; + } + + FeedUtils.log.debug( + "folderRenamed: old:new - " + aOrigFolder.name + ":" + aNewFolder.name + ); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(aOrigFolder); + let open = indexInView != null; + + // Rebuild the renamed folder's item in the tree row cache. + feedWindow.selectFolder(aOrigFolder, { + select: false, + open, + newFolder: aNewFolder, + }); + + if (!open || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + let parentIndex = feedWindow.mView.getParentIndex(indexInView); + if (parentIndex == feedWindow.mView.kRowIndexUndefined) { + // Root folder is its own parent. + parentIndex = indexInView; + } + + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + if (curSelItem.folder == aOrigFolder) { + feedWindow.selectFolder(aNewFolder, { open: curSelItem.open }); + } else if (select) { + feedWindow.mView.selection.select(indexInView); + } else { + feedWindow.selectFolder(curSelItem.folder, { open: curSelItem.open }); + } + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url }, + parentIndex + ); + } + }, + + folderMoveCopyCompleted(aMove, aSrcFolder, aDestFolder) { + if (aDestFolder.server.type != "rss") { + return; + } + + FeedUtils.log.debug( + "folderMoveCopyCompleted: move:src:dest - " + + aMove + + ":" + + aSrcFolder.name + + ":" + + aDestFolder.name + ); + if (!this.feedWindow) { + return; + } + + let feedWindow = this.feedWindow; + let curSelIndex = this.currentSelectedIndex; + let curSelItem = this.currentSelectedItem; + let firstVisRow = feedWindow.mView.tree.getFirstVisibleRow(); + let indexInView = feedWindow.mView.getItemInViewIndex(aSrcFolder); + let destIndexInView = feedWindow.mView.getItemInViewIndex(aDestFolder); + let open = indexInView != null || destIndexInView != null; + let parentIndex = feedWindow.mView.getItemInViewIndex( + aDestFolder.parent || aDestFolder + ); + let select = + indexInView == curSelIndex || + feedWindow.mView.isIndexChildOfParentIndex(indexInView, curSelIndex); + + if (aMove) { + this.folderDeleted(aSrcFolder); + if (aDestFolder.getFlag(Ci.nsMsgFolderFlags.Trash)) { + return; + } + } + + setTimeout(() => { + // State on disk needs to settle before a folder object can be rebuilt. + feedWindow.selectFolder(aDestFolder, { + select: false, + open: open || select, + newFolder: aDestFolder, + }); + + if (!open || !curSelItem) { + // Folder isn't in the tree view, no need to update the view. + return; + } + + feedWindow.mView.toggle(parentIndex); + feedWindow.mView.toggleOpenState(parentIndex); + feedWindow.mView.tree.scrollToRow(firstVisRow); + + if (curSelItem.container) { + if (curSelItem.folder == aSrcFolder || select) { + feedWindow.selectFolder(aDestFolder, { open: true }); + } else { + feedWindow.selectFolder(curSelItem.folder, { + open: curSelItem.open, + }); + } + } else { + feedWindow.selectFeed( + { folder: curSelItem.parentFolder.rootFolder, url: curSelItem.url }, + null + ); + } + }, 50); + }, + }, + + /* *************************************************************** */ + /* OPML Functions */ + /* *************************************************************** */ + + get brandShortName() { + let brandBundle = document.getElementById("bundle_brand"); + return brandBundle ? brandBundle.getString("brandShortName") : ""; + }, + + /** + * Export feeds as opml file Save As filepicker function. + * + * @param {Boolean} aList - If true, exporting as list; if false (default) + * exporting feeds in folder structure - used for title. + * @returns {Promise} nsIFile or null. + */ + opmlPickSaveAsFile(aList) { + let accountName = this.mRSSServer.rootFolder.prettyName; + let fileName = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDefaultFileName", + [this.brandShortName, accountName] + ); + let title = aList + ? FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleList", + [accountName] + ) + : FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportTitleStruct", + [accountName] + ); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + fp.defaultString = fileName; + fp.defaultExtension = "opml"; + if ( + this.opmlLastSaveAsDir && + this.opmlLastSaveAsDir instanceof Ci.nsIFile + ) { + fp.displayDirectory = this.opmlLastSaveAsDir; + } + + let opmlFilterText = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLExportOPMLFilesFilterText" + ); + fp.appendFilter(opmlFilterText, "*.opml"); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.filterIndex = 0; + fp.init(window, title, Ci.nsIFilePicker.modeSave); + + return new Promise(resolve => { + fp.open(rv => { + if ( + (rv != Ci.nsIFilePicker.returnOK && + rv != Ci.nsIFilePicker.returnReplace) || + !fp.file + ) { + resolve(null); + return; + } + + this.opmlLastSaveAsDir = fp.file.parent; + resolve(fp.file); + }); + }); + }, + + /** + * Import feeds opml file Open filepicker function. + * + * @returns {Promise} [{nsIFile} file, {String} fileUrl] or null. + */ + opmlPickOpenFile() { + let title = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportTitle" + ); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + fp.defaultString = ""; + if (this.opmlLastOpenDir && this.opmlLastOpenDir instanceof Ci.nsIFile) { + fp.displayDirectory = this.opmlLastOpenDir; + } + + let opmlFilterText = FeedUtils.strings.GetStringFromName( + "subscribe-OPMLExportOPMLFilesFilterText" + ); + fp.appendFilter(opmlFilterText, "*.opml"); + fp.appendFilters(Ci.nsIFilePicker.filterXML); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.init(window, title, Ci.nsIFilePicker.modeOpen); + + return new Promise(resolve => { + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.file) { + resolve(null); + return; + } + + this.opmlLastOpenDir = fp.file.parent; + resolve([fp.file, fp.fileURL.spec]); + }); + }); + }, + + async exportOPML(aEvent) { + // Account folder must be selected. + let item = this.mView.currentItem; + if (!item || !item.folder || !item.folder.isServer) { + return; + } + + this.mRSSServer = item.folder.server; + let rootFolder = this.mRSSServer.rootFolder; + let exportAsList = aEvent.ctrlKey; + let SPACES2 = " "; + let SPACES4 = " "; + + if (this.mRSSServer.rootFolder.hasSubFolders) { + let opmlDoc = document.implementation.createDocument("", "opml", null); + let opmlRoot = opmlDoc.documentElement; + opmlRoot.setAttribute("version", "1.0"); + opmlRoot.setAttribute("xmlns:fz", "urn:forumzilla:"); + + this.generatePPSpace(opmlRoot, SPACES2); + + // Make the <head> element. + let head = opmlDoc.createElement("head"); + this.generatePPSpace(head, SPACES4); + let titleText = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportFileDialogTitle", + [this.brandShortName, rootFolder.prettyName] + ); + let title = opmlDoc.createElement("title"); + title.appendChild(opmlDoc.createTextNode(titleText)); + head.appendChild(title); + this.generatePPSpace(head, SPACES4); + let dt = opmlDoc.createElement("dateCreated"); + dt.appendChild(opmlDoc.createTextNode(new Date().toUTCString())); + head.appendChild(dt); + this.generatePPSpace(head, SPACES2); + opmlRoot.appendChild(head); + + this.generatePPSpace(opmlRoot, SPACES2); + + // Add <outline>s to the <body>. + let body = opmlDoc.createElement("body"); + if (exportAsList) { + this.generateOutlineList(rootFolder, body, SPACES4.length + 2); + } else { + this.generateOutlineStruct(rootFolder, body, SPACES4.length); + } + + this.generatePPSpace(body, SPACES2); + + if (!body.childElementCount) { + // No folders/feeds. + return; + } + + opmlRoot.appendChild(body); + this.generatePPSpace(opmlRoot, ""); + + // Get file to save from filepicker. + let saveAsFile = await this.opmlPickSaveAsFile(exportAsList); + if (!saveAsFile) { + return; + } + + let fos = FileUtils.openSafeFileOutputStream(saveAsFile); + let serializer = new XMLSerializer(); + serializer.serializeToStream(opmlDoc, fos, "utf-8"); + FileUtils.closeSafeFileOutputStream(fos); + + let statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLExportDone", + [saveAsFile.path] + ); + this.updateStatusItem("statusText", statusReport); + FeedUtils.log.info("exportOPML: " + statusReport); + } + }, + + generatePPSpace(aNode, indentString) { + aNode.appendChild(aNode.ownerDocument.createTextNode("\n")); + aNode.appendChild(aNode.ownerDocument.createTextNode(indentString)); + }, + + generateOutlineList(baseFolder, parent, indentLevel) { + // Pretty printing. + let indentString = " ".repeat(indentLevel - 2); + + let feedOutline; + for (let folder of baseFolder.subFolders) { + FeedUtils.log.debug( + "generateOutlineList: folder - " + folder.filePath.path + ); + if ( + !(folder instanceof Ci.nsIMsgFolder) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + continue; + } + + FeedUtils.log.debug( + "generateOutlineList: CONTINUE folderName - " + folder.name + ); + + if (folder.hasSubFolders) { + FeedUtils.log.debug( + "generateOutlineList: has subfolders - " + folder.name + ); + // Recurse. + this.generateOutlineList(folder, parent, indentLevel); + } + + // Add outline elements with xmlUrls. + let feeds = this.getFeedsInFolder(folder); + for (let feed of feeds) { + FeedUtils.log.debug( + "generateOutlineList: folder has FEED url - " + + folder.name + + " : " + + feed.url + ); + feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument); + this.generatePPSpace(parent, indentString); + parent.appendChild(feedOutline); + } + } + }, + + generateOutlineStruct(baseFolder, parent, indentLevel) { + // Pretty printing. + function indentString(len) { + return " ".repeat(len - 2); + } + + let folderOutline, feedOutline; + for (let folder of baseFolder.subFolders) { + FeedUtils.log.debug( + "generateOutlineStruct: folder - " + folder.filePath.path + ); + if ( + !(folder instanceof Ci.nsIMsgFolder) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + continue; + } + + FeedUtils.log.debug( + "generateOutlineStruct: CONTINUE folderName - " + folder.name + ); + + // Make a folder outline element. + folderOutline = parent.ownerDocument.createElement("outline"); + folderOutline.setAttribute("title", folder.prettyName); + this.generatePPSpace(parent, indentString(indentLevel + 2)); + + if (folder.hasSubFolders) { + FeedUtils.log.debug( + "generateOutlineStruct: has subfolders - " + folder.name + ); + // Recurse. + this.generateOutlineStruct(folder, folderOutline, indentLevel + 2); + } + + let feeds = this.getFeedsInFolder(folder); + for (let feed of feeds) { + // Add feed outline elements with xmlUrls. + FeedUtils.log.debug( + "generateOutlineStruct: folder has FEED url - " + + folder.name + + " : " + + feed.url + ); + feedOutline = this.exportOPMLOutline(feed, parent.ownerDocument); + this.generatePPSpace(folderOutline, indentString(indentLevel + 4)); + folderOutline.appendChild(feedOutline); + } + + parent.appendChild(folderOutline); + } + }, + + exportOPMLOutline(aFeed, aDoc) { + let outRv = aDoc.createElement("outline"); + outRv.setAttribute("type", "rss"); + outRv.setAttribute("title", aFeed.title); + outRv.setAttribute("text", aFeed.title); + outRv.setAttribute("version", "RSS"); + outRv.setAttribute("fz:quickMode", aFeed.quickMode); + outRv.setAttribute("fz:options", JSON.stringify(aFeed.options)); + outRv.setAttribute("xmlUrl", aFeed.url); + outRv.setAttribute("htmlUrl", aFeed.link); + return outRv; + }, + + async importOPML() { + // Account folder must be selected in subscribe dialog. + let item = this.mView ? this.mView.currentItem : null; + if (!item || !item.folder || !item.folder.isServer) { + return; + } + + let server = item.folder.server; + // Get file and file url to open from filepicker. + let [openFile, openFileUrl] = await this.opmlPickOpenFile(); + + this.mActionMode = this.kImportingOPML; + this.updateButtons(null); + this.selectFolder(item.folder, { select: false, open: true }); + let statusReport = FeedUtils.strings.GetStringFromName("subscribe-loading"); + this.updateStatusItem("statusText", statusReport); + // If there were a getElementsByAttribute in html, we could go determined... + this.updateStatusItem("progressMeter", "?"); + + if ( + !(await this.importOPMLFile( + openFile, + openFileUrl, + server, + this.importOPMLFinished + )) + ) { + this.mActionMode = null; + this.updateButtons(item); + this.clearStatusInfo(); + } + }, + + /** + * Import opml file into a feed account. Used by the Subscribe dialog and + * the Import wizard. + * + * @param {nsIFile} aFile - The opml file. + * @param {string} aFileUrl - The opml file url. + * @param {nsIMsgIncomingServer} aServer - The account server. + * @param {Function} aCallback - Callback function. + * + * @returns {Boolean} - false if error. + */ + async importOPMLFile(aFile, aFileUrl, aServer, aCallback) { + if (aServer && aServer instanceof Ci.nsIMsgIncomingServer) { + this.mRSSServer = aServer; + } + + if (!aFile || !aFileUrl || !this.mRSSServer) { + return false; + } + + let opmlDom, statusReport; + FeedUtils.log.debug( + "importOPMLFile: fileName:fileUrl - " + aFile.leafName + ":" + aFileUrl + ); + let request = new Request(aFileUrl); + await fetch(request) + .then(function (response) { + if (!response.ok) { + // If the OPML file is not readable/accessible. + statusReport = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + return null; + } + + return response.text(); + }) + .then(function (responseText) { + if (responseText != null) { + opmlDom = new DOMParser().parseFromString( + responseText, + "application/xml" + ); + if ( + !XMLDocument.isInstance(opmlDom) || + opmlDom.documentElement.namespaceURI == + FeedUtils.MOZ_PARSERERROR_NS || + opmlDom.documentElement.tagName != "opml" || + !( + opmlDom.querySelector("body") && + opmlDom.querySelector("body").childElementCount + ) + ) { + // If the OPML file is invalid or empty. + statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLImportInvalidFile", + [aFile.leafName] + ); + } + } + }) + .catch(function (error) { + statusReport = FeedUtils.strings.GetStringFromName( + "subscribe-errorOpeningFile" + ); + FeedUtils.log.error("importOPMLFile: error - " + error.message); + }); + + if (statusReport) { + FeedUtils.log.error("importOPMLFile: status - " + statusReport); + Services.prompt.alert(window, null, statusReport); + return false; + } + + let body = opmlDom.querySelector("body"); + this.importOPMLOutlines(body, this.mRSSServer, aCallback); + return true; + }, + + importOPMLOutlines(aBody, aRSSServer, aCallback) { + let win = this; + let rssServer = aRSSServer; + let callback = aCallback; + let outline, feedFolder; + let badTag = false; + let firstFeedInFolderQuickMode = null; + let lastFolder; + let feedsAdded = 0; + let rssOutlines = 0; + + function processor(aParentNode, aParentFolder) { + FeedUtils.log.trace( + "importOPMLOutlines: PROCESSOR tag:name:children - " + + aParentNode.tagName + + ":" + + aParentNode.getAttribute("text") + + ":" + + aParentNode.childElementCount + ); + while (true) { + if (aParentNode.tagName == "body" && !aParentNode.childElementCount) { + // Finished. + let statusReport = win.importOPMLStatus(feedsAdded, rssOutlines); + callback(statusReport, lastFolder, win); + return; + } + + outline = aParentNode.firstElementChild; + if (outline.tagName != "outline") { + FeedUtils.log.info( + "importOPMLOutlines: skipping, node is not an " + + "<outline> - <" + + outline.tagName + + ">" + ); + badTag = true; + break; + } + + let outlineName = + outline.getAttribute("text") || + outline.getAttribute("title") || + outline.getAttribute("xmlUrl"); + let feedUrl, folder; + + if (outline.getAttribute("type") == "rss") { + // A feed outline. + feedUrl = + outline.getAttribute("xmlUrl") || outline.getAttribute("url"); + if (!feedUrl) { + FeedUtils.log.info( + "importOPMLOutlines: skipping, type=rss <outline> " + + "has no url - " + + outlineName + ); + break; + } + + rssOutlines++; + feedFolder = aParentFolder; + + if (FeedUtils.feedAlreadyExists(feedUrl, rssServer)) { + FeedUtils.log.info( + "importOPMLOutlines: feed already subscribed in account " + + rssServer.prettyName + + ", url - " + + feedUrl + ); + break; + } + + if ( + aParentNode.tagName == "outline" && + aParentNode.getAttribute("type") != "rss" + ) { + // Parent is a folder, already created. + folder = feedFolder; + } else { + // Parent is not a folder outline, likely the <body> in a flat list. + // Create feed's folder with feed's name and account rootFolder as + // parent of feed's folder. + // NOTE: Assume a type=rss outline must be a leaf and is not a + // direct parent of another type=rss outline; such a structure + // may lead to unintended nesting and inaccurate counts. + folder = rssServer.rootFolder; + } + + // Create the feed. + let quickMode = outline.hasAttribute("fz:quickMode") + ? outline.getAttribute("fz:quickMode") == "true" + : rssServer.getBoolValue("quickMode"); + let options = outline.getAttribute("fz:options"); + options = options ? JSON.parse(options) : null; + + if (firstFeedInFolderQuickMode === null) { + // The summary/web page pref applies to all feeds in a folder, + // though it is a property of an individual feed. This can be + // set (and is obvious) in the subscribe dialog; ensure import + // doesn't leave mismatches if mismatched in the opml file. + firstFeedInFolderQuickMode = quickMode; + } else { + quickMode = firstFeedInFolderQuickMode; + } + + let feedProperties = { + feedName: outlineName, + feedLocation: feedUrl, + feedFolder: folder, + quickMode, + options, + }; + + FeedUtils.log.info( + "importOPMLOutlines: importing feed: name, url - " + + outlineName + + ", " + + feedUrl + ); + + let feed = win.storeFeed(feedProperties); + if (outline.hasAttribute("htmlUrl")) { + feed.link = outline.getAttribute("htmlUrl"); + } + + feed.createFolder(); + if (!feed.folder) { + // Non success. Remove intermediate traces from the feeds database. + if (feed && feed.url && feed.server) { + FeedUtils.deleteFeed(feed); + } + + FeedUtils.log.info( + "importOPMLOutlines: skipping, error creating folder - '" + + feed.folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + badTag = true; + break; + } + + // Add the feed to the databases. + FeedUtils.addFeed(feed); + // Feed correctly added. + feedsAdded++; + lastFolder = feed.folder; + } else { + // A folder outline. If a folder exists in the account structure at + // the same level as in the opml structure, feeds are placed into the + // existing folder. + let folderName = outlineName; + try { + feedFolder = aParentFolder.getChildNamed(folderName); + } catch (ex) { + // Folder not found, create it. + FeedUtils.log.info( + "importOPMLOutlines: creating folder - '" + + folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + firstFeedInFolderQuickMode = null; + try { + feedFolder = aParentFolder + .QueryInterface(Ci.nsIMsgLocalMailFolder) + .createLocalSubfolder(folderName); + } catch (ex) { + // An error creating. Skip it. + FeedUtils.log.info( + "importOPMLOutlines: skipping, error creating folder - '" + + folderName + + "' from outlineName - '" + + outlineName + + "' in parent folder " + + aParentFolder.filePath.path + ); + let xfolder = aParentFolder.getChildNamed(folderName); + aParentFolder.propagateDelete(xfolder, true); + badTag = true; + break; + } + } + } + + break; + } + + if (!outline.childElementCount || badTag) { + // Remove leaf nodes that are processed or bad tags from the opml dom, + // and go back to reparse. This method lets us use setTimeout to + // prevent UI hang, in situations of both deep and shallow trees. + // A yield/generator.next() method is fine for shallow trees, but not + // the true recursion required for deeper trees; both the shallow loop + // and the recurse should give it up. + outline.remove(); + badTag = false; + outline = aBody; + feedFolder = rssServer.rootFolder; + } + + setTimeout(() => { + processor(outline, feedFolder); + }, 0); + } + + processor(aBody, rssServer.rootFolder); + }, + + importOPMLStatus(aFeedsAdded, aRssOutlines, aFolderOutlines) { + let statusReport; + if (aRssOutlines > aFeedsAdded) { + statusReport = FeedUtils.strings.formatStringFromName( + "subscribe-OPMLImportStatus", + [ + PluralForm.get( + aFeedsAdded, + FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportUniqueFeeds" + ) + ).replace("#1", aFeedsAdded), + PluralForm.get( + aRssOutlines, + FeedUtils.strings.GetStringFromName( + "subscribe-OPMLImportFoundFeeds" + ) + ).replace("#1", aRssOutlines), + ], + 2 + ); + } else { + statusReport = PluralForm.get( + aFeedsAdded, + FeedUtils.strings.GetStringFromName("subscribe-OPMLImportFeedCount") + ).replace("#1", aFeedsAdded); + } + + return statusReport; + }, + + importOPMLFinished(aStatusReport, aLastFolder, aWin) { + if (aLastFolder) { + aWin.selectFolder(aLastFolder, { select: false, newFolder: aLastFolder }); + aWin.selectFolder(aLastFolder.parent); + } + aWin.mActionMode = null; + aWin.updateButtons(aWin.mView.currentItem); + aWin.clearStatusInfo(); + aWin.updateStatusItem("statusText", aStatusReport); + }, +}; |