Eine aufbereitete Darstellung der Quelle

 
     
 
 
Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 

Benutzer

Quelle  translations.js   Sprache: JAVA

 

/* 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/. */


// @ts-check

"use strict";

/* import-globals-from main.js */

/**
 * @import {
 *  TranslationsSettingsElements,
 *  SupportedLanguages,
 *  LanguageInfo
 * } from "./translations"
 */


/** @type {string} */
const ALWAYS_TRANSLATE_LANGS_PREF =
  "browser.translations.alwaysTranslateLanguages";
/** @type {string} */
const NEVER_TRANSLATE_LANGS_PREF =
  "browser.translations.neverTranslateLanguages";
/** @type {string} */
const TOPIC_TRANSLATIONS_PREF_CHANGED = "translations:pref-changed";
/** @type {string} */
const TRANSLATIONS_PERMISSION = "translations";

/** @type {string} */
const ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS =
  "translations-always-translate-language-item";
/** @type {string} */
const ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS =
  "translations-always-translate-remove-button";

/** @type {string} */
const NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS =
  "translations-never-translate-language-item";
/** @type {string} */
const NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS =
  "translations-never-translate-remove-button";
/** @type {string} */
const NEVER_TRANSLATE_SITE_ITEM_CLASS =
  "translations-never-translate-site-item";
/** @type {string} */
const NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS =
  "translations-never-translate-site-remove-button";

/** @type {string} */
const DOWNLOAD_LANGUAGE_ITEM_CLASS = "translations-download-language-item";
/** @type {string} */
const DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS =
  "translations-download-remove-button";
/** @type {string} */
const DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS =
  "translations-download-retry-button";
/** @type {string} */
const DOWNLOAD_LANGUAGE_FAILED_CLASS = "translations-download-language-error";
/** @type {string} */
const DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS =
  "translations-download-delete-confirm-button";
/** @type {string} */
const DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS =
  "translations-download-delete-cancel-button";
/** @type {string} */
const DOWNLOAD_LOADING_ICON = "chrome://global/skin/icons/loading.svg";
/** @type {string} */
const DOWNLOAD_DELETE_ICON = "chrome://global/skin/icons/delete.svg";
/** @type {string} */
const DOWNLOAD_ERROR_ICON = "chrome://global/skin/icons/error.svg";
/** @type {string} */
const DOWNLOAD_WARNING_ICON = "chrome://global/skin/icons/warning.svg";

/**
 * Dispatches a test-only event when running under automation.
 *
 * @param {string} name - Event name without the "TranslationsSettingsTest:" prefix.
 * @param {object} [detail] - Optional event detail.
 */

function dispatchTestEvent(name, detail) {
  if (!globalThis.Cu?.isInAutomation) {
    return;
  }
  const options = detail ? { detail } : undefined;
  document.dispatchEvent(
    new CustomEvent(`TranslationsSettingsTest:${name}`, options)
  );
}

