diff options
Diffstat (limited to 'comm/mail/modules/MailMigrator.jsm')
-rw-r--r-- | comm/mail/modules/MailMigrator.jsm | 1200 |
1 files changed, 1200 insertions, 0 deletions
diff --git a/comm/mail/modules/MailMigrator.jsm b/comm/mail/modules/MailMigrator.jsm new file mode 100644 index 0000000000..129ff5e835 --- /dev/null +++ b/comm/mail/modules/MailMigrator.jsm @@ -0,0 +1,1200 @@ +/* -*- 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 module handles migrating mail-specific preferences, etc. Migration has + * traditionally been a part of messenger.js, but separating the code out into + * a module makes unit testing much easier. + */ + +const EXPORTED_SYMBOLS = ["MailMigrator", "MigrationTasks"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + migrateToolbarForSpace: "resource:///modules/ToolbarMigration.sys.mjs", + clearXULToolbarState: "resource:///modules/ToolbarMigration.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + FolderUtils: "resource:///modules/FolderUtils.jsm", + migrateMailnews: "resource:///modules/MailnewsMigrator.jsm", +}); + +var MailMigrator = { + _migrateXULStoreForDocument(fromURL, toURL) { + Array.from(Services.xulStore.getIDsEnumerator(fromURL)).forEach(id => { + Array.from(Services.xulStore.getAttributeEnumerator(fromURL, id)).forEach( + attr => { + let value = Services.xulStore.getValue(fromURL, id, attr); + Services.xulStore.setValue(toURL, id, attr, value); + } + ); + }); + }, + + _migrateXULStoreForElement(url, fromID, toID) { + Array.from(Services.xulStore.getAttributeEnumerator(url, fromID)).forEach( + attr => { + let value = Services.xulStore.getValue(url, fromID, attr); + Services.xulStore.setValue(url, toID, attr, value); + Services.xulStore.removeValue(url, fromID, attr); + } + ); + }, + + /* eslint-disable complexity */ + /** + * Determine if the UI has been upgraded in a way that requires us to reset + * some user configuration. If so, performs the resets. + */ + _migrateUI() { + // The code for this was ported from + // mozilla/browser/components/nsBrowserGlue.js + const UI_VERSION = 40; + const MESSENGER_DOCURL = "chrome://messenger/content/messenger.xhtml"; + const MESSENGERCOMPOSE_DOCURL = + "chrome://messenger/content/messengercompose/messengercompose.xhtml"; + const UI_VERSION_PREF = "mail.ui-rdf.version"; + let currentUIVersion = Services.prefs.getIntPref(UI_VERSION_PREF, 0); + + if (currentUIVersion >= UI_VERSION) { + return; + } + + let xulStore = Services.xulStore; + + let newProfile = currentUIVersion == 0; + if (newProfile) { + // Collapse the main menu by default if the override pref + // "mail.main_menu.collapse_by_default" is set to true. + if (Services.prefs.getBoolPref("mail.main_menu.collapse_by_default")) { + xulStore.setValue( + MESSENGER_DOCURL, + "toolbar-menubar", + "autohide", + "true" + ); + } + + // Set to current version to skip all the migration below. + currentUIVersion = UI_VERSION; + } + + try { + // UI versions below 5 could only exist in an old profile with localstore.rdf + // file used for the XUL store. Since TB55 this file is no longer read. + // Since UI version 5, the xulstore.json file is being used, so we only + // support those versions here, see bug 1371898. + + // In UI version 6, we move the otherActionsButton button to the + // header-view-toolbar. + if (currentUIVersion < 6) { + let cs = xulStore.getValue( + MESSENGER_DOCURL, + "header-view-toolbar", + "currentset" + ); + if (cs && !cs.includes("otherActionsButton")) { + // Put the otherActionsButton button at the end. + cs = cs + ",otherActionsButton"; + xulStore.setValue( + MESSENGER_DOCURL, + "header-view-toolbar", + "currentset", + cs + ); + } + } + + // In UI version 7, the three-state doNotTrack setting was reverted back + // to two-state. This reverts a (no longer supported) setting of "please + // track me" to the default "don't say anything". + if (currentUIVersion < 7) { + try { + if ( + Services.prefs.getBoolPref("privacy.donottrackheader.enabled") && + Services.prefs.getIntPref("privacy.donottrackheader.value") != 1 + ) { + Services.prefs.clearUserPref("privacy.donottrackheader.enabled"); + Services.prefs.clearUserPref("privacy.donottrackheader.value"); + } + } catch (ex) {} + } + + // In UI version 8, we change from boolean browser.display.use_document_colors + // to the tri-state browser.display.document_color_use. + if (currentUIVersion < 8) { + const kOldColorPref = "browser.display.use_document_colors"; + if ( + Services.prefs.prefHasUserValue(kOldColorPref) && + !Services.prefs.getBoolPref(kOldColorPref) + ) { + Services.prefs.setIntPref("browser.display.document_color_use", 2); + } + } + + // This one is needed also in all new profiles. + // Add an expanded entry for All Address Books. + if (currentUIVersion < 10 || newProfile) { + // If the file exists, read its contents, prepend the "All ABs" URI + // and save it, else, just write the "All ABs" URI to the file. + let spec = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "directoryTree.json" + ); + IOUtils.readJSON(spec) + .then(data => { + data.unshift("moz-abdirectory://?"); + IOUtils.writeJSON(spec, data); + }) + .catch(ex => { + if (["NotFoundError"].includes(ex.name)) { + IOUtils.writeJSON(spec, ["moz-abdirectory://?"]); + } else { + console.error(ex); + } + }); + } + + // Several Latin language groups were consolidated into x-western. + if (currentUIVersion < 11) { + let group = null; + try { + group = Services.prefs.getComplexValue( + "font.language.group", + Ci.nsIPrefLocalizedString + ); + } catch (ex) {} + if ( + group && + ["tr", "x-baltic", "x-central-euro"].some(g => g == group.data) + ) { + group.data = "x-western"; + Services.prefs.setComplexValue( + "font.language.group", + Ci.nsIPrefLocalizedString, + group + ); + } + } + + // Untangle starting in Paragraph mode from Enter key preference. + if (currentUIVersion < 13) { + Services.prefs.setBoolPref( + "mail.compose.default_to_paragraph", + Services.prefs.getBoolPref("editor.CR_creates_new_p") + ); + Services.prefs.clearUserPref("editor.CR_creates_new_p"); + } + + // Migrate remote content exceptions for email addresses which are + // encoded as chrome URIs. + if (currentUIVersion < 14) { + let permissionsDB = Services.dirsvc.get("ProfD", Ci.nsIFile); + permissionsDB.append("permissions.sqlite"); + let db = Services.storage.openDatabase(permissionsDB); + + try { + let statement = db.createStatement( + "select origin,permission from moz_perms where " + + // Avoid 'like' here which needs to be escaped. + "substr(origin, 1, 28)='chrome://messenger/content/?';" + ); + try { + while (statement.executeStep()) { + let origin = statement.getUTF8String(0); + let permission = statement.getInt32(1); + Services.perms.removeFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + {} + ), + "image" + ); + origin = origin.replace( + "chrome://messenger/content/?", + "chrome://messenger/content/messenger.xhtml" + ); + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(origin), + {} + ), + "image", + permission + ); + } + } finally { + statement.finalize(); + } + + // Sadly we still need to clear the database manually. Experiments + // showed that the permissions manager deleted only one record. + db.defaultTransactionType = + Ci.mozIStorageConnection.TRANSACTION_EXCLUSIVE; + db.beginTransaction(); + try { + db.executeSimpleSQL( + "delete from moz_perms where " + + "substr(origin, 1, 28)='chrome://messenger/content/?';" + ); + db.commitTransaction(); + } catch (ex) { + db.rollbackTransaction(); + throw ex; + } + } finally { + db.close(); + } + } + + // Changed notification sound behaviour on OS X. + if (currentUIVersion < 15) { + var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + if (AppConstants.platform == "macosx") { + // For people updating from versions < 52 who had "Play system sound" + // selected for notifications. As TB no longer plays system sounds, + // uncheck the pref to match the new behaviour. + const soundPref = "mail.biff.play_sound"; + if ( + Services.prefs.getBoolPref(soundPref) && + Services.prefs.getIntPref(soundPref + ".type") == 0 + ) { + Services.prefs.setBoolPref(soundPref, false); + } + } + } + + if (currentUIVersion < 16) { + // Migrate the old requested locales prefs to use the new model + const SELECTED_LOCALE_PREF = "general.useragent.locale"; + const MATCHOS_LOCALE_PREF = "intl.locale.matchOS"; + + if ( + Services.prefs.prefHasUserValue(MATCHOS_LOCALE_PREF) || + Services.prefs.prefHasUserValue(SELECTED_LOCALE_PREF) + ) { + if (Services.prefs.getBoolPref(MATCHOS_LOCALE_PREF, false)) { + Services.locale.requestedLocales = []; + } else { + let locale = Services.prefs.getComplexValue( + SELECTED_LOCALE_PREF, + Ci.nsIPrefLocalizedString + ); + if (locale) { + try { + Services.locale.requestedLocales = [locale.data]; + } catch (e) { + /* Don't panic if the value is not a valid locale code. */ + } + } + } + Services.prefs.clearUserPref(SELECTED_LOCALE_PREF); + Services.prefs.clearUserPref(MATCHOS_LOCALE_PREF); + } + } + + if (currentUIVersion < 17) { + // Move composition's [Attach |v] button to the right end of Composition + // Toolbar (unless the button was removed by user), so that it is + // right above the attachment pane. + // First, get value of currentset (string of comma-separated button ids). + let cs = xulStore.getValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset" + ); + if (cs && cs.includes("button-attach")) { + // Get array of button ids from currentset string. + let csArray = cs.split(","); + let attachButtonIndex = csArray.indexOf("button-attach"); + // Remove attach button id from current array position. + csArray.splice(attachButtonIndex, 1); + // If the currentset string does not contain a spring which causes + // elements after the spring to be right-aligned, add it now at the + // end of the array. Note: Prior to this UI version, only MAC OS + // defaultset contained a spring; in any case, user might have added + // or removed it via customization. + if (!cs.includes("spring")) { + csArray.push("spring"); + } + // Add attach button id to the end of the array. + csArray.push("button-attach"); + // Join array values back into comma-separated string. + cs = csArray.join(","); + // Apply changes to currentset. + xulStore.setValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset", + cs + ); + } + } + + if (currentUIVersion < 18) { + for (let url of [ + "chrome://calendar/content/calendar-event-dialog-attendees.xul", + "chrome://calendar/content/calendar-event-dialog.xul", + "chrome://messenger/content/addressbook/addressbook.xul", + "chrome://messenger/content/messageWindow.xul", + "chrome://messenger/content/messenger.xul", + "chrome://messenger/content/messengercompose/messengercompose.xul", + ]) { + this._migrateXULStoreForDocument( + url, + url.replace(/\.xul$/, ".xhtml") + ); + } + // See bug 1653168. messagepanebox is the problematic one, but ensure + // messagepaneboxwrapper doesn't cause problems as well. + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "messagepanebox", + "collapsed" + ); + Services.xulStore.removeValue( + "chrome://messenger/content/messenger.xhtml", + "messagepaneboxwrapper", + "collapsed" + ); + + Services.xulStore.removeValue( + "chrome://messenger/content/messageWindow.xhtml", + "messagepanebox", + "collapsed" + ); + Services.xulStore.removeValue( + "chrome://messenger/content/messageWindow.xhtml", + "messagepaneboxwrapper", + "collapsed" + ); + } + + if (currentUIVersion < 19) { + // Clear socks proxy values if they were shared from http, to prevent + // websocket breakage after bug 1577862 (see bug 1606679). + if ( + Services.prefs.getBoolPref( + "network.proxy.share_proxy_settings", + false + ) && + Services.prefs.getIntPref("network.proxy.type", 0) == 1 + ) { + let httpProxy = Services.prefs.getCharPref("network.proxy.http", ""); + let httpPort = Services.prefs.getIntPref( + "network.proxy.http_port", + 0 + ); + let socksProxy = Services.prefs.getCharPref( + "network.proxy.socks", + "" + ); + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 0 + ); + if (httpProxy && httpProxy == socksProxy && httpPort == socksPort) { + Services.prefs.setCharPref( + "network.proxy.socks", + Services.prefs.getCharPref("network.proxy.backup.socks", "") + ); + Services.prefs.setIntPref( + "network.proxy.socks_port", + Services.prefs.getIntPref("network.proxy.backup.socks_port", 0) + ); + } + } + } + + // Clear unused socks proxy backup values - see bug 1625773. + if (currentUIVersion < 20) { + let backup = Services.prefs.getCharPref( + "network.proxy.backup.socks", + "" + ); + let backupPort = Services.prefs.getIntPref( + "network.proxy.backup.socks_port", + 0 + ); + let socksProxy = Services.prefs.getCharPref("network.proxy.socks", ""); + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 0 + ); + if (backup == socksProxy) { + Services.prefs.clearUserPref("network.proxy.backup.socks"); + } + if (backupPort == socksPort) { + Services.prefs.clearUserPref("network.proxy.backup.socks_port"); + } + } + + // Make "bad" msgcompose.font_face value "tt" be "monospace" instead. + if (currentUIVersion < 21) { + if (Services.prefs.getStringPref("msgcompose.font_face") == "tt") { + Services.prefs.setStringPref("msgcompose.font_face", "monospace"); + } + } + + // Migrate Yahoo users to OAuth2, since "normal password" is going away + // on October 20, 2020. + if (currentUIVersion < 22) { + this._migrateIncomingToOAuth2("mail.yahoo.com"); + this._migrateSMTPToOAuth2("mail.yahoo.com"); + } + // ... and same thing for AOL users. + if (currentUIVersion < 23) { + this._migrateIncomingToOAuth2("imap.aol.com"); + this._migrateIncomingToOAuth2("pop.aol.com"); + this._migrateSMTPToOAuth2("smtp.aol.com"); + } + + // Version 24 was used and backed out. + + // Some elements changed ID, move their persisted values to the new ID. + if (currentUIVersion < 25) { + let url = "chrome://messenger/content/messenger.xhtml"; + this._migrateXULStoreForElement(url, "view-deck", "view-box"); + this._migrateXULStoreForElement(url, "displayDeck", "displayBox"); + } + + // Migrate the old Folder Pane modes dropdown. + if (currentUIVersion < 26) { + this._migrateXULStoreForElement( + "chrome://messenger/content/messenger.xhtml", + "folderPane-toolbar", + "folderPaneHeader" + ); + } + + if (currentUIVersion < 27) { + let accountList = MailServices.accounts.accounts.filter( + a => a.incomingServer + ); + accountList.sort(lazy.FolderUtils.compareAccounts); + let accountKeyList = accountList.map(account => account.key); + try { + MailServices.accounts.reorderAccounts(accountKeyList); + } catch (error) { + console.error( + "Migrating account list order failed. Error message was: " + + error + + " -- Will not reattempt migration." + ); + } + } + + // Migrating the preference of the font size in the message compose window + // to use in document.execCommand. + if (currentUIVersion < 28) { + let fontSize = Services.prefs.getCharPref("msgcompose.font_size"); + let newFontSize; + switch (fontSize) { + case "x-small": + newFontSize = "1"; + break; + case "small": + newFontSize = "2"; + break; + case "medium": + newFontSize = "3"; + break; + case "large": + newFontSize = "4"; + break; + case "x-large": + newFontSize = "5"; + break; + case "xx-large": + newFontSize = "6"; + break; + default: + newFontSize = "3"; + } + Services.prefs.setCharPref("msgcompose.font_size", newFontSize); + } + + // Migrate mail.biff.use_new_count_in_mac_dock to + // mail.biff.use_new_count_in_badge. + if (currentUIVersion < 29) { + if ( + Services.prefs.getBoolPref( + "mail.biff.use_new_count_in_mac_dock", + false + ) + ) { + Services.prefs.setBoolPref("mail.biff.use_new_count_in_badge", true); + Services.prefs.clearUserPref("mail.biff.use_new_count_in_mac_dock"); + } + } + + // Clear ui.systemUsesDarkTheme after bug 1736252. + if (currentUIVersion < 30) { + Services.prefs.clearUserPref("ui.systemUsesDarkTheme"); + } + + if (currentUIVersion < 32) { + this._migrateIncomingToOAuth2("imap.gmail.com"); + this._migrateIncomingToOAuth2("pop.gmail.com"); + this._migrateSMTPToOAuth2("smtp.gmail.com"); + } + + if (currentUIVersion < 33) { + // Put button-encryption and button-encryption-options on the + // Composition Toolbar. + // First, get value of currentset (string of comma-separated button ids). + let cs = xulStore.getValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset" + ); + if (cs) { + // Button ids from currentset string. + let buttonIds = cs.split(","); + + // We want to insert the two buttons at index 2 and 3. + buttonIds.splice(2, 0, "button-encryption"); + buttonIds.splice(3, 0, "button-encryption-options"); + + cs = buttonIds.join(","); + // Apply changes to currentset. + xulStore.setValue( + MESSENGERCOMPOSE_DOCURL, + "composeToolbar2", + "currentset", + cs + ); + } + } + + if (currentUIVersion < 34) { + // Migrate from + // + mailnews.sendformat.auto_downgrade - Whether we should + // auto-downgrade to plain text when the message is plain. + // + mail.default_html_action - The default sending format if we didn't + // auto-downgrade. + // to mail.default_send_format + let defaultHTMLAction = Services.prefs.getIntPref( + "mail.default_html_action", + 3 + ); + Services.prefs.clearUserPref("mail.default_html_action"); + let autoDowngrade = Services.prefs.getBoolPref( + "mailnews.sendformat.auto_downgrade", + true + ); + Services.prefs.clearUserPref("mailnews.sendformat.auto_downgrade"); + + let sendFormat; + switch (defaultHTMLAction) { + case 0: + // Was AskUser. Move to the new Auto default. + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + break; + case 1: + // Was PlainText only. Keep as plain text. Note, autoDowngrade has + // no effect on this option. + sendFormat = Ci.nsIMsgCompSendFormat.PlainText; + break; + case 2: + // Was HTML. Keep as HTML if autoDowngrade was false, otherwise use + // the Auto default. + sendFormat = autoDowngrade + ? Ci.nsIMsgCompSendFormat.Auto + : Ci.nsIMsgCompSendFormat.HTML; + break; + case 3: + // Was Both. If autoDowngrade was true, this is the same as the + // new Auto default. Otherwise, keep as Both. + sendFormat = autoDowngrade + ? Ci.nsIMsgCompSendFormat.Auto + : Ci.nsIMsgCompSendFormat.Both; + break; + default: + sendFormat = Ci.nsIMsgCompSendFormat.Auto; + break; + } + Services.prefs.setIntPref("mail.default_send_format", sendFormat); + } + + if (currentUIVersion < 35) { + // Both IMAP and POP settings currently use this domain + this._migrateIncomingToOAuth2("outlook.office365.com"); + this._migrateSMTPToOAuth2("smtp.office365.com"); + } + + if (currentUIVersion < 36) { + lazy.migrateToolbarForSpace("mail"); + } + + if (currentUIVersion < 37) { + if (!Services.prefs.prefHasUserValue("mail.uidensity")) { + Services.prefs.setIntPref("mail.uidensity", 0); + } + } + + if (currentUIVersion < 38) { + lazy.migrateToolbarForSpace("calendar"); + lazy.migrateToolbarForSpace("tasks"); + lazy.migrateToolbarForSpace("chat"); + lazy.migrateToolbarForSpace("settings"); + lazy.migrateToolbarForSpace("addressbook"); + // Clear menubar and tabbar XUL toolbar state. + lazy.clearXULToolbarState("tabbar-toolbar"); + lazy.clearXULToolbarState("toolbar-menubar"); + } + + if (currentUIVersion < 39) { + // Set old defaults for message header customization in existing + // profiles without any customization settings. + if ( + !Services.xulStore.hasValue( + "chrome://messenger/content/messenger.xhtml", + "messageHeader", + "layout" + ) + ) { + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "messageHeader", + "layout", + JSON.stringify({ + showAvatar: false, + showBigAvatar: false, + showFullAddress: false, + hideLabels: false, + subjectLarge: false, + buttonStyle: "default", + }) + ); + } + } + + if (currentUIVersion < 40) { + // Keep the view to table for existing profiles if the user never + // customized the thread pane view. + if ( + !Services.xulStore.hasValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view" + ) + ) { + Services.xulStore.setValue( + "chrome://messenger/content/messenger.xhtml", + "threadPane", + "view", + "table" + ); + } + + // Maintain the default horizontal layout for existing profiles if the + // user never changed it. + if (!Services.prefs.prefHasUserValue("mail.pane_config.dynamic")) { + Services.prefs.setIntPref("mail.pane_config.dynamic", 0); + } + } + + // Migration tasks that may take a long time are not run immediately, but + // added to the MigrationTasks object then run at the end. + // + // See the documentation on MigrationTask and MigrationTasks for how to + // add a task. + MigrationTasks.runTasks(); + + // Update the migration version. + Services.prefs.setIntPref(UI_VERSION_PREF, UI_VERSION); + } catch (e) { + console.error( + "Migrating from UI version " + + currentUIVersion + + " to " + + UI_VERSION + + " failed. Error message was: " + + e + + " -- " + + "Will reattempt on next start." + ); + } + }, + /* eslint-enable complexity */ + + /** + * Migrate incoming server to using OAuth2 as authMethod. + * + * @param {string} hostnameHint - What the hostname should end with. + */ + _migrateIncomingToOAuth2(hostnameHint) { + for (let account of MailServices.accounts.accounts) { + // Skip if not a matching account. + if (!account.incomingServer.hostName.endsWith(hostnameHint)) { + continue; + } + + // Change Incoming server to OAuth2. + account.incomingServer.authMethod = Ci.nsMsgAuthMethod.OAuth2; + } + }, + + /** + * Migrate outgoing server to using OAuth2 as authMethod. + * + * @param {string} hostnameHint - What the hostname should end with. + */ + _migrateSMTPToOAuth2(hostnameHint) { + for (let server of MailServices.smtp.servers) { + // Skip if not a matching server. + if (!server.hostname.endsWith(hostnameHint)) { + continue; + } + + // Change Outgoing SMTP server to OAuth2. + server.authMethod = Ci.nsMsgAuthMethod.OAuth2; + } + }, + + /** + * RSS subscriptions and items used to be stored in .rdf files, but now + * we've changed to use JSON files instead. This migration routine checks + * for the old format files and upgrades them as appropriate. + * The feeds and items migration are handled as separate (hopefully atomic) + * steps. It is careful to not overwrite new-style .json files. + * + * @returns {void} + */ + async _migrateRSS() { + // Find all the RSS IncomingServers. + let rssServers = []; + for (let server of MailServices.accounts.allServers) { + if (server && server.type == "rss") { + rssServers.push(server); + } + } + + // For each one... + for (let server of rssServers) { + await this._migrateRSSServer(server); + } + }, + + async _migrateRSSServer(server) { + let rssServer = server.QueryInterface(Ci.nsIRssIncomingServer); + + // Convert feeds.rdf to feeds.json (if needed). + let feedsFile = rssServer.subscriptionsPath; + let legacyFeedsFile = server.localPath; + legacyFeedsFile.append("feeds.rdf"); + + try { + await this._migrateRSSSubscriptions(legacyFeedsFile, feedsFile); + } catch (err) { + console.error( + "Failed to migrate '" + + feedsFile.path + + "' to '" + + legacyFeedsFile.path + + "': " + + err + ); + } + + // Convert feeditems.rdf to feeditems.json (if needed). + let itemsFile = rssServer.feedItemsPath; + let legacyItemsFile = server.localPath; + legacyItemsFile.append("feeditems.rdf"); + try { + await this._migrateRSSItems(legacyItemsFile, itemsFile); + } catch (err) { + console.error( + "Failed to migrate '" + + itemsFile.path + + "' to '" + + legacyItemsFile.path + + "': " + + err + ); + } + }, + + // Assorted namespace strings required for the feed migrations. + FZ_NS: "urn:forumzilla:", + DC_NS: "http://purl.org/dc/elements/1.1/", + RSS_NS: "http://purl.org/rss/1.0/", + 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", + + /** + * Convert rss subscriptions in a legacy feeds.rdf file into feeds.json. + * If the conversion is successful, the legacy file will be removed. + * + * @param {nsIFile} legacyFile - Location of the rdf file. + * @param {nsIFile} jsonFile - Location for the output JSON file. + * @returns {void} + */ + async _migrateRSSSubscriptions(legacyFile, jsonFile) { + // Load .rdf file into an XMLDocument. + let rawXMLRDF; + try { + rawXMLRDF = await IOUtils.readUTF8(legacyFile.path); + } catch (ex) { + if (["NotFoundError"].includes(ex.name)) { + return; // nothing legacy file to migrate + } + } + let parser = new DOMParser(); + let doc = parser.parseFromString(rawXMLRDF, "text/xml"); + + let feeds = []; + // Skip the fz:root->fz:feeds->etc structure. Just grab fz:feed nodes. + let feedNodes = doc.documentElement.getElementsByTagNameNS( + this.FZ_NS, + "feed" + ); + + let toBool = function (val) { + return val == "true"; + }; + + // Map RDF feed property names to js. + let propMap = [ + { ns: this.DC_NS, name: "title", dest: "title" }, + { ns: this.DC_NS, name: "lastModified", dest: "lastModified" }, + { ns: this.DC_NS, name: "identifier", dest: "url" }, + { ns: this.FZ_NS, name: "quickMode", dest: "quickMode", cook: toBool }, + { ns: this.FZ_NS, name: "options", dest: "options", cook: JSON.parse }, + { ns: this.FZ_NS, name: "destFolder", dest: "destFolder" }, + { ns: this.RSS_NS, name: "link", dest: "link" }, + ]; + + for (let f of feedNodes) { + let feed = {}; + for (let p of propMap) { + // The data could be in either an attribute or an element. + let val = f.getAttributeNS(p.ns, p.name); + if (!val) { + let el = f.getElementsByTagNameNS(p.ns, p.name).item(0); + if (el) { + // Might be a RDF:resource... + val = el.getAttributeNS(this.RDF_SYNTAX_NS, "resource"); + if (!val) { + // ...or a literal string. + val = el.textContent; + } + } + } + if (!val) { + // log.warn(`feeds.rdf: ${p.name} missing`); + continue; + } + // Conversion needed? + if ("cook" in p) { + val = p.cook(val); + } + feed[p.dest] = val; + } + + if (feed.url) { + feeds.push(feed); + } + } + + await IOUtils.writeJSON(jsonFile.path, feeds); + legacyFile.remove(false); + }, + + /** + * Convert a legacy feeditems.rdf file into feeditems.json. + * If the conversion is successful, the legacy file will be removed. + * + * @param {nsIFile} legacyFile - Location of the rdf file. + * @param {nsIFile} jsonFile - Location for the output JSON file. + * @returns {void} + */ + async _migrateRSSItems(legacyFile, jsonFile) { + // Load .rdf file into an XMLDocument. + let rawXMLRDF; + try { + rawXMLRDF = await IOUtils.readUTF8(legacyFile.path); + } catch (ex) { + if (["NotFoundError"].includes(ex.name)) { + return; // nothing legacy file to migrate + } + } + let parser = new DOMParser(); + let doc = parser.parseFromString(rawXMLRDF, "text/xml"); + + let items = {}; + + let demangleURL = function (itemURI) { + // Reverse the mapping that originally turned links/guids into URIs. + let url = itemURI; + url = url.replace("urn:feeditem:", ""); + url = url.replace(/%23/g, "#"); + url = url.replace(/%2f/g, "/"); + url = url.replace(/%3f/g, "?"); + url = url.replace(/%26/g, "&"); + url = url.replace(/%7e/g, "~"); + url = decodeURI(url); + return url; + }; + + let toBool = function (s) { + return s == "true"; + }; + + let toInt = function (s) { + let t = parseInt(s); + return Number.isNaN(t) ? 0 : t; + }; + + let itemNodes = doc.documentElement.getElementsByTagNameNS( + this.RDF_SYNTAX_NS, + "Description" + ); + + // Map RDF feed property names to js. + let propMap = [ + { ns: this.FZ_NS, name: "stored", dest: "stored", cook: toBool }, + { ns: this.FZ_NS, name: "valid", dest: "valid", cook: toBool }, + { + ns: this.FZ_NS, + name: "last-seen-timestamp", + dest: "lastSeenTime", + cook: toInt, + }, + ]; + + for (let itemNode of itemNodes) { + let item = {}; + for (let p of propMap) { + // The data could be in either an attribute or an element. + let val = itemNode.getAttributeNS(p.ns, p.name); + if (!val) { + let elements = itemNode.getElementsByTagNameNS(p.ns, p.name); + if (elements.length > 0) { + val = elements.item(0).textContent; + } + } + if (!val) { + // log.warn(`feeditems.rdf: ${p.name} missing`); + continue; + } + // Conversion needed? + if ("cook" in p) { + val = p.cook(val); + } + item[p.dest] = val; + } + + item.feedURLs = []; + let feedNodes = itemNode.getElementsByTagNameNS(this.FZ_NS, "feed"); + for (let feedNode of feedNodes) { + let feedURL = feedNode.getAttributeNS(this.RDF_SYNTAX_NS, "resource"); + item.feedURLs.push(feedURL); + } + + let id = itemNode.getAttributeNS(this.RDF_SYNTAX_NS, "about"); + id = demangleURL(id); + if (id) { + items[id] = item; + } + } + + await IOUtils.writeJSON(jsonFile.path, items); + legacyFile.remove(false); + }, + + /** + * Perform any migration work that needs to occur once the user profile has + * been loaded. + */ + migrateAtProfileStartup() { + lazy.migrateMailnews(); + this._migrateUI(); + this._migrateRSS(); + }, +}; + +/** + * Controls migration tasks, including (if the migration is taking a while) + * presenting the user with a pop-up window showing the current status. + */ +var MigrationTasks = { + _finished: false, + _progressWindow: null, + _start: null, + _tasks: [], + _waitThreshold: 1000, + + /** + * Adds a simple task to be completed. + * + * @param {string} [fluentID] - The name of this task. If specified, a string + * for this name MUST be in migration.ftl. If not specified, this task + * won't appear in the list of migration tasks. + * @param {Function} action + */ + addSimpleTask(fluentID, action) { + this._tasks.push(new MigrationTask(fluentID, action)); + }, + + /** + * Adds a task to be completed. Subclasses of MigrationTask are allowed, + * allowing more complex tasks than `addSimpleTask`. + * + * @param {MigrationTask} task + */ + addComplexTask(task) { + if (!(task instanceof MigrationTask)) { + throw new Error("Task is not a MigrationTask"); + } + this._tasks.push(task); + }, + + /** + * Runs the tasks in sequence. + */ + async _runTasksInternal() { + this._start = Date.now(); + + // Do not optimise this for-loop. More tasks could be added. + for (let t = 0; t < this._tasks.length; t++) { + let task = this._tasks[t]; + task.status = "running"; + + await task.action(); + + for (let i = 0; i < task.subTasks.length; i++) { + task.emit("progress", i, task.subTasks.length); + let subTask = task.subTasks[i]; + subTask.status = "running"; + + await subTask.action(); + subTask.status = "finished"; + } + if (task.subTasks.length) { + task.emit("progress", task.subTasks.length, task.subTasks.length); + // Pause long enough for the user to see the progress bar at 100%. + await new Promise(resolve => lazy.setTimeout(resolve, 150)); + } + + task.status = "finished"; + } + + this._tasks.length = 0; + this._finished = true; + }, + + /** + * Runs the migration tasks. Controls the opening and closing of the pop-up. + */ + runTasks() { + this._runTasksInternal(); + + Services.tm.spinEventLoopUntil("MigrationTasks", () => { + if (this._finished) { + return true; + } + + if ( + !this._progressWindow && + Date.now() - this._start > this._waitThreshold + ) { + this._progressWindow = Services.ww.openWindow( + null, + "chrome://messenger/content/migrationProgress.xhtml", + "_blank", + "centerscreen,width=640", + Services.ww + ); + this.addSimpleTask(undefined, async () => { + await new Promise(r => lazy.setTimeout(r, 1000)); + this._progressWindow.close(); + }); + } + + return false; + }); + + delete this._progressWindow; + }, + + /** + * @type MigrationTask[] + */ + get tasks() { + return this._tasks; + }, +}; + +/** + * A single task to be completed. + */ +class MigrationTask { + /** + * The name of this task. If specified, a string for this name MUST be in + * migration.ftl. If not specified, this task won't appear in the list of + * migration tasks. + * + * @type string + */ + fluentID = null; + + /** + * Smaller tasks for this task. If there are sub-tasks, a progress bar will + * be displayed to the user, showing how many sub-tasks are complete. + * + * @note A sub-task may not have sub-sub-tasks. + * + * @type MigrationTask[] + */ + subTasks = []; + + /** + * Current status of the task. Either "pending", "running" or "finished". + * + * @type string + */ + _status = "pending"; + + /** + * @param {string} [fluentID] + * @param {Function} action + */ + constructor(fluentID, action) { + this.fluentID = fluentID; + this.action = action; + lazy.EventEmitter.decorate(this); + } + + /** + * Current status of the task. Either "pending", "running" or "finished". + * Emits a "status-change" notification on change. + * + * @type string + */ + get status() { + return this._status; + } + + set status(value) { + this._status = value; + this.emit("status-change", value); + } +} |