/* 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/. */ /* global MozElements */ var { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { AddrBookFileImporter: "resource:///modules/AddrBookFileImporter.sys.mjs", CalendarFileImporter: "resource:///modules/CalendarFileImporter.sys.mjs", MailServices: "resource:///modules/MailServices.sys.mjs", MailUtils: "resource:///modules/MailUtils.sys.mjs", ProfileExporter: "resource:///modules/ProfileExporter.sys.mjs", cal: "resource:///modules/calendar/calUtils.sys.mjs", }); /** * An object to represent a source profile to import from. * * @typedef {object} SourceProfile * @property {string} [name] - The profile name. * @property {nsIFile} dir - The profile location. */ /** * @typedef {object} Step * @property {Function} returnTo - Function that resets to this step. Should end * up calling |updateSteps()| with this step again. */ const Steps = { _pastSteps: [], /** * Toggle visibility of the navigation steps. * * @param {boolean} visible - If the navigation steps should be shown. */ toggle(visible) { document.getElementById("stepNav").hidden = !visible; }, /** * Update the currently displayed steps by adding a new step and updating the * forecast of remaining steps. * * @param {Step} currentStep * @param {number} plannedSteps - Amount of steps to follow this step, * including summary. */ updateSteps(currentStep, plannedSteps) { this._pastSteps.push(currentStep); const confirm = document.getElementById("navConfirm"); const isConfirmStep = plannedSteps === 0; confirm.classList.toggle("current", isConfirmStep); confirm.toggleAttribute("disabled", isConfirmStep); confirm.removeAttribute("aria-current"); document.getElementById("stepNav").replaceChildren( ...this._pastSteps.map((step, index) => { const li = document.createElement("li"); const button = document.createElement("button"); if (step === currentStep) { if (isConfirmStep) { confirm.setAttribute("aria-current", "step"); return confirm; } li.classList.add("current"); li.setAttribute("aria-current", "step"); button.setAttribute("disabled", "disabled"); } else { li.classList.add("completed"); button.addEventListener("click", () => { this.backTo(index); }); } document.l10n.setAttributes(button, "step-count", { number: index + 1, }); li.append(button); //TODO tooltips return li; }), ...new Array(Math.max(plannedSteps - 1, 0)) .fill(null) .map((item, index) => { const li = document.createElement("li"); const button = document.createElement("button"); document.l10n.setAttributes(button, "step-count", { number: this._pastSteps.length + index + 1, }); button.setAttribute("disabled", "disabled"); li.append(button); //TODO tooltips return li; }), isConfirmStep ? "" : confirm ); }, /** * Return to a previous step. * * @param {number} [stepIndex=-1] - The absolute index of the step to return * to. By default goes back one step. * @returns {boolean} if a previous step was recalled. */ backTo(stepIndex = -1) { if (!this._pastSteps.length || stepIndex >= this._pastSteps.length) { return false; } if (stepIndex < 0) { // Make relative step index absolute stepIndex = this._pastSteps.length + stepIndex - 1; } const targetStep = this._pastSteps[stepIndex]; this._pastSteps = this._pastSteps.slice(0, stepIndex); targetStep.returnTo(); return true; }, /** * If any previous steps have been recorded. * * @returns {boolean} If there are steps preceding the current state. */ hasStepHistory() { return this._pastSteps.length > 0; }, /** * Reset step state. */ reset() { this._pastSteps = []; }, }; /** * The base controller for an importing process. */ class ImporterController { _logger = console.createInstance({ prefix: "mail.import", maxLogLevel: "Warn", maxLogLevelPref: "mail.import.loglevel", }); /** * @param {string} elementId - The root element id. * @param {string} paneIdPrefix - The prefix of sub pane id. */ constructor(elementId, paneIdPrefix) { this._el = document.getElementById(elementId); this._paneIdPrefix = paneIdPrefix; } /** * Show a specific pane, hide all the others. * * @param {string} id - The pane id to show. */ showPane(id) { this._currentPane = id; id = `${this._paneIdPrefix}-${id}`; for (const pane of this._el.querySelectorAll(":scope > section")) { pane.hidden = pane.id != id; } } /** * Show the previous pane. */ back() { ImporterController.notificationBox.removeAllNotifications(); Steps.backTo(); } /** * Show the next pane. */ next() { if (this._restartOnOk) { window.close(); MailUtils.restartApplication(); return; } ImporterController.notificationBox.removeAllNotifications(); } /** * Show the first pane. */ reset() { this._el.classList.remove( "restart-only", "progress", "complete", "final-step" ); this._toggleBackButton(true); } /** * Show the progress bar. * * @param {string} progressL10nId - Fluent ID to use for the progress * description. Should have a |progressPercent| variable expecting the * current progress like "50%". */ showProgress(progressL10nId) { this._progressL10nId = progressL10nId; this.updateProgress(0); this._el.classList.add("progress"); this._toggleBackButton(false); this._inProgress = true; } /** * Update the progress bar. * * @param {number} value - A number between 0 and 1 to represent the progress. */ updateProgress(value) { this._el.querySelector(".progressPaneProgressBar").value = value; document.l10n.setAttributes( this._el.querySelector(".progressPaneDesc"), this._progressL10nId, { progressPercent: ImporterController.percentFormatter.format(value), } ); } /** * Show the finish text. * * @param {boolean} [restartNeeded=false] - Whether restart is needed to * finish the importing. */ finish(restartNeeded = false) { this._restartOnOk = restartNeeded; this._el.classList.toggle("restart-required", restartNeeded); this._el.classList.add("complete"); document.l10n.setAttributes( this._el.querySelector(".progressPaneDesc"), "progress-pane-finished-desc2" ); this._inProgress = false; } /** * Show the error pane, with an error message. * * @param {string} msgId - The error message fluent id. */ async showError(msgId) { if (this._inProgress) { this._toggleBackButton(true); this._el.classList.remove("progress"); this._el.querySelector(".before-progress").disabled = true; this._restartOnOk = false; this._inProgress = false; } ImporterController.notificationBox.removeAllNotifications(); const notification = await ImporterController.notificationBox.appendNotification( "error", { label: { "l10n-id": msgId, }, priority: ImporterController.notificationBox.PRIORITY_CRITICAL_HIGH, }, null ); notification.dismissable = false; } /** * Disable/enable the back button. * * @param {boolean} enable - If the back button should be enabled */ _toggleBackButton(enable) { if (this._el.querySelector(".buttons-container")) { this._el.querySelector(".back").disabled = !enable; } } } ChromeUtils.defineLazyGetter( ImporterController, "percentFormatter", () => new Intl.NumberFormat(undefined, { style: "percent", }) ); ChromeUtils.defineLazyGetter( ImporterController, "notificationBox", () => new MozElements.NotificationBox(element => { element.setAttribute("notificationside", "bottom"); document.getElementById("errorNotifications").append(element); }) ); /** * Control the #tabPane-app element, to support importing from an application. */ class ProfileImporterController extends ImporterController { constructor() { super("tabPane-app", "app"); document.getElementById("appItemsList").addEventListener( "input", () => { const state = this._getItemsChecked(true); document.getElementById("profileNextButton").disabled = Object.values( state ).every(isChecked => !isChecked); }, { capture: true, passive: true, } ); } /** * A map from radio input value to the importer module name. */ _sourceModules = { Thunderbird: "ThunderbirdProfileImporter", Seamonkey: "SeamonkeyProfileImporter", Outlook: "OutlookProfileImporter", AppleMail: "AppleMailProfileImporter", }; /** * Maps app radio input values to their respective representations in l10n * ids. */ _sourceL10nIds = { Thunderbird: "thunderbird", Seamonkey: "seamonkey", Outlook: "outlook", AppleMail: "apple-mail", }; _sourceAppName = "thunderbird"; next() { super.next(); switch (this._currentPane) { case "profiles": this._onSelectProfile(); break; case "items": this._onSelectItems(); break; case "summary": window.close(); break; } } /** * Handler for the Continue button on the sources pane. * * @param {string} source - Profile source to import. */ async _onSelectSource(source) { this._sourceAppName = this._sourceL10nIds[source]; const sourceModule = this._sourceModules[source]; const module = ChromeUtils.importESModule( `resource:///modules/${sourceModule}.sys.mjs` ); this._importer = new module[sourceModule](); const sourceProfiles = await this._importer.getSourceProfiles(); if (sourceProfiles.length > 1 || this._importer.USE_FILE_PICKER) { // Let the user pick a profile if there are multiple options. this._showProfiles(sourceProfiles, this._importer.USE_FILE_PICKER); } else if (sourceProfiles.length == 1) { // Let the user pick what to import. this._showItems(sourceProfiles[0]); } else { this.showError("error-message-no-profile"); throw new Error("No profile found, do not advance to app flow."); } } /** * Show the profiles pane, with a list of profiles and optional file pickers. * * @param {SourceProfile[]} profiles - An array of profiles. * @param {boolean} useFilePicker - Whether to render file pickers. */ _showProfiles(profiles, useFilePicker) { Steps.updateSteps( { returnTo: () => { this.reset(); this._showProfiles(profiles, useFilePicker); }, }, 2 ); this._sourceProfiles = profiles; document.l10n.setAttributes( document.getElementById("profilesPaneTitle"), `from-app-${this._sourceAppName}` ); document.l10n.setAttributes( document.getElementById("profilesPaneSubtitle"), `profiles-pane-title-${this._sourceAppName}` ); const elProfileList = document.getElementById("profileList"); elProfileList.hidden = !profiles.length; elProfileList.innerHTML = ""; document.getElementById("filePickerList").hidden = !useFilePicker; for (const profile of profiles) { const label = document.createElement("label"); label.className = "toggle-container-with-text"; const input = document.createElement("input"); input.type = "radio"; input.name = "appProfile"; input.value = profile.dir.path; label.append(input); const name = document.createElement("p"); if (profile.name) { document.l10n.setAttributes(name, "profile-source-named", { profileName: profile.name, }); } else { document.l10n.setAttributes(name, "profile-source"); } label.append(name); const profileDetails = document.createElement("dl"); profileDetails.className = "result-indent tip-caption"; const profilePathLabel = document.createElement("dt"); document.l10n.setAttributes(profilePathLabel, "items-pane-directory"); const profilePath = document.createElement("dd"); profilePath.textContent = profile.dir.path; profileDetails.append(profilePathLabel, profilePath); label.append(profileDetails); elProfileList.append(label); } document.querySelector("input[name=appProfile]").checked = true; document.getElementById("profileNextButton").disabled = false; this.showPane("profiles"); delete this._importingFromZip; // Clear any previous value. } /** * Handler for the Continue button on the profiles pane. */ _onSelectProfile() { const index = [ ...document.querySelectorAll("input[name=appProfile]"), ].findIndex(el => el.checked); if (this._sourceProfiles[index]) { this._showItems(this._sourceProfiles[index]); } else { this._openFilePicker( index == this._sourceProfiles.length ? "dir" : "zip" ); } } /** * Open a file picker to select a folder or a zip file. * * @param {'dir' | 'zip'} type - Whether to pick a folder or a zip file. */ async _openFilePicker(type) { const filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( Ci.nsIFilePicker ); const [filePickerTitleDir, filePickerTitleZip] = await document.l10n.formatValues([ "profile-file-picker-directory", "profile-file-picker-archive-title", ]); if (type == "zip") { filePicker.init( window.browsingContext, filePickerTitleZip, filePicker.modeOpen ); filePicker.appendFilter("", "*.zip"); } else { filePicker.init( window.browsingContext, filePickerTitleDir, filePicker.modeGetFolder ); } const rv = await new Promise(resolve => filePicker.open(resolve)); if (rv != Ci.nsIFilePicker.returnOK) { return; } const selectedFile = filePicker.file; if (!selectedFile.isDirectory()) { if (selectedFile.fileSize > 2147483647) { // nsIZipReader only supports zip file less than 2GB. this.showError("error-message-zip-file-too-big2"); return; } this._importingFromZip = true; } this._showItems({ dir: selectedFile }); } /** * Show the items pane, with a list of items to import. * * @param {SourceProfile} profile - The profile to import from. */ async _showItems(profile) { Steps.updateSteps( { returnTo: () => { this.reset(); this._showItems(profile); }, }, 1 ); this._el.classList.remove("final-step", "progress"); document.l10n.setAttributes( this._el.querySelector("#app-items h1"), `from-app-${this._sourceAppName}` ); this._sourceProfile = profile; document.getElementById("appSourceProfilePath").textContent = this._sourceProfile.dir.path; document.getElementById("appSourceProfileNameWrapper").hidden = !this._sourceProfile.name; if (this._sourceProfile.name) { document.getElementById("appSourceProfileName").textContent = this._sourceProfile.name; } if (this._importer.validateSource(this._sourceProfile.dir)) { this._setItemsChecked(this._importer.SUPPORTED_ITEMS); } else { this._setItemsChecked({}); } const nothingChecked = Object.keys(profileController._itemCheckboxes).every( k => !document.getElementById(k).checked ); document.getElementById("appSourceInvalid").hidden = !nothingChecked; document.getElementById("appSourceItems").hidden = nothingChecked; document.getElementById("profileNextButton").disabled = nothingChecked; this.showPane("items"); } /** A map from checkbox id to ImportItems field */ _itemCheckboxes = { checkAccounts: "accounts", checkAddressBooks: "addressBooks", checkCalendars: "calendars", checkMailMessages: "mailMessages", }; /** * Map of fluent IDs from ImportItems if they differ. * * @type {object} */ _importItemFluentId = { addressBooks: "address-books", mailMessages: "mail-messages", }; /** * Set checkbox states according to an ImportItems object. * * @param {ImportItems} items */ _setItemsChecked(items) { for (const [id, field] of Object.entries(this._itemCheckboxes)) { const supported = items[field]; const checkbox = document.getElementById(id); checkbox.checked = supported; checkbox.disabled = !supported; } } /** * Construct an ImportItems object from the checkbox states. * * @param {boolean} [onlySupported=false] - Only return supported ImportItems. * @returns {ImportItems} */ _getItemsChecked(onlySupported = false) { const items = {}; for (const id in this._itemCheckboxes) { const checkbox = document.getElementById(id); if (!onlySupported || !checkbox.disabled) { items[this._itemCheckboxes[id]] = checkbox.checked; } } return items; } /** * Handler for the Continue button on the items pane. */ _onSelectItems() { const checkedItems = this._getItemsChecked(true); if (Object.values(checkedItems).some(isChecked => isChecked)) { this._showSummary(); } } _showSummary() { Steps.updateSteps({}, 0); this._el.classList.add("final-step"); document.l10n.setAttributes( this._el.querySelector("#app-summary h1"), `from-app-${this._sourceAppName}` ); document.getElementById("appSummaryProfilePath").textContent = this._sourceProfile.dir.path; document.getElementById("appSummaryProfileNameWrapper").hidden = !this._sourceProfile.name; if (this._sourceProfile.name) { document.getElementById("appSummaryProfileName").textContent = this._sourceProfile.name; } document.getElementById("appSummaryItems").replaceChildren( ...Object.entries(this._getItemsChecked(true)) .filter(([, checked]) => checked) .map(([item]) => { const li = document.createElement("li"); const fluentId = this._importItemFluentId[item] ?? item; document.l10n.setAttributes(li, `items-pane-checkbox-${fluentId}`); return li; }) ); this._el.querySelector(".before-progress").disabled = false; this.showPane("summary"); } async startImport() { const gleanData = { importer: this._importer.NAME, types: Object.entries(this._getItemsChecked()) .filter(entry => entry[1]) .map(entry => entry[0]) .join(","), }; this.showProgress("progress-pane-importing2"); if (this._importingFromZip) { gleanData.importer += ",zip"; try { this._sourceProfile = { dir: await this._importer.extractZipFile( this._sourceProfile.dir, progress => this.updateProgress(progress * 0.2) ), }; } catch (e) { this.showError("error-message-extract-zip-file-failed2"); Glean.mail.import.record({ ...gleanData, result: "unzipFailed" }); throw e; } } else if (this._sourceProfile.name) { gleanData.importer += ",profile"; } else { gleanData.importer += ",directory"; } this._importer.onProgress = (current, total) => { this.updateProgress( this._importingFromZip ? 0.2 + (0.8 * current) / total : current / total ); }; try { const restartNeeded = await this._importer.startImport( this._sourceProfile.dir, this._getItemsChecked() ); Glean.mail.import.record({ ...gleanData, result: "succeeded" }); this.finish(restartNeeded); } catch (e) { this.showError("error-message-failed"); Glean.mail.import.record({ ...gleanData, result: "failed" }); throw e; } finally { if (this._importingFromZip) { IOUtils.remove(this._sourceProfile.dir.path, { recursive: true }); } } } } /** * Control the #tabPane-addressBook element, to support importing from an * address book file. */ class AddrBookImporterController extends ImporterController { constructor() { super("tabPane-addressBook", "addr-book"); } /** * Show the next pane. */ next() { super.next(); switch (this._currentPane) { case "sources": this._onSelectSource(); break; case "csvFieldMap": this._onSubmitCsvFieldMap(); break; case "directories": this._onSelectDirectory(); break; case "summary": window.close(); break; } } showInitialStep() { this._showSources(); } /** * Show the sources pane. */ _showSources() { document.getElementById("addrBookBackButton").hidden = !Steps.hasStepHistory(); Steps.updateSteps( { returnTo: () => { this.reset(); this._showSources(); }, }, 2 ); this.showPane("sources"); } /** * Handler for the Continue button on the sources pane. */ async _onSelectSource() { this._fileType = document.querySelector( "input[name=addrBookSource]:checked" ).value; this._importer = new AddrBookFileImporter(this._fileType); const filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( Ci.nsIFilePicker ); const [filePickerTitle] = await document.l10n.formatValues([ "addr-book-file-picker", ]); filePicker.init( window.browsingContext, filePickerTitle, filePicker.modeOpen ); const filter = { csv: "*.csv; *.tsv; *.tab", ldif: "*.ldif", vcard: "*.vcf", sqlite: "*.sqlite", mab: "*.mab", }[this._fileType]; if (filter) { filePicker.appendFilter("", filter); } filePicker.appendFilters(Ci.nsIFilePicker.filterAll); const rv = await new Promise(resolve => filePicker.open(resolve)); if (rv != Ci.nsIFilePicker.returnOK) { return; } this._sourceFile = filePicker.file; document.getElementById("addrBookSourcePath").textContent = filePicker.file.path; if (this._fileType == "csv") { const unmatchedRows = await this._importer.parseCsvFile(filePicker.file); if (unmatchedRows.length) { document.getElementById("csvFieldMap").data = unmatchedRows; this._showCsvFieldMap(); return; } } this._showDirectories(); } /** * Show the csvFieldMap pane, user can map source CSV fields to address book * fields. */ _showCsvFieldMap() { Steps.updateSteps( { returnTo: () => { this.reset(); this._showCsvFieldMap(); }, }, 2 ); document.getElementById("addrBookBackButton").hidden = false; this.showPane("csvFieldMap"); } /** * Handler for the Continue button on the csvFieldMap pane. */ async _onSubmitCsvFieldMap() { this._importer.setCsvFields(document.getElementById("csvFieldMap").value); this._showDirectories(); } /** * Show the directories pane, with a list of existing directories and an * option to create a new directory. */ async _showDirectories() { Steps.updateSteps( { returnTo: () => { this.reset(); this._showDirectories(); }, }, 1 ); document.getElementById("addrBookBackButton").hidden = false; this._el.classList.remove("final-step", "progress"); const sourceFileName = this._sourceFile.leafName; this._fallbackABName = sourceFileName.slice( 0, sourceFileName.lastIndexOf(".") == -1 ? Infinity : sourceFileName.lastIndexOf(".") ); document.l10n.setAttributes( document.getElementById("newDirectoryLabel"), "addr-book-import-into-new-directory2", { addressBookName: this._fallbackABName, } ); const elList = document.getElementById("directoryList"); elList.innerHTML = ""; this._directories = MailServices.ab.directories.filter( dir => !dir.readOnly ); for (const directory of this._directories) { const label = document.createElement("label"); label.className = "toggle-container-with-text"; const input = document.createElement("input"); input.type = "radio"; input.name = "addrBookDirectory"; input.value = directory.dirPrefId; label.append(input); const name = document.createElement("div"); name.className = "strong"; name.textContent = directory.dirName; label.append(name); elList.append(label); } document.querySelector("input[name=addrBookDirectory]").checked = true; this.showPane("directories"); } /** * Handler for the Continue button on the directories pane. */ _onSelectDirectory() { const index = [ ...document.querySelectorAll("input[name=addrBookDirectory]"), ].findIndex(el => el.checked); this._selectedAddressBook = this._directories[index]; this._showSummary(); } _showSummary() { Steps.updateSteps({}, 0); this._el.classList.add("final-step"); document.getElementById("addrBookSummaryPath").textContent = this._sourceFile.path; let targetAddressBook = this._selectedAddressBook?.dirName; let newAddressBook = false; if (!targetAddressBook) { targetAddressBook = this._fallbackABName; newAddressBook = true; } const description = this._el.querySelector( "#addr-book-summary .description" ); description.hidden = !newAddressBook; if (newAddressBook) { document.l10n.setAttributes( description, "addr-book-summary-description", { addressBookName: targetAddressBook, } ); } document.l10n.setAttributes( document.getElementById("addrBookSummarySubtitle"), "addr-book-summary-title", { addressBookName: targetAddressBook, } ); this._el.querySelector(".before-progress").disabled = false; this.showPane("summary"); } async startImport() { let targetDirectory = this._selectedAddressBook; if (!targetDirectory) { // User selected to create a new address book and import into it. Create // one based on the file name. const dirId = MailServices.ab.newAddressBook( this._fallbackABName, "", Ci.nsIAbManager.JS_DIRECTORY_TYPE ); targetDirectory = MailServices.ab.getDirectoryFromId(dirId); } this.showProgress("progress-pane-importing2"); this._importer.onProgress = (current, total) => { this.updateProgress(current / total); }; try { this.finish( await this._importer.startImport(this._sourceFile, targetDirectory) ); Glean.mail.import.record({ importer: "addrbook", types: this._fileType, result: "succeeded", }); } catch (e) { this.showError("error-message-failed"); Glean.mail.import.record({ importer: "addrbook", types: this._fileType, result: "failed", }); throw e; } } } /** * Control the #tabPane-calendar element, to support importing from a calendar * file. */ class CalendarImporterController extends ImporterController { constructor() { super("tabPane-calendar", "calendar"); const filter = document.getElementById("calendarFilter"); filter.addEventListener("autocomplete", this.onFilterChange.bind(this)); filter.addEventListener("search", event => event.preventDefault()); } next() { super.next(); switch (this._currentPane) { case "sources": this._onSelectSource(); break; case "items": this._onSelectItems(); break; case "calendars": this._onSelectCalendar(); break; case "summary": window.close(); break; } } showInitialStep() { this._showSources(); } /** * When filter changes, re-render the item list. This function wraps * #onFilterChange in a timer, to reduce the frequency of list updates. * * @param {Event} event - The "autocomplete" event fired by the filter input. */ onFilterChange(event) { let searchString = event.detail.trim(); if (!searchString) { this._filteredItems = [...this._items]; for (const item of this._items) { const element = this._itemElements[item.id]; element.hidden = false; } return; } searchString = searchString.toLowerCase().normalize(); // Split the search string into tokens. Quoted strings are preserved. let searchTokens = []; let startIndex; while ((startIndex = searchString.indexOf('"')) != -1) { let endIndex = searchString.indexOf('"', startIndex + 1); if (endIndex == -1) { endIndex = searchString.length; } searchTokens.push(searchString.slice(startIndex + 1, endIndex)); let query = searchString.slice(0, startIndex); if (endIndex < searchString.length) { query += searchString.slice(endIndex + 1); } searchString = query.trim(); } if (searchString.length != 0) { searchTokens = searchTokens.concat(searchString.split(/\s+/)); } this._filteredItems = []; for (const item of this._items) { const title = item.title.toLowerCase().normalize(); let description; const matches = searchTokens.every(term => { if (title?.includes(term)) { return true; } if (description === undefined) { description = item .getProperty("description") ?.toLowerCase() .normalize(); } return description?.includes(term); }); const element = this._itemElements[item.id]; if (matches) { element.hidden = false; this._filteredItems.push(item); } else { element.hidden = true; } } } /** * Select or deselect all visible items. * * @param {boolean} selected - Select all if true, otherwise deselect all. */ selectAllItems(selected) { for (const item of this._filteredItems) { const element = this._itemElements[item.id]; element.querySelector("input").checked = selected; if (selected) { this._selectedItems.add(item); } else { this._selectedItems.delete(item); } } document.getElementById("calendarNextButton").disabled = this._selectedItems.size == 0; } /** * Show the sources pane. */ _showSources() { document.getElementById("calendarBackButton").hidden = !Steps.hasStepHistory(); Steps.updateSteps( { returnTo: () => { this.reset(); this._showSources(); }, }, 3 ); this.showPane("sources"); document.getElementById("calendarNextButton").disabled = false; } /** * Handler for the Continue button on the sources pane. */ async _onSelectSource() { const filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( Ci.nsIFilePicker ); filePicker.appendFilter("", "*.ics"); filePicker.appendFilters(Ci.nsIFilePicker.filterAll); filePicker.init( window.browsingContext, await document.l10n.formatValue("file-calendar-description"), filePicker.modeOpen ); const rv = await new Promise(resolve => filePicker.open(resolve)); if (rv != Ci.nsIFilePicker.returnOK) { return; } this.useFile(filePicker.file); } /** * Use `file` as the source of items to be imported. Normally the file comes * from the file picker, but it could be given as command-line argument when * starting Thunderbird. * * @param {nsIFile} file */ useFile(file) { this._sourceFile = file; this._importer = new CalendarFileImporter(); document.getElementById("calendarSourcePath").textContent = file.path; this._showItems(); } /** * Show the sources pane. */ async _showItems() { Steps.updateSteps( { returnTo: () => { this.reset(); this._showItems(); }, }, 2 ); document.getElementById("calendarBackButton").hidden = false; const elItemList = document.getElementById("calendar-item-list"); document.getElementById("calendarItemsTools").hidden = true; document.l10n.setAttributes(elItemList, "calendar-items-loading"); this.showPane("items"); // Give the UI a chance to render. await document.l10n.translateElements([elItemList]); await new Promise(resolve => setTimeout(resolve, 100)); try { this._items = await this._importer.parseIcsFile(this._sourceFile); } catch (e) { this.showError("error-failed-to-parse-ics-file"); throw e; } document.getElementById("calendarItemsTools").hidden = this._items.length < 2; delete elItemList.dataset.l10nId; elItemList.replaceChildren(); this._filteredItems = this._items; this._selectedItems = new Set(this._items); this._itemElements = {}; for (const item of this._items) { if (!item.id) { item.id = Services.uuid.generateUUID().toString().slice(1, 37); } const wrapper = document.createElement("div"); wrapper.className = "calendar-item-wrapper"; elItemList.appendChild(wrapper); this._itemElements[item.id] = wrapper; const summary = document.createXULElement("calendar-item-summary"); wrapper.appendChild(summary); summary.item = item; summary.updateItemDetails(); const input = document.createElement("input"); input.type = "checkbox"; input.checked = true; wrapper.appendChild(input); wrapper.addEventListener("click", e => { if (e.target != input) { input.checked = !input.checked; } if (input.checked) { this._selectedItems.add(item); } else { this._selectedItems.delete(item); } document.getElementById("calendarNextButton").disabled = this._selectedItems.size == 0; }); } } /** * Handler for the Continue button on the items pane. */ _onSelectItems() { this._showCalendars(); } /** * Show the calendars pane, with a list of existing writable calendars and an * option to create a new calendar. */ _showCalendars() { Steps.updateSteps( { returnTo: () => { this.reset(); this._showCalendars(); }, }, 1 ); this._el.classList.remove("final-step", "progress"); document.getElementById("calendarCalPath").textContent = this._sourceFile.path; const elList = document.getElementById("calendarList"); elList.innerHTML = ""; const sourceFileName = this._sourceFile.leafName; this._fallbackCalendarName = sourceFileName.slice( 0, sourceFileName.lastIndexOf(".") == -1 ? Infinity : sourceFileName.lastIndexOf(".") ); document.l10n.setAttributes( document.getElementById("newCalendarLabel"), "calendar-import-into-new-calendar2", { targetCalendar: this._fallbackCalendarName, } ); this._calendars = this._importer.getTargetCalendars(); for (const calendar of this._calendars) { const label = document.createElement("label"); label.className = "toggle-container-with-text"; const input = document.createElement("input"); input.type = "radio"; input.name = "targetCalendar"; input.value = calendar.id; label.append(input); const name = document.createElement("div"); name.className = "strong"; name.textContent = calendar.name; label.append(name); elList.append(label); } document.querySelector("input[name=targetCalendar]").checked = true; document.getElementById("calendarNextButton").disabled = false; this.showPane("calendars"); } _onSelectCalendar() { const index = [ ...document.querySelectorAll("input[name=targetCalendar]"), ].findIndex(el => el.checked); this._selectedCalendar = this._calendars[index]; this._showSummary(); } _showSummary() { Steps.updateSteps({}, 0); this._el.classList.add("final-step"); document.getElementById("calendarSummaryPath").textContent = this._sourceFile.path; let targetCalendar = this._selectedCalendar?.name; let newCalendar = false; if (!targetCalendar) { targetCalendar = this._fallbackCalendarName; newCalendar = true; } const description = this._el.querySelector( "#calendar-summary .description" ); description.hidden = !newCalendar; if (newCalendar) { document.l10n.setAttributes(description, "calendar-summary-description", { targetCalendar, }); } document.l10n.setAttributes( document.getElementById("calendarSummarySubtitle"), "calendar-summary-title", { itemCount: this._selectedItems.size, targetCalendar, } ); this._el.querySelector(".before-progress").disabled = false; this.showPane("summary"); } /** * Handler for the Continue button on the calendars pane. */ async startImport() { let targetCalendar = this._selectedCalendar; if (!targetCalendar) { // Create a new calendar. targetCalendar = cal.manager.createCalendar( "storage", Services.io.newURI("moz-storage-calendar://") ); targetCalendar.name = this._fallbackCalendarName; cal.manager.registerCalendar(targetCalendar); } this.showProgress("progress-pane-importing2"); this._importer.onProgress = (current, total) => { this.updateProgress(current / total); }; try { await this._importer.startImport( [...this._selectedItems], targetCalendar ); Glean.mail.import.record({ importer: "calendar", result: "succeeded" }); this.finish(); } catch (e) { this.showError("error-message-failed"); Glean.mail.import.record({ importer: "calendar", result: "failed" }); throw e; } } } /** * Control the #tabPane-export element, to support exporting the current profile * to a zip file. */ class ExportController extends ImporterController { constructor() { super("tabPane-export", ""); } back() { window.close(); } async next() { super.next(); const [filePickerTitle, brandName] = await document.l10n.formatValues([ "export-file-picker2", "export-brand-name", ]); const filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( Ci.nsIFilePicker ); filePicker.init( window.browsingContext, filePickerTitle, Ci.nsIFilePicker.modeSave ); filePicker.defaultString = `${brandName}_profile_backup.zip`; filePicker.defaultExtension = "zip"; filePicker.appendFilter("", "*.zip"); const rv = await new Promise(resolve => filePicker.open(resolve)); if ( ![Ci.nsIFilePicker.returnOK, Ci.nsIFilePicker.returnReplace].includes(rv) ) { return; } const exporter = new ProfileExporter(); this.showProgress("progress-pane-exporting2"); exporter.onProgress = (current, total) => { this.updateProgress(current / total); }; try { await exporter.startExport(filePicker.file); this.finish(); } catch (e) { this.showError("error-export-failed"); throw e; } } openProfileFolder() { Services.dirsvc.get("ProfD", Ci.nsIFile).reveal(); } } class StartController extends ImporterController { constructor() { super("tabPane-start", "start"); } next() { super.next(); switch (this._currentPane) { case "sources": this._onSelectSource(); break; case "file": this._onSelectFile(); break; } } showInitialStep() { this._showSources(); } /** * Show the sources pane. */ _showSources() { Steps.updateSteps( { returnTo: () => { this.reset(); showTab("start"); //showTab will always call showInitialStep }, }, 3 ); document.getElementById("startBackButton").hidden = true; this.showPane("sources"); } /** * Handler for the Continue button on the sources pane. */ async _onSelectSource() { const checkedInput = document.querySelector( "input[name=appSource]:checked" ); switch (checkedInput.value) { case "file": this._showFile(); break; default: await profileController._onSelectSource(checkedInput.value); showTab("app"); // Don't change back button state, since we switch to app flow. return; } document.getElementById("startBackButton").hidden = false; } _showFile() { Steps.updateSteps( { returnTo: () => { this.reset(); showTab("start"); this._showFile(); }, }, 3 ); this.showPane("file"); } async _onSelectFile() { const checkedInput = document.querySelector( "input[name=startFile]:checked" ); switch (checkedInput.value) { case "profile": // Go to the import profile from zip file step in profile flow for TB. profileController.reset(); await profileController._onSelectSource("Thunderbird"); document.getElementById("appFilePickerZip").checked = true; await profileController._onSelectProfile(); showTab("app"); break; case "calendar": calendarController.reset(); showTab("calendar"); calendarController.showInitialStep(); await calendarController._onSelectSource(); break; case "addressbook": addrBookController.reset(); showTab("addressBook"); addrBookController.showInitialStep(); break; } } } let currentTab; /** * Show a specific importing tab. * * @param {"app"|"addressBook"|"calendar"|"export"|"start"} paneId - * Tab to show. * @param {boolean} [reset=false] - If the state should be reset as if this was * the initial tab shown. */ function showTab(paneId, reset = false) { if (reset) { Steps.reset(); restart(); } currentTab = paneId; const selectedPaneId = `tabPane-${paneId}`; const isExport = paneId === "export"; document.getElementById("importDocs").hidden = isExport; document.getElementById("exportDocs").hidden = !isExport; Steps.toggle(!isExport); document.l10n.setAttributes( document.querySelector("title"), isExport ? "export-page-title" : "import-page-title" ); document.querySelector("link[rel=icon]").href = isExport ? "chrome://messenger/skin/icons/new/compact/export.svg" : "chrome://messenger/skin/icons/new/compact/import.svg"; location.hash = paneId; for (const tabPane of document.querySelectorAll("[id^=tabPane-]")) { tabPane.hidden = tabPane.id != selectedPaneId; } if (!Steps.hasStepHistory()) { switch (paneId) { case "start": startController.showInitialStep(); break; case "addressBook": addrBookController.showInitialStep(); break; case "calendar": calendarController.showInitialStep(); break; case "app": // Profile import can't be restored to - app selection is in start flow. showTab("start", true); break; default: } } } /** * Restart the import wizard. Resets all previous choices. */ function restart() { startController?.reset(); profileController?.reset(); addrBookController?.reset(); calendarController?.reset(); Steps.backTo(0); } let profileController; let addrBookController; var calendarController; let exportController; let startController; document.addEventListener("DOMContentLoaded", () => { profileController = new ProfileImporterController(); addrBookController = new AddrBookImporterController(); calendarController = new CalendarImporterController(); exportController = new ExportController(); startController = new StartController(); showTab(location.hash ? location.hash.slice(1) : "start", true); }); window.addEventListener("hashchange", () => { const requestedTab = location.hash.slice(1); if (requestedTab !== currentTab) { showTab(requestedTab, true); } });