const TranslationsSettings = {
  /**
   * True once initialization has completed.
   *
   * @type {boolean}
   */

  initialized: false,

  /**
   * Promise guarding full initialization to avoid re-entry.
   *
   * @type {Promise<void>|null}
   */

  initPromise: null,

  /**
   * Promise cached after the pane/group finish rendering.
   *
   * @type {Promise<void>|null}
   */

  paneRenderPromise: null,

  /**
   * Supported languages fetched from TranslationsParent.
   *
   * @type {SupportedLanguages|null}
   */

  supportedLanguages: null,

  /**
   * Display names for supported languages.
   *
   * @type {Intl.DisplayNames|null}
   */

  languageDisplayNames: null,

  /**
   * Language metadata used to build labels and selectors.
   *
   * @type {LanguageInfo[]|null}
   */

  languageList: null,

  /**
   * Download sizes keyed by language tag.
   *
   * @type {Map<string, number>|null}
   */

  languageSizes: null,

  /**
   * Formatter used for download size labels.
   *
   * @type {Intl.NumberFormat|null}
   */

  numberFormatter: null,

  /**
   * Current always-translate language tags.
   *
   * @type {Set<string>}
   */

  alwaysTranslateLanguageTags: new Set(),

  /**
   * Current never-translate language tags.
   *
   * @type {Set<string>}
   */

  neverTranslateLanguageTags: new Set(),

  /**
   * Current never-translate site origins.
   *
   * @type {Set<string>}
   */

  neverTranslateSiteOrigins: new Set(),

  /**
   * Language tags with downloaded translation models.
   *
   * @type {Set<string>}
   */

  downloadedLanguageTags: new Set(),

  /**
   * Language tags currently downloading.
   *
   * @type {Set<string>}
   */

  downloadingLanguageTags: new Set(),

  /**
   * Language tags that failed to download.
   *
   * @type {Set<string>}
   */

  downloadFailedLanguageTags: new Set(),

  /**
   * Language tags pending delete confirmation.
   *
   * @type {Set<string>}
   */

  downloadPendingDeleteLanguageTags: new Set(),

  /**
   * Language tag of the in-progress download, if any.
   *
   * @type {string|null}
   */

  currentDownloadLangTag: null,

  /**
   * Cached DOM elements used by the module.
   *
   * @type {TranslationsSettingsElements|null}
   */

  elements: null,

  /**
   * Handles events this object is registered for.
   *
   * @param {Event} event
   */

  async handleEvent(event) {
    switch (event.type) {
      case "paneshown":
        await this.handlePaneShown(
          /** @type {CustomEvent} */ (event).detail?.category
        );
        break;
      case "change":
        if (event.target === this.elements?.alwaysTranslateLanguagesSelect) {
          this.onAlwaysTranslateLanguageSelectionChanged();
        } else if (
          event.target === this.elements?.neverTranslateLanguagesSelect
        ) {
          this.onNeverTranslateLanguageSelectionChanged();
        } else if (event.target === this.elements?.downloadLanguagesSelect) {
          this.onDownloadSelectionChanged();
        }
        break;
      case "click": {
        const target = /** @type {HTMLElement} */ (event.target);
        if (
          target === this.elements?.alwaysTranslateLanguagesButton ||
          target.closest?.("#translationsAlwaysTranslateLanguagesButton")
        ) {
          await this.onAlwaysTranslateLanguageChosen(
            this.elements?.alwaysTranslateLanguagesSelect?.value ?? ""
          );
          break;
        }
        if (
          target === this.elements?.neverTranslateLanguagesButton ||
          target.closest?.("#translationsNeverTranslateLanguagesButton")
        ) {
          await this.onNeverTranslateLanguageChosen(
            this.elements?.neverTranslateLanguagesSelect?.value ?? ""
          );
          break;
        }

        if (
          target === this.elements?.downloadLanguagesButton ||
          target.closest?.("#translationsDownloadLanguagesButton")
        ) {
          this.onDownloadLanguageButtonClicked();
          break;
        }

        const downloadRemoveButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS}`)
        );
        if (downloadRemoveButton?.dataset.langTag) {
          this.onDeleteButtonClicked(downloadRemoveButton.dataset.langTag);
          break;
        }

        const downloadDeleteConfirmButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS}`)
        );
        if (downloadDeleteConfirmButton?.dataset.langTag) {
          this.confirmDeleteLanguage(
            downloadDeleteConfirmButton.dataset.langTag
          );
          break;
        }

        const downloadDeleteCancelButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS}`)
        );
        if (downloadDeleteCancelButton?.dataset.langTag) {
          this.cancelDeleteLanguage(downloadDeleteCancelButton.dataset.langTag);
          break;
        }

        const downloadRetryButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS}`)
        );
        if (downloadRetryButton?.dataset.langTag) {
          this.retryDownloadLanguage(downloadRetryButton.dataset.langTag);
          break;
        }

        const alwaysRemoveButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`)
        );
        if (alwaysRemoveButton?.dataset.langTag) {
          this.removeAlwaysTranslateLanguage(
            alwaysRemoveButton.dataset.langTag
          );
          break;
        }

        const neverRemoveButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS}`)
        );
        if (neverRemoveButton?.dataset.langTag) {
          this.removeNeverTranslateLanguage(neverRemoveButton.dataset.langTag);
          break;
        }

        const neverSiteRemoveButton = /** @type {HTMLElement|null} */ (
          target.closest?.(`.${NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS}`)
        );
        if (neverSiteRemoveButton?.dataset.origin) {
          this.removeNeverTranslateSite(neverSiteRemoveButton.dataset.origin);
        }
        break;
      }
      case "unload":
        this.teardown();
        break;
    }
  },

  /**
   * Observer for translations pref changes.
   *
   * @param {any} subject
   * @param {string} topic
   * @param {string} data
   */

  observe(subject, topic, data) {
    if (topic === TOPIC_TRANSLATIONS_PREF_CHANGED) {
      if (data === ALWAYS_TRANSLATE_LANGS_PREF) {
        this.refreshAlwaysTranslateLanguages().catch(console.error);
      } else if (data === NEVER_TRANSLATE_LANGS_PREF) {
        this.refreshNeverTranslateLanguages().catch(console.error);
      }
    } else if (topic === "perm-changed") {
      this.handlePermissionChange(subject, data);
    }
  },

  /**
   * Runs when the translations sub-pane is shown.
   *
   * @param {string} category
   * @returns {Promise<void>}
   */

  async handlePaneShown(category) {
    if (category !== "paneTranslations") {
      return;
    }

    if (this.initPromise) {
      await this.initPromise;
      await this.refreshAlwaysTranslateLanguages();
      await this.refreshNeverTranslateLanguages();
      this.refreshNeverTranslateSites();
      await this.refreshDownloadedLanguages();
      this.dispatchInitializedTestEvent();
      return;
    }

    if (this.initialized) {
      await this.refreshAlwaysTranslateLanguages();
      await this.refreshNeverTranslateLanguages();
      this.refreshNeverTranslateSites();
      await this.refreshDownloadedLanguages();
      this.dispatchInitializedTestEvent();
      return;
    }

    this.initPromise = this.init();
    await this.initPromise;
    this.initPromise = null;
  },

  /**
   * Ensure the translations pane has finished rendering.
   *
   * @returns {Promise<void>}
   */

  async ensurePaneRendered() {
    if (this.paneRenderPromise) {
      await this.paneRenderPromise;
      return;
    }

    /**
     * @typedef {HTMLElement & { getUpdateComplete?: () => Promise<void> }} ElementWithUpdateComplete
     */

    const pane = /** @type {ElementWithUpdateComplete|null} */ (
      document.querySelector('setting-pane[data-category="paneTranslations"]')
    );
    const groups = Array.from(
      document.querySelectorAll(
        'setting-group[groupid="translationsAutomaticTranslation"], setting-group[groupid="translationsDownloadLanguages"]'
      )
    );

    const promises = [];
    if (pane?.getUpdateComplete) {
      promises.push(pane.getUpdateComplete());
    }
    for (const group of groups) {
      if (group?.getUpdateComplete) {
        promises.push(group.getUpdateComplete());
      }
    }

    if (promises.length) {
      this.paneRenderPromise = (async () => {
        const results = await Promise.allSettled(promises);
        const failure = results.find(result => result.status === "rejected");
        if (failure && failure.reason) {
          console.warn("Translations pane render wait failed", failure.reason);
        }
      })();
      await this.paneRenderPromise;
    }
  },

  /**
   * Initialize the translations settings UI.
   *
   * @returns {Promise<void>}
   */

  async init() {
    await this.ensurePaneRendered();
    this.cacheElements();
    if (
      !this.elements?.alwaysTranslateLanguagesGroup ||
      !this.elements?.alwaysTranslateLanguagesSelect ||
      !this.elements?.alwaysTranslateLanguagesButton ||
      !this.elements?.alwaysTranslateLanguagesNoneRow ||
      !this.elements?.neverTranslateLanguagesGroup ||
      !this.elements?.neverTranslateLanguagesSelect ||
      !this.elements?.neverTranslateLanguagesButton ||
      !this.elements?.neverTranslateLanguagesNoneRow ||
      !this.elements?.neverTranslateSitesGroup ||
      !this.elements?.downloadLanguagesGroup ||
      !this.elements?.downloadLanguagesSelect ||
      !this.elements?.downloadLanguagesButton ||
      !this.elements?.downloadLanguagesNoneRow
    ) {
      this.dispatchInitializedTestEvent();
      return;
    }

    try {
      this.numberFormatter = null;
      this.languageDisplayNames =
        TranslationsParent.createLanguageDisplayNames();
      this.supportedLanguages =
        await TranslationsParent.getSupportedLanguages();
      this.languageList = TranslationsParent.getLanguageList(
        this.supportedLanguages
      );
      await this.loadLanguageSizes();
      await this.refreshDownloadedLanguages();
    } catch (error) {
      console.error("Failed to initialize translations settings UI", error);
      this.elements.alwaysTranslateLanguagesSelect.disabled = true;
      this.elements.alwaysTranslateLanguagesButton.disabled = true;
      this.elements.neverTranslateLanguagesSelect.disabled = true;
      this.elements.neverTranslateLanguagesButton.disabled = true;
      this.elements.downloadLanguagesSelect.disabled = true;
      this.setDownloadLanguageButtonDisabledState(true);
      this.dispatchInitializedTestEvent();
      return;
    }

    this.elements.alwaysTranslateLanguagesSelect.disabled = false;
    this.elements.alwaysTranslateLanguagesButton.disabled = true;
    this.elements.neverTranslateLanguagesSelect.disabled = false;
    this.elements.neverTranslateLanguagesButton.disabled = true;
    this.elements.downloadLanguagesSelect.disabled = false;
    this.resetDownloadSelect();
    this.setDownloadLanguageButtonDisabledState(true);
    await this.buildAlwaysTranslateSelectOptions();
    await this.buildNeverTranslateSelectOptions();
    await this.buildDownloadSelectOptions();
    await this.renderDownloadLanguages();

    this.elements.alwaysTranslateLanguagesSelect.addEventListener(
      "change",
      this
    );
    this.elements.alwaysTranslateLanguagesButton.addEventListener(
      "click",
      this
    );
    this.elements.alwaysTranslateLanguagesGroup.addEventListener("click", this);
    this.elements.neverTranslateLanguagesSelect.addEventListener(
      "change",
      this
    );
    this.elements.neverTranslateLanguagesButton.addEventListener("click", this);
    this.elements.neverTranslateLanguagesGroup.addEventListener("click", this);
    this.elements.neverTranslateSitesGroup.addEventListener("click", this);
    this.elements.downloadLanguagesSelect.addEventListener("change", this);
    this.elements.downloadLanguagesGroup.addEventListener("click", this);
    this.elements.downloadLanguagesButton.addEventListener("click", this);
    Services.obs.addObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED);
    Services.obs.addObserver(this, "perm-changed");
    window.addEventListener("unload", this);

    await this.refreshAlwaysTranslateLanguages();
    await this.refreshNeverTranslateLanguages();
    this.refreshNeverTranslateSites();
    this.initialized = true;

    this.dispatchInitializedTestEvent();
  },

  /**
   * Dispatch the test-only Initialized event and mark the document as ready.
   */

  dispatchInitializedTestEvent() {
    dispatchTestEvent("Initialized");
  },

  /**
   * Cache the DOM elements we interact with.
   */

  cacheElements() {
    if (this.elements) {
      return;
    }

    const elements = {
      alwaysTranslateLanguagesGroup: /** @type {HTMLElement} */ (
        document.getElementById("translationsAlwaysTranslateLanguagesGroup")
      ),
      alwaysTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ (
        document.getElementById("translationsAlwaysTranslateLanguagesSelect")
      ),
      alwaysTranslateLanguagesButton: /** @type {HTMLButtonElement} */ (
        document.getElementById("translationsAlwaysTranslateLanguagesButton")
      ),
      alwaysTranslateLanguagesNoneRow: /** @type {HTMLElement} */ (
        document.getElementById("translationsAlwaysTranslateLanguagesNoneRow")
      ),
      neverTranslateLanguagesGroup: /** @type {HTMLElement} */ (
        document.getElementById("translationsNeverTranslateLanguagesGroup")
      ),
      neverTranslateLanguagesSelect: /** @type {HTMLSelectElement} */ (
        document.getElementById("translationsNeverTranslateLanguagesSelect")
      ),
      neverTranslateLanguagesButton: /** @type {HTMLButtonElement} */ (
        document.getElementById("translationsNeverTranslateLanguagesButton")
      ),
      neverTranslateLanguagesNoneRow: /** @type {HTMLElement} */ (
        document.getElementById("translationsNeverTranslateLanguagesNoneRow")
      ),
      neverTranslateSitesGroup: /** @type {HTMLElement} */ (
        document.getElementById("translationsNeverTranslateSitesGroup")
      ),
      neverTranslateSitesRow: /** @type {HTMLElement} */ (
        document.getElementById("translationsNeverTranslateSitesRow")
      ),
      neverTranslateSitesNoneRow: /** @type {HTMLElement} */ (
        document.getElementById("translationsNeverTranslateSitesNoneRow")
      ),
      downloadLanguagesGroup: /** @type {HTMLElement} */ (
        document.getElementById("translationsDownloadLanguagesGroup")
      ),
      downloadLanguagesSelect: /** @type {HTMLSelectElement} */ (
        document.getElementById("translationsDownloadLanguagesSelect")
      ),
      downloadLanguagesButton: /** @type {HTMLButtonElement} */ (
        document.getElementById("translationsDownloadLanguagesButton")
      ),
      downloadLanguagesNoneRow: /** @type {HTMLElement} */ (
        document.getElementById("translationsDownloadLanguagesNoneRow")
      ),
    };

    if (
      !elements.alwaysTranslateLanguagesGroup ||
      !elements.alwaysTranslateLanguagesSelect ||
      !elements.alwaysTranslateLanguagesNoneRow ||
      !elements.neverTranslateLanguagesGroup ||
      !elements.neverTranslateLanguagesSelect ||
      !elements.neverTranslateLanguagesNoneRow
    ) {
      return;
    }

    this.elements = elements;
  },

  /**
   * Load the download sizes for all supported languages and cache them.
   *
   * @returns {Promise<void>}
   */

  async loadLanguageSizes() {
    if (!this.languageList?.length) {
      this.languageSizes = new Map();
      return;
    }

    const sizes = await Promise.all(
      this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => {
        try {
          return /** @type {[string, number]} */ ([
            langTag,
            await TranslationsParent.getLanguageSize(langTag),
          ]);
        } catch (error) {
          console.error(`Failed to get size for ${langTag}`, error);
          return /** @type {[string, number]} */ ([langTag, 0]);
        }
      })
    );

    this.languageSizes = new Map(sizes);
  },

  /**
   * Format a download size for display.
   *
   * @param {string} langTag
   * @returns {string|null}
   */

  formatLanguageSize(langTag) {
    const sizeBytes = this.languageSizes?.get(langTag);
    if (!sizeBytes && sizeBytes !== 0) {
      return null;
    }

    const sizeInMB = sizeBytes / (1024 * 1024);
    if (!Number.isFinite(sizeInMB)) {
      return null;
    }

    return this.getNumberFormatter().format(sizeInMB);
  },

  /**
   * Lazily create and return a number formatter for the app locale.
   *
   * @returns {Intl.NumberFormat}
   */

  getNumberFormatter() {
    if (this.numberFormatter) {
      return this.numberFormatter;
    }
    this.numberFormatter = new Intl.NumberFormat(
      Services.locale.appLocaleAsBCP47,
      {
        minimumFractionDigits: 0,
        maximumFractionDigits: 1,
      }
    );
    return this.numberFormatter;
  },

  /**
   * Build the display label for a download language including its size.
   *
   * @param {string} langTag
   * @returns {Promise<string|null>}
   */

  async formatDownloadLabel(langTag) {
    const languageLabel = this.formatLanguageLabel(langTag) ?? langTag;
    const sizeLabel = this.formatLanguageSize(langTag);
    if (!sizeLabel) {
      return languageLabel;
    }
    try {
      return await document.l10n.formatValue(
        "settings-translations-subpage-download-language-option",
        { language: languageLabel, size: sizeLabel }
      );
    } catch (error) {
      console.error("Failed to format download language label", error);
      return `${languageLabel} (${sizeLabel})`;
    }
  },

  /**
   * Populate the select options for download languages with sizes.
   *
   * @returns {Promise<void>}
   */

  async buildDownloadSelectOptions() {
    const select = this.elements?.downloadLanguagesSelect;
    if (!select || !this.supportedLanguages?.sourceLanguages?.length) {
      return;
    }

    const placeholder = select.querySelector('moz-option[value=""]');
    for (const option of select.querySelectorAll("moz-option")) {
      if (option !== placeholder) {
        option.remove();
      }
    }

    const sourceLanguages = [...this.supportedLanguages.sourceLanguages]
      .filter(({ langTag }) => langTag !== "en")
      .sort((lhs, rhs) =>
        (
          this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName
        ).localeCompare(
          this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName
        )
      );
    for (const { langTag, displayName } of sourceLanguages) {
      const option = document.createElement("moz-option");
      option.setAttribute("value", langTag);
      const label =
        (await this.formatDownloadLabel(langTag)) ??
        this.formatLanguageLabel(langTag) ??
        displayName;
      option.setAttribute("label", label);
      const sizeLabel = this.formatLanguageSize(langTag) ?? "";
      if (sizeLabel) {
        document.l10n.setAttributes(
          option,
          "settings-translations-subpage-download-language-option",
          {
            language: this.formatLanguageLabel(langTag) ?? displayName,
            size: sizeLabel,
          }
        );
      }
      select.appendChild(option);
    }

    this.updateDownloadSelectOptionState();
    this.resetDownloadSelect();
  },

  /**
   * Disable already-downloaded or downloading languages in the download select.
   */

  updateDownloadSelectOptionState({ preserveSelection = false } = {}) {
    const select = this.elements?.downloadLanguagesSelect;
    if (!select) {
      return;
    }

    for (const option of select.querySelectorAll("moz-option")) {
      const value = option.getAttribute("value");
      if (!value) {
        continue;
      }
      const isDisabled =
        this.downloadedLanguageTags.has(value) ||
        this.downloadingLanguageTags.has(value);
      option.toggleAttribute("disabled", isDisabled);
    }

    if (preserveSelection) {
      this.updateDownloadLanguageButtonDisabled();
    } else {
      this.resetDownloadSelect();
    }
    dispatchTestEvent("DownloadedLanguagesSelectOptionsUpdated");
  },

  /**
   * Handle a selection in the "Always translate languages" dropdown.
   *
   * @param {string} langTag
   */

  async onAlwaysTranslateLanguageChosen(langTag) {
    if (!langTag) {
      this.updateAlwaysTranslateAddButtonDisabledState();
      return;
    }

    if (this.shouldDisableAlwaysTranslateAddButton()) {
      this.updateAlwaysTranslateAddButtonDisabledState();
      return;
    }

    TranslationsParent.addLangTagToPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF);
    TranslationsParent.removeLangTagFromPref(
      langTag,
      NEVER_TRANSLATE_LANGS_PREF
    );
    await this.resetAlwaysTranslateSelect();
  },

  /**
   * Handle a selection change in the always-translate dropdown.
   */

  onAlwaysTranslateLanguageSelectionChanged() {
    this.updateAlwaysTranslateAddButtonDisabledState();
  },

  /**
   * Whether the add button for always-translate languages should be disabled.
   *
   * @returns {boolean}
   */

  shouldDisableAlwaysTranslateAddButton() {
    const select = this.elements?.alwaysTranslateLanguagesSelect;
    if (!select || select.disabled) {
      return true;
    }

    const langTag = select.value;
    if (!langTag) {
      return true;
    }

    const option = /** @type {HTMLElement|null} */ (
      select.querySelector(`moz-option[value="${langTag}"]`)
    );
    return option?.hasAttribute("disabled") ?? false;
  },

  /**
   * Set the add button enabled state for always-translate languages.
   *
   * @param {boolean} isDisabled
   */

  setAlwaysTranslateAddButtonDisabledState(isDisabled) {
    if (!this.elements?.alwaysTranslateLanguagesButton) {
      return;
    }

    const wasDisabled = this.elements.alwaysTranslateLanguagesButton.disabled;
    this.elements.alwaysTranslateLanguagesButton.disabled = isDisabled;
    if (wasDisabled !== isDisabled) {
      dispatchTestEvent(
        isDisabled
          ? "AlwaysTranslateLanguagesAddButtonDisabled"
          : "AlwaysTranslateLanguagesAddButtonEnabled"
      );
    }
  },

  /**
   * Update the add button enabled state for always-translate languages.
   */

  updateAlwaysTranslateAddButtonDisabledState() {
    this.setAlwaysTranslateAddButtonDisabledState(
      this.shouldDisableAlwaysTranslateAddButton()
    );
  },

  /**
   * Remove the given language from the always translate list.
   *
   * @param {string} langTag
   */

  removeAlwaysTranslateLanguage(langTag) {
    TranslationsParent.removeLangTagFromPref(
      langTag,
      ALWAYS_TRANSLATE_LANGS_PREF
    );
  },

  async resetSelect(select, settingId) {
    const setting = Preferences.getSetting?.(settingId);
    if (setting) {
      setting.value = "";
    }

    if (!select) {
      return;
    }

    if (select.updateComplete) {
      await select.updateComplete;
    }

    select.value = "";
    if (select.inputEl) {
      select.inputEl.value = "";
    }

    if (select.updateComplete) {
      await select.updateComplete;
    }
  },

  /**
   * Reset the dropdown back to the placeholder value and underlying setting state.
   */

  async resetAlwaysTranslateSelect() {
    await this.resetSelect(
      this.elements?.alwaysTranslateLanguagesSelect,
      "translationsAlwaysTranslateLanguagesSelect"
    );
    this.updateAlwaysTranslateAddButtonDisabledState();
  },

  /**
   * Refresh the rendered list of always-translate languages to match prefs.
   */

  async refreshAlwaysTranslateLanguages() {
    if (!this.elements?.alwaysTranslateLanguagesGroup) {
      return;
    }

    const langTags = Array.from(
      TranslationsParent.getAlwaysTranslateLanguages?.() ?? []
    );

    if (this.alwaysTranslateLanguageTags) {
      for (const langTag of langTags) {
        if (this.alwaysTranslateLanguageTags.has(langTag)) {
          continue;
        }
        TranslationsParent.removeLangTagFromPref(
          langTag,
          NEVER_TRANSLATE_LANGS_PREF
        );
      }
    }

    this.alwaysTranslateLanguageTags = new Set(langTags);

    this.renderAlwaysTranslateLanguages(langTags);
    await this.updateAlwaysTranslateSelectOptionState();
  },

  /**
   * Render the current set of always-translate languages into the list UI.
   *
   * @param {string[]} langTags
   */

  renderAlwaysTranslateLanguages(langTags) {
    const { alwaysTranslateLanguagesGroup, alwaysTranslateLanguagesNoneRow } =
      this.elements;

    for (const item of alwaysTranslateLanguagesGroup.querySelectorAll(
      `.${ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS}`
    )) {
      item.remove();
    }

    const previousEmptyStateVisible =
      alwaysTranslateLanguagesNoneRow &&
      !alwaysTranslateLanguagesNoneRow.hidden;

    if (alwaysTranslateLanguagesNoneRow) {
      const hasLanguages = !!langTags.length;
      alwaysTranslateLanguagesNoneRow.hidden = hasLanguages;

      if (hasLanguages && alwaysTranslateLanguagesNoneRow.isConnected) {
        alwaysTranslateLanguagesNoneRow.remove();
      } else if (
        !hasLanguages &&
        !alwaysTranslateLanguagesNoneRow.isConnected
      ) {
        alwaysTranslateLanguagesGroup.appendChild(
          alwaysTranslateLanguagesNoneRow
        );
      }
    }

    const sortedLangTags = [...langTags].sort((langTagA, langTagB) => {
      const labelA = this.formatLanguageLabel(langTagA) ?? langTagA;
      const labelB = this.formatLanguageLabel(langTagB) ?? langTagB;
      return labelA.localeCompare(labelB);
    });

    for (const langTag of sortedLangTags) {
      const label = this.formatLanguageLabel(langTag);
      if (!label) {
        continue;
      }

      const removeButton = document.createElement("moz-button");
      removeButton.setAttribute("slot", "actions-start");
      removeButton.setAttribute("type", "icon");
      removeButton.setAttribute(
        "iconsrc",
        "chrome://global/skin/icons/delete.svg"
      );
      removeButton.classList.add(ALWAYS_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS);
      removeButton.dataset.langTag = langTag;
      removeButton.setAttribute("aria-label", label);

      const item = document.createElement("moz-box-item");
      item.classList.add(ALWAYS_TRANSLATE_LANGUAGE_ITEM_CLASS);
      item.setAttribute("label", label);
      item.dataset.langTag = langTag;
      item.appendChild(removeButton);
      if (
        alwaysTranslateLanguagesNoneRow &&
        alwaysTranslateLanguagesNoneRow.parentElement ===
          alwaysTranslateLanguagesGroup
      ) {
        alwaysTranslateLanguagesGroup.insertBefore(
          item,
          alwaysTranslateLanguagesNoneRow
        );
      } else {
        alwaysTranslateLanguagesGroup.appendChild(item);
      }
    }

    dispatchTestEvent("AlwaysTranslateLanguagesRendered", {
      languages: langTags,
      count: langTags.length,
    });

    const currentEmptyStateVisible =
      alwaysTranslateLanguagesNoneRow &&
      !alwaysTranslateLanguagesNoneRow.hidden;
    if (previousEmptyStateVisible && !currentEmptyStateVisible) {
      dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateHidden");
    } else if (!previousEmptyStateVisible && currentEmptyStateVisible) {
      dispatchTestEvent("AlwaysTranslateLanguagesEmptyStateShown");
    }
  },

  /**
   * Format a language tag for display using the cached display names.
   *
   * @param {string} langTag
   * @returns {string|null}
   */

  formatLanguageLabel(langTag) {
    try {
      return this.languageDisplayNames?.of(langTag) ?? null;
    } catch (error) {
      console.warn(`Failed to format language label for ${langTag}`, error);
      return null;
    }
  },

  /**
   * Populate the select options for the supported source languages.
   */

  async buildAlwaysTranslateSelectOptions() {
    const select = this.elements?.alwaysTranslateLanguagesSelect;
    if (!select || !this.supportedLanguages?.sourceLanguages?.length) {
      return;
    }

    const placeholder = select.querySelector('moz-option[value=""]');
    for (const option of select.querySelectorAll("moz-option")) {
      if (option !== placeholder) {
        option.remove();
      }
    }

    const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort(
      (lhs, rhs) =>
        (
          this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName
        ).localeCompare(
          this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName
        )
    );
    for (const { langTag, displayName } of sourceLanguages) {
      const option = document.createElement("moz-option");
      option.setAttribute("value", langTag);
      option.setAttribute(
        "label",
        this.formatLanguageLabel(langTag) ?? displayName
      );
      select.appendChild(option);
    }

    await this.resetAlwaysTranslateSelect();
  },

  /**
   * Disable already-added languages in the select so they cannot be re-added.
   */

  async updateAlwaysTranslateSelectOptionState() {
    const select = this.elements?.alwaysTranslateLanguagesSelect;
    if (!select) {
      return;
    }

    for (const option of select.querySelectorAll("moz-option")) {
      const value = option.getAttribute("value");
      if (!value) {
        continue;
      }
      option.disabled = this.alwaysTranslateLanguageTags.has(value);
    }

    await this.resetAlwaysTranslateSelect();

    dispatchTestEvent("AlwaysTranslateLanguagesSelectOptionsUpdated");
  },

  /**
   * Handle a selection in the "Never translate languages" dropdown.
   *
   * @param {string} langTag
   */

  async onNeverTranslateLanguageChosen(langTag) {
    if (!langTag) {
      this.updateNeverTranslateAddButtonDisabledState();
      return;
    }

    if (this.shouldDisableNeverTranslateAddButton()) {
      this.updateNeverTranslateAddButtonDisabledState();
      return;
    }

    TranslationsParent.addLangTagToPref(langTag, NEVER_TRANSLATE_LANGS_PREF);
    TranslationsParent.removeLangTagFromPref(
      langTag,
      ALWAYS_TRANSLATE_LANGS_PREF
    );
    await this.resetNeverTranslateSelect();
  },

  /**
   * Handle a selection change in the never-translate dropdown.
   */

  onNeverTranslateLanguageSelectionChanged() {
    this.updateNeverTranslateAddButtonDisabledState();
  },

  /**
   * Whether the add button for never-translate languages should be disabled.
   *
   * @returns {boolean}
   */

  shouldDisableNeverTranslateAddButton() {
    const select = this.elements?.neverTranslateLanguagesSelect;
    if (!select || select.disabled) {
      return true;
    }

    const langTag = select.value;
    if (!langTag) {
      return true;
    }

    const option = /** @type {HTMLElement|null} */ (
      select.querySelector(`moz-option[value="${langTag}"]`)
    );
    return option?.hasAttribute("disabled") ?? false;
  },

  /**
   * Set the add button enabled state for never-translate languages.
   *
   * @param {boolean} isDisabled
   */

  setNeverTranslateAddButtonDisabledState(isDisabled) {
    if (!this.elements?.neverTranslateLanguagesButton) {
      return;
    }

    const wasDisabled = this.elements.neverTranslateLanguagesButton.disabled;
    this.elements.neverTranslateLanguagesButton.disabled = isDisabled;
    if (wasDisabled !== isDisabled) {
      dispatchTestEvent(
        isDisabled
          ? "NeverTranslateLanguagesAddButtonDisabled"
          : "NeverTranslateLanguagesAddButtonEnabled"
      );
    }
  },

  /**
   * Update the add button enabled state for never-translate languages.
   */

  updateNeverTranslateAddButtonDisabledState() {
    this.setNeverTranslateAddButtonDisabledState(
      this.shouldDisableNeverTranslateAddButton()
    );
  },

  /**
   * Remove the given language from the never translate list.
   *
   * @param {string} langTag
   */

  removeNeverTranslateLanguage(langTag) {
    TranslationsParent.removeLangTagFromPref(
      langTag,
      NEVER_TRANSLATE_LANGS_PREF
    );
  },

  /**
   * Reset the dropdown back to the placeholder value and underlying setting state.
   */

  async resetNeverTranslateSelect() {
    await this.resetSelect(
      this.elements?.neverTranslateLanguagesSelect,
      "translationsNeverTranslateLanguagesSelect"
    );
    this.updateNeverTranslateAddButtonDisabledState();
  },

  /**
   * Refresh the rendered list of never-translate languages to match prefs.
   */

  async refreshNeverTranslateLanguages() {
    if (!this.elements?.neverTranslateLanguagesGroup) {
      return;
    }

    const langTags = Array.from(
      TranslationsParent.getNeverTranslateLanguages?.() ?? []
    );
    this.neverTranslateLanguageTags = new Set(langTags);

    this.renderNeverTranslateLanguages(langTags);
    await this.updateNeverTranslateSelectOptionState();
  },

  /**
   * Render the current set of never-translate languages into the list UI.
   *
   * @param {string[]} langTags
   */

  renderNeverTranslateLanguages(langTags) {
    const { neverTranslateLanguagesGroup, neverTranslateLanguagesNoneRow } =
      this.elements;

    for (const item of neverTranslateLanguagesGroup.querySelectorAll(
      `.${NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS}`
    )) {
      item.remove();
    }

    const previousEmptyStateVisible =
      neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden;

    if (neverTranslateLanguagesNoneRow) {
      const hasLanguages = Boolean(langTags.length);
      neverTranslateLanguagesNoneRow.hidden = hasLanguages;

      if (hasLanguages && neverTranslateLanguagesNoneRow.isConnected) {
        neverTranslateLanguagesNoneRow.remove();
      } else if (!hasLanguages && !neverTranslateLanguagesNoneRow.isConnected) {
        neverTranslateLanguagesGroup.appendChild(
          neverTranslateLanguagesNoneRow
        );
      }
    }

    const sortedLangTags = [...langTags].sort((langTagA, langTagB) => {
      const labelA = this.formatLanguageLabel(langTagA) ?? langTagA;
      const labelB = this.formatLanguageLabel(langTagB) ?? langTagB;
      return labelA.localeCompare(labelB);
    });

    for (const langTag of sortedLangTags) {
      const label = this.formatLanguageLabel(langTag);
      if (!label) {
        continue;
      }

      const removeButton = document.createElement("moz-button");
      removeButton.setAttribute("slot", "actions-start");
      removeButton.setAttribute("type", "icon");
      removeButton.setAttribute(
        "iconsrc",
        "chrome://global/skin/icons/delete.svg"
      );
      removeButton.classList.add(NEVER_TRANSLATE_LANGUAGE_REMOVE_BUTTON_CLASS);
      removeButton.dataset.langTag = langTag;
      removeButton.setAttribute("aria-label", label);

      const item = document.createElement("moz-box-item");
      item.classList.add(NEVER_TRANSLATE_LANGUAGE_ITEM_CLASS);
      item.setAttribute("label", label);
      item.dataset.langTag = langTag;
      item.appendChild(removeButton);
      if (
        neverTranslateLanguagesNoneRow &&
        neverTranslateLanguagesNoneRow.parentElement ===
          neverTranslateLanguagesGroup
      ) {
        neverTranslateLanguagesGroup.insertBefore(
          item,
          neverTranslateLanguagesNoneRow
        );
      } else {
        neverTranslateLanguagesGroup.appendChild(item);
      }
    }

    dispatchTestEvent("NeverTranslateLanguagesRendered", {
      languages: langTags,
      count: langTags.length,
    });

    const currentEmptyStateVisible =
      neverTranslateLanguagesNoneRow && !neverTranslateLanguagesNoneRow.hidden;
    if (previousEmptyStateVisible && !currentEmptyStateVisible) {
      dispatchTestEvent("NeverTranslateLanguagesEmptyStateHidden");
    } else if (!previousEmptyStateVisible && currentEmptyStateVisible) {
      dispatchTestEvent("NeverTranslateLanguagesEmptyStateShown");
    }
  },

  /**
   * Populate the select options for the supported source languages.
   */

  async buildNeverTranslateSelectOptions() {
    const select = this.elements?.neverTranslateLanguagesSelect;
    if (!select || !this.supportedLanguages?.sourceLanguages?.length) {
      return;
    }

    const placeholder = select.querySelector('moz-option[value=""]');
    for (const option of select.querySelectorAll("moz-option")) {
      if (option !== placeholder) {
        option.remove();
      }
    }

    const sourceLanguages = [...this.supportedLanguages.sourceLanguages].sort(
      (lhs, rhs) =>
        (
          this.formatLanguageLabel(lhs.langTag) ?? lhs.displayName
        ).localeCompare(
          this.formatLanguageLabel(rhs.langTag) ?? rhs.displayName
        )
    );
    for (const { langTag, displayName } of sourceLanguages) {
      const option = document.createElement("moz-option");
      option.setAttribute("value", langTag);
      option.setAttribute(
        "label",
        this.formatLanguageLabel(langTag) ?? displayName
      );
      select.appendChild(option);
    }

    await this.resetNeverTranslateSelect();
  },

  /**
   * Disable already-added languages in the select so they cannot be re-added.
   */

  async updateNeverTranslateSelectOptionState() {
    const select = this.elements?.neverTranslateLanguagesSelect;
    if (!select) {
      return;
    }

    for (const option of select.querySelectorAll("moz-option")) {
      const value = option.getAttribute("value");
      if (!value) {
        continue;
      }
      option.disabled = this.neverTranslateLanguageTags.has(value);
    }

    await this.resetNeverTranslateSelect();

    dispatchTestEvent("NeverTranslateLanguagesSelectOptionsUpdated");
  },

  /**
   * Refresh the rendered list of never-translate sites.
   */

  refreshNeverTranslateSites() {
    if (!this.elements?.neverTranslateSitesGroup) {
      return;
    }

    /** @type {string[]} */
    let siteOrigins = [];
    try {
      siteOrigins = TranslationsParent.listNeverTranslateSites() ?? [];
    } catch (error) {
      console.error("Failed to list never translate sites", error);
    }

    this.neverTranslateSiteOrigins = new Set(siteOrigins);
    this.renderNeverTranslateSites(siteOrigins);
  },

  /**
   * Render the never-translate sites list.
   *
   * @param {string[]} siteOrigins
   */

  renderNeverTranslateSites(siteOrigins) {
    const { neverTranslateSitesGroup, neverTranslateSitesNoneRow } =
      this.elements ?? {};
    if (!neverTranslateSitesGroup) {
      return;
    }

    for (const item of neverTranslateSitesGroup.querySelectorAll(
      `.${NEVER_TRANSLATE_SITE_ITEM_CLASS}`
    )) {
      item.remove();
    }

    const previousEmptyStateVisible =
      neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden;

    if (neverTranslateSitesNoneRow) {
      const hasSites = Boolean(siteOrigins.length);
      neverTranslateSitesNoneRow.hidden = hasSites;

      if (hasSites && neverTranslateSitesNoneRow.isConnected) {
        neverTranslateSitesNoneRow.remove();
      } else if (!hasSites && !neverTranslateSitesNoneRow.isConnected) {
        neverTranslateSitesGroup.appendChild(neverTranslateSitesNoneRow);
      }
    }

    const sortedOrigins = [...siteOrigins].sort((originA, originB) => {
      return this.getSiteSortKey(originA).localeCompare(
        this.getSiteSortKey(originB)
      );
    });

    for (const origin of sortedOrigins) {
      const removeButton = document.createElement("moz-button");
      removeButton.setAttribute("slot", "actions-start");
      removeButton.setAttribute("type", "icon");
      removeButton.setAttribute(
        "iconsrc",
        "chrome://global/skin/icons/delete.svg"
      );
      removeButton.classList.add(NEVER_TRANSLATE_SITE_REMOVE_BUTTON_CLASS);
      removeButton.dataset.origin = origin;
      removeButton.setAttribute("aria-label", origin);

      const item = document.createElement("moz-box-item");
      item.classList.add(NEVER_TRANSLATE_SITE_ITEM_CLASS);
      item.setAttribute("label", origin);
      item.dataset.origin = origin;
      item.appendChild(removeButton);
      if (
        neverTranslateSitesNoneRow &&
        neverTranslateSitesNoneRow.parentElement === neverTranslateSitesGroup
      ) {
        neverTranslateSitesGroup.insertBefore(item, neverTranslateSitesNoneRow);
      } else {
        neverTranslateSitesGroup.appendChild(item);
      }
    }

    dispatchTestEvent("NeverTranslateSitesRendered", {
      sites: siteOrigins,
      count: siteOrigins.length,
    });

    const currentEmptyStateVisible =
      neverTranslateSitesNoneRow && !neverTranslateSitesNoneRow.hidden;
    if (previousEmptyStateVisible && !currentEmptyStateVisible) {
      dispatchTestEvent("NeverTranslateSitesEmptyStateHidden");
    } else if (!previousEmptyStateVisible && currentEmptyStateVisible) {
      dispatchTestEvent("NeverTranslateSitesEmptyStateShown");
    }
  },

  /**
   * Remove a site from the never-translate list.
   *
   * @param {string} origin
   */

  removeNeverTranslateSite(origin) {
    if (!origin || !this.neverTranslateSiteOrigins.has(origin)) {
      return;
    }

    try {
      TranslationsParent.setNeverTranslateSiteByOrigin(false, origin);
    } catch (error) {
      console.error("Failed to remove never translate site", error);
      return;
    }

    this.refreshNeverTranslateSites();
  },

  /**
   * Create a sort key that ignores protocol differences.
   *
   * @param {string} origin
   * @returns {string}
   */

  getSiteSortKey(origin) {
    try {
      return Services.io.newURI(origin).asciiHostPort;
    } catch {
      return origin;
    }
  },

  /**
   * Handle a selection change in the download dropdown.
   */

  onDownloadSelectionChanged() {
    this.updateDownloadLanguageButtonDisabled();
  },

  /**
   * Whether the download button should be disabled based on selection state.
   *
   * @returns {boolean}
   */

  shouldDisableDownloadLanguageButton() {
    const select = this.elements?.downloadLanguagesSelect;
    if (!select || this.currentDownloadLangTag) {
      return true;
    }

    const langTag = select.value;
    if (!langTag) {
      return true;
    }

    const option = /** @type {HTMLElement|null} */ (
      select.querySelector(`moz-option[value="${langTag}"]`)
    );
    return option?.hasAttribute("disabled") ?? false;
  },

  /**
   * Set the download button state and dispatch test events when it changes.
   *
   * @param {boolean} isDisabled
   */

  setDownloadLanguageButtonDisabledState(isDisabled) {
    const button = this.elements?.downloadLanguagesButton;
    if (!button) {
      return;
    }

    const wasDisabled = button.disabled;
    button.disabled = isDisabled;

    if (wasDisabled !== isDisabled) {
      dispatchTestEvent(
        isDisabled
          ? "DownloadLanguageButtonDisabled"
          : "DownloadLanguageButtonEnabled"
      );
    }
  },

  /**
   * Update the enabled state of the download button.
   */

  updateDownloadLanguageButtonDisabled() {
    this.setDownloadLanguageButtonDisabledState(
      this.shouldDisableDownloadLanguageButton()
    );
  },

  /**
   * Handle a click on the download button.
   *
   * @returns {Promise<void>}
   */

  async onDownloadLanguageButtonClicked() {
    const langTag = this.elements?.downloadLanguagesSelect?.value;
    if (!langTag || this.currentDownloadLangTag) {
      return;
    }

    this.downloadPendingDeleteLanguageTags.clear();
    this.downloadFailedLanguageTags.clear();
    this.currentDownloadLangTag = langTag;
    this.downloadingLanguageTags.add(langTag);
    this.setDownloadControlsDisabled(true);
    dispatchTestEvent("DownloadStarted", { langTag });
    await this.renderDownloadLanguages();
    this.updateDownloadSelectOptionState({ preserveSelection: true });

    let downloadSucceeded = false;
    try {
      await TranslationsParent.downloadLanguageFiles(langTag);
      this.downloadedLanguageTags.add(langTag);
      downloadSucceeded = true;
      dispatchTestEvent("DownloadCompleted", { langTag });
    } catch (error) {
      dispatchTestEvent("DownloadFailed", { langTag });
      console.error("Failed to download language files", error);
      this.downloadFailedLanguageTags.add(langTag);
    } finally {
      this.downloadingLanguageTags.delete(langTag);
      this.currentDownloadLangTag = null;
      this.setDownloadControlsDisabled(false);
      await this.renderDownloadLanguages();
      this.updateDownloadSelectOptionState({
        preserveSelection: !downloadSucceeded,
      });
      this.updateDownloadLanguageButtonDisabled();
    }
  },

  /**
   * Disable or enable the download controls.
   *
   * @param {boolean} isDisabled
   */

  setDownloadControlsDisabled(isDisabled) {
    if (this.elements?.downloadLanguagesSelect) {
      this.elements.downloadLanguagesSelect.disabled = isDisabled;
    }
    this.setDownloadLanguageButtonDisabledState(
      isDisabled || this.shouldDisableDownloadLanguageButton()
    );
  },

  /**
   * Toggle ghost styling on icon buttons.
   *
   * @param {HTMLElement|null} button
   * @param {boolean} isGhost
   */

  setIconButtonGhostState(button, isGhost) {
    if (!button) {
      return;
    }
    const type = isGhost ? "icon ghost" : "icon";
    if (button.getAttribute("type") !== type) {
      button.setAttribute("type", type);
    }
  },

  /**
   * Reset the download dropdown back to its placeholder value.
   */

  resetDownloadSelect() {
    if (this.elements?.downloadLanguagesSelect) {
      this.elements.downloadLanguagesSelect.value = "";
    }
    const setting = Preferences.getSetting?.(
      "translationsDownloadLanguagesSelect"
    );
    if (setting) {
      setting.value = "";
    }
    this.updateDownloadLanguageButtonDisabled();
  },

  /**
   * Refresh download state from disk and update the UI.
   *
   * @returns {Promise<void>}
   */

  async refreshDownloadedLanguages() {
    if (!this.languageList?.length) {
      return;
    }

    this.downloadPendingDeleteLanguageTags.clear();
    const downloaded = await Promise.all(
      this.languageList.map(async (/** @type {LanguageInfo} */ { langTag }) => {
        try {
          const hasFiles =
            await TranslationsParent.hasAllFilesForLanguage(langTag);
          return /** @type {[string, boolean]} */ ([langTag, hasFiles]);
        } catch (error) {
          console.error(
            `Failed to check download status for ${langTag}`,
            error
          );
          return /** @type {[string, boolean]} */ ([langTag, false]);
        }
      })
    );

    this.downloadedLanguageTags = new Set(
      downloaded.filter(([, isDownloaded]) => isDownloaded).map(([tag]) => tag)
    );

    for (const [langTag, isDownloaded] of downloaded) {
      if (isDownloaded) {
        this.downloadingLanguageTags.delete(langTag);
        this.downloadFailedLanguageTags.delete(langTag);
      } else {
        this.downloadPendingDeleteLanguageTags.delete(langTag);
      }
    }

    await this.renderDownloadLanguages();
    this.updateDownloadSelectOptionState();
    this.updateDownloadLanguageButtonDisabled();
  },

  /**
   * Create a delete confirmation item with warning icon and action buttons.
   *
   * @param {string} langTag
   * @param {HTMLElement} item - The moz-box-item element to populate.
   * @returns {Promise<void>}
   */

  async createDeleteConfirmationItem(langTag, item, disableActions = false) {
    const warningButton = document.createElement("moz-button");
    warningButton.setAttribute("slot", "actions-start");
    warningButton.setAttribute("type", "icon");
    warningButton.setAttribute("iconsrc", DOWNLOAD_WARNING_ICON);
    warningButton.style.pointerEvents = "none";
    warningButton.style.color = "var(--icon-color-warning)";
    warningButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS);
    warningButton.dataset.langTag = langTag;
    this.setIconButtonGhostState(warningButton, true);

    const sizeLabel = this.formatLanguageSize(langTag) ?? "0";
    const languageLabel = this.formatLanguageLabel(langTag) ?? langTag;

    const confirmContent = document.createElement("div");
    confirmContent.style.cssText =
      "display: flex; align-items: center; gap: var(--space-small);";

    const confirmText = document.createElement("span");
    confirmText.textContent = await document.l10n.formatValue(
      "settings-translations-subpage-download-delete-confirm",
      { language: languageLabel, size: sizeLabel }
    );

    const buttonGroup = document.createElement("moz-button-group");

    const deleteButton = document.createElement("moz-button");
    deleteButton.setAttribute("type", "destructive");
    deleteButton.setAttribute("size", "small");
    deleteButton.disabled = disableActions;
    document.l10n.setAttributes(
      deleteButton,
      "settings-translations-subpage-download-delete-button"
    );
    deleteButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CONFIRM_BUTTON_CLASS);
    deleteButton.dataset.langTag = langTag;

    const cancelButton = document.createElement("moz-button");
    cancelButton.setAttribute("type", "default");
    cancelButton.setAttribute("size", "small");
    cancelButton.disabled = disableActions;
    document.l10n.setAttributes(
      cancelButton,
      "settings-translations-subpage-download-cancel-button"
    );
    cancelButton.classList.add(DOWNLOAD_LANGUAGE_DELETE_CANCEL_BUTTON_CLASS);
    cancelButton.dataset.langTag = langTag;

    confirmContent.appendChild(confirmText);
    buttonGroup.append(deleteButton, cancelButton);
    confirmContent.appendChild(buttonGroup);

    if (!deleteButton.disabled) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          if (deleteButton.isConnected) {
            deleteButton.focus({ focusVisible: true });
          }
        });
      });
    }

    item.appendChild(warningButton);
    item.appendChild(confirmContent);
  },

  /**
   * Create a failed download item with error icon and retry button.
   *
   * @param {string} langTag
   * @param {HTMLElement} item - The moz-box-item element to populate.
   * @returns {Promise<void>}
   */

  async createFailedDownloadItem(langTag, item, disableActions = false) {
    const errorButton = document.createElement("moz-button");
    errorButton.setAttribute("slot", "actions-start");
    errorButton.setAttribute("type", "icon");
    errorButton.setAttribute("iconsrc", DOWNLOAD_ERROR_ICON);
    errorButton.style.pointerEvents = "none";
    errorButton.style.color = "var(--text-color-error)";
    errorButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS);
    errorButton.dataset.langTag = langTag;
    this.setIconButtonGhostState(errorButton, true);

    const sizeLabel = this.formatLanguageSize(langTag) ?? "0";
    const languageLabel = this.formatLanguageLabel(langTag) ?? langTag;

    const errorContent = document.createElement("div");
    errorContent.style.cssText =
      "display: flex; align-items: center; gap: var(--space-small);";

    const errorText = document.createElement("span");
    document.l10n.setAttributes(
      errorText,
      "settings-translations-subpage-download-error",
      { language: languageLabel, size: sizeLabel }
    );

    const retryButton = document.createElement("moz-button");
    retryButton.setAttribute("type", "text");
    retryButton.setAttribute("size", "small");
    retryButton.disabled = disableActions;
    document.l10n.setAttributes(
      retryButton,
      "settings-translations-subpage-download-retry-button"
    );
    retryButton.classList.add(DOWNLOAD_LANGUAGE_RETRY_BUTTON_CLASS);
    retryButton.dataset.langTag = langTag;

    errorContent.appendChild(errorText);
    errorContent.appendChild(retryButton);

    if (!retryButton.disabled) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          if (retryButton.isConnected) {
            retryButton.focus({ focusVisible: true });
          }
        });
      });
    }

    item.appendChild(errorButton);
    item.appendChild(errorContent);
  },

  /**
   * Create a download/remove button for downloaded or downloading language items.
   *
   * @param {string} langTag
   * @param {boolean} isDownloading
   * @param {HTMLElement} item - The moz-box-item element to populate.
   * @param {string} progressLabel - The localized "Downloading..." text.
   * @returns {Promise<boolean>} - Returns false if the item should be skipped.
   */

  async createDownloadLanguageItem(
    langTag,
    isDownloading,
    item,
    progressLabel,
    disableActions = false
  ) {
    const label = await this.formatDownloadLabel(langTag);
    if (!label) {
      return false;
    }

    const removeButton = document.createElement("moz-button");
    removeButton.setAttribute("slot", "actions-start");
    removeButton.setAttribute("type", "icon");
    removeButton.setAttribute(
      "iconsrc",
      isDownloading ? DOWNLOAD_LOADING_ICON : DOWNLOAD_DELETE_ICON
    );
    removeButton.classList.add(DOWNLOAD_LANGUAGE_REMOVE_BUTTON_CLASS);
    removeButton.dataset.langTag = langTag;
    removeButton.setAttribute("aria-label", label);
    if (isDownloading) {
      removeButton.style.pointerEvents = "none";
      removeButton.disabled = false;
    } else {
      removeButton.disabled = disableActions;
    }
    this.setIconButtonGhostState(
      removeButton,
      isDownloading ||
        removeButton.getAttribute("iconsrc") === DOWNLOAD_LOADING_ICON
    );

    item.setAttribute("label", label);
    if (isDownloading) {
      item.setAttribute("description", progressLabel);
    }

    item.appendChild(removeButton);
    return true;
  },

  /**
   * Render the downloaded (and downloading) languages list.
   *
   * @returns {Promise<void>}
   */

  async renderDownloadLanguages() {
    const { downloadLanguagesGroup, downloadLanguagesNoneRow } =
      this.elements ?? {};
    if (!downloadLanguagesGroup) {
      return;
    }

    const isDownloadInProgress = Boolean(this.currentDownloadLangTag);
    const previousEmptyStateVisible =
      downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden;

    for (const item of downloadLanguagesGroup.querySelectorAll(
      `.${DOWNLOAD_LANGUAGE_ITEM_CLASS}`
    )) {
      item.remove();
    }

    const langTags = [
      ...Array.from(
        new Set([
          ...Array.from(this.downloadedLanguageTags),
          ...Array.from(this.downloadingLanguageTags),
          ...Array.from(this.downloadFailedLanguageTags),
        ])
      ),
    ];

    if (downloadLanguagesNoneRow) {
      const hasLanguages = !!langTags.length;
      downloadLanguagesNoneRow.hidden = hasLanguages;

      if (hasLanguages && downloadLanguagesNoneRow.isConnected) {
        downloadLanguagesNoneRow.remove();
      } else if (!hasLanguages && !downloadLanguagesNoneRow.isConnected) {
        downloadLanguagesGroup.appendChild(downloadLanguagesNoneRow);
      }
    }

    const currentEmptyStateVisible =
      downloadLanguagesNoneRow && !downloadLanguagesNoneRow.hidden;
    if (previousEmptyStateVisible && !currentEmptyStateVisible) {
      dispatchTestEvent("DownloadedLanguagesEmptyStateHidden");
    } else if (!previousEmptyStateVisible && currentEmptyStateVisible) {
      dispatchTestEvent("DownloadedLanguagesEmptyStateShown");
    }

    const sortedLangTags = [...langTags].sort((lhs, rhs) => {
      const labelA = this.formatLanguageLabel(lhs) ?? lhs;
      const labelB = this.formatLanguageLabel(rhs) ?? rhs;
      return labelA.localeCompare(labelB);
    });

    const progressLabel = await document.l10n.formatValue(
      "settings-translations-subpage-download-progress"
    );

    for (const langTag of sortedLangTags) {
      const isDownloading = this.downloadingLanguageTags.has(langTag);
      const isFailed = this.downloadFailedLanguageTags.has(langTag);
      const isPendingDelete =
        this.downloadPendingDeleteLanguageTags.has(langTag);

      const item = document.createElement("moz-box-item");
      item.classList.add(DOWNLOAD_LANGUAGE_ITEM_CLASS);
      item.dataset.langTag = langTag;

      if (isPendingDelete) {
        await this.createDeleteConfirmationItem(
          langTag,
          item,
          isDownloadInProgress
        );
      } else if (isFailed) {
        item.classList.add(DOWNLOAD_LANGUAGE_FAILED_CLASS);
        await this.createFailedDownloadItem(
          langTag,
          item,
          isDownloadInProgress
        );
      } else {
        const shouldAdd = await this.createDownloadLanguageItem(
          langTag,
          isDownloading,
          item,
          progressLabel,
          isDownloadInProgress
        );
        if (!shouldAdd) {
          continue;
        }
      }

      if (
        downloadLanguagesNoneRow &&
        downloadLanguagesNoneRow.parentElement === downloadLanguagesGroup
      ) {
        downloadLanguagesGroup.insertBefore(item, downloadLanguagesNoneRow);
      } else {
        downloadLanguagesGroup.appendChild(item);
      }
    }

    dispatchTestEvent("DownloadedLanguagesRendered", {
      languages: sortedLangTags,
      count: sortedLangTags.length,
      downloading: sortedLangTags.filter(langTag =>
        this.downloadingLanguageTags.has(langTag)
      ),
    });
  },

  /**
   * Show delete confirmation UI when delete button is clicked.
   *
   * @param {string} langTag
   * @returns {Promise<void>}
   */

  async onDeleteButtonClicked(langTag) {
    if (!langTag || !this.downloadedLanguageTags.has(langTag)) {
      return;
    }

    this.downloadFailedLanguageTags.clear();
    this.downloadPendingDeleteLanguageTags.clear();
    this.downloadPendingDeleteLanguageTags.add(langTag);
    await this.renderDownloadLanguages();
  },

  /**
   * Confirm and complete deletion of a language.
   *
   * @param {string} langTag
   * @returns {Promise<void>}
   */

  async confirmDeleteLanguage(langTag) {
    if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) {
      return;
    }

    this.downloadPendingDeleteLanguageTags.delete(langTag);

    try {
      await TranslationsParent.deleteLanguageFiles(langTag);
      this.downloadedLanguageTags.delete(langTag);
      dispatchTestEvent("DownloadDeleted", { langTag });
    } catch (error) {
      console.error("Failed to remove downloaded language files", error);
      await this.renderDownloadLanguages();
      return;
    }

    await this.renderDownloadLanguages();
    this.updateDownloadSelectOptionState();
    this.updateDownloadLanguageButtonDisabled();
  },

  /**
   * Cancel delete confirmation and restore normal state.
   *
   * @param {string} langTag
   * @returns {Promise<void>}
   */

  async cancelDeleteLanguage(langTag) {
    if (!langTag || !this.downloadPendingDeleteLanguageTags.has(langTag)) {
      return;
    }

    this.downloadPendingDeleteLanguageTags.delete(langTag);
    await this.renderDownloadLanguages();
  },

  /**
   * Retry downloading a failed language.
   *
   * @param {string} langTag
   * @returns {Promise<void>}
   */

  async retryDownloadLanguage(langTag) {
    if (!langTag || !this.downloadFailedLanguageTags.has(langTag)) {
      return;
    }

    this.downloadFailedLanguageTags.delete(langTag);
    this.currentDownloadLangTag = langTag;
    this.downloadingLanguageTags.add(langTag);
    this.setDownloadControlsDisabled(true);
    dispatchTestEvent("DownloadStarted", { langTag });
    await this.renderDownloadLanguages();
    this.updateDownloadSelectOptionState({ preserveSelection: true });

    let downloadSucceeded = false;
    try {
      await TranslationsParent.downloadLanguageFiles(langTag);
      this.downloadedLanguageTags.add(langTag);
      downloadSucceeded = true;
      dispatchTestEvent("DownloadCompleted", { langTag });
    } catch (error) {
      console.error("Failed to download language files", error);
      this.downloadFailedLanguageTags.add(langTag);
      dispatchTestEvent("DownloadFailed", { langTag });
    } finally {
      this.downloadingLanguageTags.delete(langTag);
      this.currentDownloadLangTag = null;
      this.setDownloadControlsDisabled(false);
      await this.renderDownloadLanguages();
      this.updateDownloadSelectOptionState({
        preserveSelection: !downloadSucceeded,
      });
      this.updateDownloadLanguageButtonDisabled();
    }
  },

  /**
   * Handle updates to translations permissions.
   *
   * @param {nsISupports} subject
   * @param {string} data
   */

  handlePermissionChange(subject, data) {
    if (data === "cleared") {
      this.neverTranslateSiteOrigins = new Set();
      this.renderNeverTranslateSites([]);
      return;
    }

    const perm = subject?.QueryInterface?.(Ci.nsIPermission);
    if (perm?.type !== TRANSLATIONS_PERMISSION) {
      return;
    }

    this.refreshNeverTranslateSites();
  },

  /**
   * Remove observers and listeners added during init.
   */

  teardown() {
    try {
      Services.obs.removeObserver(this, TOPIC_TRANSLATIONS_PREF_CHANGED);
      Services.obs.removeObserver(this, "perm-changed");
    } catch (e) {
      // Ignore if we were never added.
    }
    document.removeEventListener("paneshown", this);
    window.removeEventListener("unload", this);
    this.elements?.alwaysTranslateLanguagesSelect?.removeEventListener(
      "change",
      this
    );
    this.elements?.alwaysTranslateLanguagesGroup?.removeEventListener(
      "click",
      this
    );
    this.elements?.alwaysTranslateLanguagesButton?.removeEventListener(
      "click",
      this
    );
    this.elements?.neverTranslateLanguagesSelect?.removeEventListener(
      "change",
      this
    );
    this.elements?.neverTranslateLanguagesButton?.removeEventListener(
      "click",
      this
    );
    this.elements?.neverTranslateLanguagesGroup?.removeEventListener(
      "click",
      this
    );
    this.elements?.neverTranslateSitesGroup?.removeEventListener("click", this);
    this.elements?.downloadLanguagesSelect?.removeEventListener("change", this);
    this.elements?.downloadLanguagesGroup?.removeEventListener("click", this);
    this.elements?.downloadLanguagesButton?.removeEventListener("click", this);
  },
};

document.addEventListener("paneshown", TranslationsSettings);

Messung V0.5 in Prozent
C=94 H=93 G=93

¤ Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.0.29Bemerkung:  (vorverarbeitet am  2026-09-28) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

PVS Prover

Isabelle Prover

NIST Cobol Testsuite

Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

letze Version des Elbe Quellennavigators


Jenseits des Üblichen ....
    

Besucher

Besucher

Statistik
#Sources=1126438
#Domains=1897691