diff --git a/package-lock.json b/package-lock.json index 9cdf56f4..0a8c9f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3032,14 +3032,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4651,9 +4651,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -6501,9 +6501,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6699,9 +6699,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, diff --git a/src/background/LanguageDetector.ts b/src/background/LanguageDetector.ts index fb4c3486..d0ec9346 100644 --- a/src/background/LanguageDetector.ts +++ b/src/background/LanguageDetector.ts @@ -2,6 +2,7 @@ import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES_SHORT_CODE, + resolveEnabledPredictionLanguages, } from "../shared/lang"; import { SettingsManager } from "../shared/settingsManager"; @@ -11,10 +12,21 @@ export class LanguageDetector { this.settings = settings; } - async detectLanguage(text: string, tabId: number): Promise { - const fallbackLanguage = (await this.settings.get( + async detectLanguage( + text: string, + tabId: number, + enabledLanguages?: string[], + ): Promise { + const fallbackLanguageRaw = (await this.settings.get( "fallbackLanguage", )) as string; + const allowedLanguages = + resolveEnabledPredictionLanguages(enabledLanguages); + const fallbackLanguage = + fallbackLanguageRaw && fallbackLanguageRaw !== "auto_detect" + ? fallbackLanguageRaw + : allowedLanguages[0]; + const allowedSet = new Set(allowedLanguages); const globalAny = globalThis as { browser?: typeof chrome }; const api = typeof globalAny.browser === "undefined" ? chrome : globalAny.browser; @@ -22,17 +34,18 @@ export class LanguageDetector { let detectedLanguage: string | null = null; let maxPercentage = -1; for (const language of result.languages) { + let resolvedLanguage: string | null = null; + if (language.language in SUPPORTED_LANGUAGES) { + resolvedLanguage = language.language; + } else if (language.language in SUPPORTED_LANGUAGES_SHORT_CODE) { + resolvedLanguage = SUPPORTED_LANGUAGES_SHORT_CODE[language.language]; + } if ( - language.language in SUPPORTED_LANGUAGES && + resolvedLanguage && + allowedSet.has(resolvedLanguage) && language.percentage > maxPercentage ) { - detectedLanguage = language.language; - maxPercentage = language.percentage; - } else if ( - language.language in SUPPORTED_LANGUAGES_SHORT_CODE && - language.percentage > maxPercentage - ) { - detectedLanguage = SUPPORTED_LANGUAGES_SHORT_CODE[language.language]; + detectedLanguage = resolvedLanguage; maxPercentage = language.percentage; } } @@ -40,12 +53,18 @@ export class LanguageDetector { return detectedLanguage; } const pageLang = await api.tabs.detectLanguage(tabId); - if (pageLang in SUPPORTED_LANGUAGES) { + if (pageLang in SUPPORTED_LANGUAGES && allowedSet.has(pageLang)) { return pageLang; } - if (pageLang in SUPPORTED_LANGUAGES_SHORT_CODE) { + if ( + pageLang in SUPPORTED_LANGUAGES_SHORT_CODE && + allowedSet.has(SUPPORTED_LANGUAGES_SHORT_CODE[pageLang]) + ) { return SUPPORTED_LANGUAGES_SHORT_CODE[pageLang]; } - return fallbackLanguage; + if (allowedSet.has(fallbackLanguage)) { + return fallbackLanguage; + } + return allowedLanguages[0]; } } diff --git a/src/background/background.ts b/src/background/background.ts index 83666558..9e851204 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -17,6 +17,7 @@ import { KEY_AUTOCOMPLETE_ON_ENTER, KEY_AUTOCOMPLETE, KEY_LANGUAGE, + KEY_ENABLED_LANGUAGES, KEY_ENABLED, KEY_NUM_SUGGESTIONS, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, @@ -30,7 +31,7 @@ import { } from "../shared/constants"; import { getDomain, isEnabledForDomain, checkLastError } from "../shared/utils"; import { logError } from "../shared/error"; -import { SUPPORTED_LANGUAGES } from "../shared/lang"; +import { resolveEnabledLanguages } from "../shared/lang"; import { SettingsManager } from "../shared/settingsManager"; import { LanguageDetector } from "./LanguageDetector"; import { PresageConfig } from "./PresageHandler"; @@ -111,8 +112,16 @@ export class BackgroundServiceWorker { }); } - async detectLanguage(text: string, tabId: number): Promise { - return await this.languageDetector.detectLanguage(text, tabId); + async detectLanguage( + text: string, + tabId: number, + enabledLanguages?: string[], + ): Promise { + return await this.languageDetector.detectLanguage( + text, + tabId, + enabledLanguages, + ); } sendCommandToActiveTabContentScript(message: Message) { @@ -120,7 +129,7 @@ export class BackgroundServiceWorker { } async getBackgroundPageSetConfigMsg(): Promise { - this.language = (await this.settingsManager.get(KEY_LANGUAGE)) as string; + this.language = await resolveActiveLanguage(this.settingsManager); const [ enabled, autocomplete, @@ -206,7 +215,7 @@ export class BackgroundServiceWorker { async updatePresageConfig() { await this.predictionManager.initialize(); - this.language = (await this.settingsManager.get(KEY_LANGUAGE)) as string; + this.language = await resolveActiveLanguage(this.settingsManager); const [ numSuggestions, minWordLengthToPredict, @@ -250,6 +259,34 @@ export class BackgroundServiceWorker { } } +async function getEnabledLanguages( + settingsManager: SettingsManager, +): Promise { + const enabledLanguages = await settingsManager.get(KEY_ENABLED_LANGUAGES); + return resolveEnabledLanguages(enabledLanguages); +} + +async function resolveActiveLanguage( + settingsManager: SettingsManager, +): Promise { + const [language, enabledLanguagesRaw] = await Promise.all([ + settingsManager.get(KEY_LANGUAGE), + settingsManager.get(KEY_ENABLED_LANGUAGES), + ]); + const enabledLanguages = resolveEnabledLanguages(enabledLanguagesRaw); + const currentLanguage = typeof language === "string" ? language : ""; + const allowAutoDetect = enabledLanguages.length > 1; + if (currentLanguage === "auto_detect" && allowAutoDetect) { + return currentLanguage; + } + if (enabledLanguages.includes(currentLanguage)) { + return currentLanguage; + } + const fallbackLanguage = enabledLanguages[0]; + await settingsManager.set(KEY_LANGUAGE, fallbackLanguage); + return fallbackLanguage; +} + function onInstalled(details: chrome.runtime.InstalledDetails) { checkLastError(); if (details.reason === "install") { @@ -286,23 +323,34 @@ function onCommand(command: string) { break; } case CMD_TOGGLE_FT_ACTIVE_LANG: { - const availableLangs = [...Object.keys(SUPPORTED_LANGUAGES)]; - const currentLangIndex = availableLangs.indexOf( - backgroundServiceWorker.language, - ); - const nextLangIndex = (currentLangIndex + 1) % availableLangs.length; - const nextLang = availableLangs[nextLangIndex]; - backgroundServiceWorker.settingsManager.set("language", nextLang); - backgroundServiceWorker.language = nextLang; - const updateLangConfigMessage: UpdateLangConfigMessage = { - command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, - context: { - lang: nextLang, - }, - }; - backgroundServiceWorker.sendCommandToActiveTabContentScript( - updateLangConfigMessage, - ); + (async () => { + const availableLangs = await getEnabledLanguages( + backgroundServiceWorker.settingsManager, + ); + const currentLanguage = await resolveActiveLanguage( + backgroundServiceWorker.settingsManager, + ); + backgroundServiceWorker.language = currentLanguage; + const currentLangIndex = availableLangs.indexOf(currentLanguage); + const nextLangIndex = + (currentLangIndex >= 0 ? currentLangIndex + 1 : 0) % + availableLangs.length; + const nextLang = availableLangs[nextLangIndex]; + await backgroundServiceWorker.settingsManager.set( + KEY_LANGUAGE, + nextLang, + ); + backgroundServiceWorker.language = nextLang; + const updateLangConfigMessage: UpdateLangConfigMessage = { + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { + lang: nextLang, + }, + }; + backgroundServiceWorker.sendCommandToActiveTabContentScript( + updateLangConfigMessage, + ); + })().catch((error) => logError("onCommand", error)); break; } default: @@ -318,14 +366,18 @@ async function handleContentScriptPredictReq( backgroundServiceWorker: BackgroundServiceWorker, ) { try { - let language = (await backgroundServiceWorker.settingsManager.get( - KEY_LANGUAGE, - )) as string; + let language = await resolveActiveLanguage( + backgroundServiceWorker.settingsManager, + ); backgroundServiceWorker.language = language; if (language === "auto_detect") { + const enabledLanguages = await getEnabledLanguages( + backgroundServiceWorker.settingsManager, + ); language = await backgroundServiceWorker.detectLanguage( request.context.text!, sender.tab!.id!, + enabledLanguages, ); } if (request.context.lang !== language) { diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 2a78f8e0..abbf56e9 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -4,11 +4,13 @@ import { blockUnBlockDomain, } from "../shared/utils"; import { SettingsManager } from "../shared/settingsManager"; -import { SUPPORTED_LANGUAGES } from "../shared/lang"; +import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../shared/lang"; import { CMD_POPUP_PAGE_ENABLE, CMD_POPUP_PAGE_DISABLE, CMD_OPTIONS_PAGE_CONFIG_CHANGE, + KEY_ENABLED_LANGUAGES, + KEY_LANGUAGE, } from "../shared/constants"; import { OptionsPageConfigChangeMessage, @@ -49,17 +51,44 @@ function init() { } checkboxEnableNode.checked = Boolean(await settings.get("enable")); } - const language = (await settings.get("language")) as string; + let language = (await settings.get(KEY_LANGUAGE)) as string; + const enabledLanguages = resolveEnabledLanguages( + await settings.get(KEY_ENABLED_LANGUAGES), + ); const select = window.document.getElementById( "languageSelect", ) as HTMLSelectElement; - for (const [langCode, lang] of Object.entries(SUPPORTED_LANGUAGES)) { + const allowAutoDetect = enabledLanguages.length > 1; + const isAutoDetect = language === "auto_detect"; + const isValidLanguage = enabledLanguages.includes(language); + const displayLanguage = + isAutoDetect && allowAutoDetect + ? "auto_detect" + : isValidLanguage + ? language + : enabledLanguages[0]; + + if (!isValidLanguage && !(isAutoDetect && allowAutoDetect)) { + language = displayLanguage; + await settings.set(KEY_LANGUAGE, language); + chrome.runtime.sendMessage({ + command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, + context: {}, + }); + } + if (allowAutoDetect) { + const opt = window.document.createElement("option"); + opt.value = "auto_detect"; + opt.innerHTML = SUPPORTED_LANGUAGES.auto_detect; + select.appendChild(opt); + } + for (const langCode of enabledLanguages) { const opt = window.document.createElement("option"); opt.value = langCode; - opt.innerHTML = lang; + opt.innerHTML = SUPPORTED_LANGUAGES[langCode]; select.appendChild(opt); } - select.value = language; + select.value = displayLanguage; }, ); window.document @@ -99,11 +128,12 @@ async function languageChangeEvent() { const select = window.document.getElementById( "languageSelect", ) as HTMLSelectElement; + const message: OptionsPageConfigChangeMessage = { command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, context: {}, }; - await settings.set("language", select.value); + await settings.set(KEY_LANGUAGE, select.value); chrome.runtime.sendMessage(message); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 654b4e71..3085d7ec 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -36,6 +36,7 @@ export const KEY_DATE_FORMAT = "dateFormat"; export const KEY_USER_DICTIONARY_LIST = "userDictionaryList"; export const KEY_LANGUAGE = "language"; export const KEY_FALLBACK_LANGUAGE = "fallbackLanguage"; +export const KEY_ENABLED_LANGUAGES = "enabled_languages"; export const KEY_DOMAIN_LIST_MODE = "domainListMode"; export const KEY_DISPLAY_LANG_HEADER = "displayLangHeader"; export const KEY_ENABLED = "enabled"; diff --git a/src/shared/lang.ts b/src/shared/lang.ts index 21718f79..ce11b705 100644 --- a/src/shared/lang.ts +++ b/src/shared/lang.ts @@ -11,6 +11,36 @@ export const SUPPORTED_LANGUAGES: Record = { pt_BR: "Brazilian Portuguese", textExpander: "Text Expander", }; + +export const SUPPORTED_LANGUAGE_KEYS = Object.keys(SUPPORTED_LANGUAGES); +export const SUPPORTED_PREDICTION_LANGUAGE_KEYS = + SUPPORTED_LANGUAGE_KEYS.filter((lang) => lang !== "auto_detect"); + +export function resolveEnabledLanguages(enabledLanguages: unknown): string[] { + if (!Array.isArray(enabledLanguages)) { + return SUPPORTED_PREDICTION_LANGUAGE_KEYS.slice(); + } + const enabledSet = new Set( + enabledLanguages.filter( + (lang): lang is string => + typeof lang === "string" && + lang in SUPPORTED_LANGUAGES && + lang !== "auto_detect", + ), + ); + const filtered = SUPPORTED_PREDICTION_LANGUAGE_KEYS.filter((lang) => + enabledSet.has(lang), + ); + return filtered.length > 0 + ? filtered + : SUPPORTED_PREDICTION_LANGUAGE_KEYS.slice(); +} + +export function resolveEnabledPredictionLanguages( + enabledLanguages: unknown, +): string[] { + return resolveEnabledLanguages(enabledLanguages); +} export const SUPPORTED_LANGUAGES_SHORT_CODE: Record = { en: "en_US", fr: "fr_FR", diff --git a/src/third_party/fancier-settings/js/classes/setting.js b/src/third_party/fancier-settings/js/classes/setting.js index 883ebe9f..3c504551 100755 --- a/src/third_party/fancier-settings/js/classes/setting.js +++ b/src/third_party/fancier-settings/js/classes/setting.js @@ -556,6 +556,41 @@ class Slider extends Bundle { } class PopupButton extends Bundle { + // Dynamically set options for the select element + setOptions(options, selectedValue) { + this.params.options = options; + // Remove all options from the select element + const selectElem = this.element && this.element.element ? this.element.element : null; + if (selectElem && selectElem.tagName === "SELECT") { + while (selectElem.options.length > 0) { + selectElem.remove(0); + } + // Add new options + options.forEach(option => { + if (Array.isArray(option)) { + option = { + value: option[0], + text: option[1] || option[0], + }; + } + let value, text; + if (typeof option === "object" && option.value !== undefined) { + value = option.value; + text = option.text || option.value; + } else { + value = option; + text = option; + } + const opt = document.createElement("option"); + opt.value = value; + opt.text = text; + if (selectedValue !== undefined && value === selectedValue) { + opt.selected = true; + } + selectElem.add(opt); + }); + } + } // label, options[{value, text}] // action -> change @@ -713,11 +748,13 @@ class ListBox extends PopupButton { this.element.addEvent("change", change); } - setupDOM() { + setupDOM(inject=true) { super.setupDOM(); this.selected = null; this.params.options = []; - + if(!inject) { + return; + } const promise = settings.get(this.params.name); promise .then((initParams) => { @@ -885,6 +922,53 @@ class RadioButtons extends Bundle { } } +class ListBoxMultiselect extends ListBox { + addEvents() { + const change = function () { + if (this.params.name !== undefined) { + settings.set(this.params.name, this.get()); + } + this.fireEvent("action", this.get()); + }.bind(this); + + this.element.addEvent("change", change); + } + + setupDOM() { + if (this.params.label !== undefined) { + this.label.set("innerHTML", this.params.label); + this.label.inject(this.bundle); + } + + this.element.inject(this.container); + this.container.inject(this.control); + this.control.inject(this.bundle); + } + + get() { + return Array.from(this.element.element.options) + .filter((option) => option.selected) + .map((option) => option.value); + } + + set(values, noChangeEvent) { + const selectedValues = Array.isArray(values) + ? values.map((value) => value.toString()) + : []; + const selectedSet = new Set(selectedValues); + const options = this.element.element.options; + for (let i = 0; i < options.length; i++) { + options[i].selected = selectedSet.has(options[i].value); + } + + if (noChangeEvent !== true) { + this.element.fireEvent("change"); + } + return this; + } +} + + class Setting { constructor(container) { this.container = container; @@ -901,6 +985,7 @@ class Setting { slider: Slider, popupButton: PopupButton, listBox: ListBox, + listBoxMultiselect: ListBoxMultiselect, radioButtons: RadioButtons, valueOnly: Bundle, modalButton: ModalButton, diff --git a/src/third_party/fancier-settings/manifest.js b/src/third_party/fancier-settings/manifest.js index 9f3aaf27..d2714f86 100755 --- a/src/third_party/fancier-settings/manifest.js +++ b/src/third_party/fancier-settings/manifest.js @@ -1,5 +1,8 @@ import { i18n } from "./i18n.js"; -import { SUPPORTED_LANGUAGES } from "../../shared/lang.ts"; +import { + SUPPORTED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, +} from "../../shared/lang.ts"; import { DOMAIN_LIST_MODE } from "../../shared/utils.ts"; import { DATE_TIME_VARIABLES } from "../../shared/variables.ts"; import { @@ -12,6 +15,7 @@ import { KEY_SELECT_BY_DIGIT, KEY_REVERT_ON_BACKSPACE, KEY_LANGUAGE, + KEY_ENABLED_LANGUAGES, KEY_FALLBACK_LANGUAGE, KEY_MIN_WORD_LENGTH_TO_PREDICT, KEY_NUM_SUGGESTIONS, @@ -154,13 +158,25 @@ const manifest = { label: i18n.get("Primary prediction language:"), default: "en_US", }, + { + tab: i18n.get("Language"), + group: i18n.get("Language Selection"), + name: KEY_ENABLED_LANGUAGES, + type: "listBoxMultiselect", + label: i18n.get("Enabled Languages"), + options: SUPPORTED_PREDICTION_LANGUAGE_KEYS.map((lang) => [ + lang, + SUPPORTED_LANGUAGES[lang], + ]), + default: SUPPORTED_PREDICTION_LANGUAGE_KEYS, + }, { tab: i18n.get("Language"), group: i18n.get("Language Selection"), name: KEY_FALLBACK_LANGUAGE, type: "popupButton", options: Object.entries(SUPPORTED_LANGUAGES), - label: i18n.get("Secondary (fallback) language:") + ": " + i18n.get("If no predictions are found in the primary language, FluentTyper will search in this language instead.") + "", + label: i18n.get("Secondary (fallback) language:") + ": " + i18n.get("Used only when auto-detection fails, to decide which language to try next.") + "", default: "en_US", }, { diff --git a/src/third_party/fancier-settings/settings.js b/src/third_party/fancier-settings/settings.js index 5cc96d68..b87011f0 100755 --- a/src/third_party/fancier-settings/settings.js +++ b/src/third_party/fancier-settings/settings.js @@ -1,7 +1,7 @@ import { FancierSettingsWithManifest } from "./js/classes/fancier-settings.js"; import { Store } from "./lib/store.js"; import { ElementWrapper } from "./js/classes/utils.js"; - +import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../../shared/lang.ts"; import { TextExpander } from "../../options/textExpander.js"; import { KEY_AUTOCOMPLETE, @@ -9,6 +9,7 @@ import { KEY_AUTOCOMPLETE_ON_TAB, KEY_LANGUAGE, KEY_FALLBACK_LANGUAGE, + KEY_ENABLED_LANGUAGES, KEY_NUM_SUGGESTIONS, KEY_MIN_WORD_LENGTH_TO_PREDICT, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, @@ -60,6 +61,68 @@ function fallbackLanguageVisibility(settings, value) { ); } +function buildLanguageOptions(enabledLanguages, allowAutoDetect) { + const options = enabledLanguages.map((lang) => [ + lang, + SUPPORTED_LANGUAGES[lang], + ]); + if (allowAutoDetect) { + options.unshift(["auto_detect", SUPPORTED_LANGUAGES.auto_detect]); + } + return options; +} + +function arraysEqual(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +async function validateLanguageSettings(settings, store) { + const enabledLanguagesRaw = await store.get(KEY_ENABLED_LANGUAGES); + const enabledLanguages = resolveEnabledLanguages(enabledLanguagesRaw); + const allowAutoDetect = enabledLanguages.length > 1; + const language = (await store.get(KEY_LANGUAGE)) || enabledLanguages[0]; + const fallbackLanguage = + (await store.get(KEY_FALLBACK_LANGUAGE)) || enabledLanguages[0]; + + const resolvedLanguage = + language === "auto_detect" && allowAutoDetect + ? "auto_detect" + : enabledLanguages.includes(language) + ? language + : enabledLanguages[0]; + const resolvedFallbackLanguage = enabledLanguages.includes(fallbackLanguage) + ? fallbackLanguage + : enabledLanguages[0]; + + const primaryOptions = buildLanguageOptions( + enabledLanguages, + allowAutoDetect, + ); + const fallbackOptions = buildLanguageOptions(enabledLanguages, false); + settings.manifest.language.setOptions(primaryOptions, resolvedLanguage); + settings.manifest.fallbackLanguage.setOptions( + fallbackOptions, + resolvedFallbackLanguage, + ); + + if (!arraysEqual(enabledLanguagesRaw, enabledLanguages)) { + settings.manifest[KEY_ENABLED_LANGUAGES].set(enabledLanguages); + } + + if (resolvedLanguage !== language) { + settings.manifest.language.set(resolvedLanguage); + } + if (resolvedFallbackLanguage !== fallbackLanguage) { + settings.manifest.fallbackLanguage.set(resolvedFallbackLanguage); + } +} + function importSettingButtonFileSelected(settings) { const importInputElem = settings.manifest.importSettingButton.element.element; const fr = new FileReader(); @@ -180,6 +243,8 @@ function applyThemePreset(settings, presetName) { } window.addEventListener("DOMContentLoaded", function () { + //chrome.storage.local.clear(); + // Option 1: Use the manifest: (() => new FancierSettingsWithManifest(async function (settings) { @@ -189,12 +254,18 @@ window.addEventListener("DOMContentLoaded", function () { }); const store = new Store("settings"); - fallbackLanguageVisibility(settings, await store.get("language")); + fallbackLanguageVisibility(settings, await store.get(KEY_LANGUAGE)); settings.manifest.language.addEvent("action", function (value) { fallbackLanguageVisibility(settings, value); + validateLanguageSettings(settings, store); }); + settings.manifest[KEY_ENABLED_LANGUAGES].addEvent("action", function () { + validateLanguageSettings(settings, store); + }); + validateLanguageSettings(settings, store); + settings.manifest.addDomainBtn.addEvent("action", function () { if (settings.manifest.domain.element.element.checkValidity()) { const domainURL = settings.manifest.domain.get(); @@ -289,6 +360,7 @@ window.addEventListener("DOMContentLoaded", function () { KEY_AUTOCOMPLETE_ON_ENTER, KEY_AUTOCOMPLETE_ON_TAB, KEY_LANGUAGE, + KEY_ENABLED_LANGUAGES, KEY_DOMAIN_LIST_MODE, KEY_FALLBACK_LANGUAGE, KEY_NUM_SUGGESTIONS, @@ -325,4 +397,3 @@ window.addEventListener("DOMContentLoaded", function () { }); }))(); }); - diff --git a/tests/LanguageSettings.test.ts b/tests/LanguageSettings.test.ts new file mode 100644 index 00000000..00e78846 --- /dev/null +++ b/tests/LanguageSettings.test.ts @@ -0,0 +1,26 @@ +import { + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + resolveEnabledLanguages, + resolveEnabledPredictionLanguages, +} from "../src/shared/lang"; + +describe("language settings helpers", () => { + test("resolveEnabledLanguages falls back to all languages for empty input", () => { + expect(resolveEnabledLanguages(undefined)).toEqual( + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + expect(resolveEnabledLanguages([])).toEqual( + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + }); + + test("resolveEnabledLanguages filters and preserves supported order", () => { + const result = resolveEnabledLanguages(["de_DE", "en_US"]); + expect(result).toEqual(["en_US", "de_DE"]); + }); + + test("resolveEnabledPredictionLanguages excludes auto_detect and never returns empty", () => { + const result = resolveEnabledPredictionLanguages(["auto_detect"]); + expect(result).toEqual(SUPPORTED_PREDICTION_LANGUAGE_KEYS); + }); +}); diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 80a01d2d..1a947ff5 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -1,14 +1,67 @@ -import puppeteer, { Browser, Page } from "puppeteer"; +import puppeteer, { Browser, Page, WebWorker } from "puppeteer"; import path from "path"; -import { DEFAULT_NUM_SUGGESTIONS } from "../../src/shared/constants"; -import { SUPPORTED_LANGUAGES } from "../../src/shared/lang"; +import { + DEFAULT_NUM_SUGGESTIONS, + KEY_ENABLED_LANGUAGES, + KEY_FALLBACK_LANGUAGE, + KEY_LANGUAGE, +} from "../../src/shared/constants"; +import { SUPPORTED_PREDICTION_LANGUAGE_KEYS } from "../../src/shared/lang"; const EXTENSION_PATH = path.resolve(__dirname, "../../build/"); const TEST_PAGE_PATH = path.resolve(__dirname, "test-page.html"); +const SETTINGS_PREFIX = "store.settings."; + +async function setSetting( + worker: WebWorker, + key: string, + value: unknown, +): Promise { + const storageKey = `${SETTINGS_PREFIX}${key}`; + await worker.evaluate( + (storageKeyInner, valueInner) => + chrome.storage.local.set({ + [storageKeyInner]: JSON.stringify(valueInner), + }), + storageKey, + value, + ); +} + +async function getSetting( + worker: WebWorker, + key: string, +): Promise { + const storageKey = `${SETTINGS_PREFIX}${key}`; + return worker.evaluate( + (storageKeyInner) => + new Promise((resolve) => { + chrome.storage.local.get(storageKeyInner, (result) => { + const rawValue = (result as Record)[ + storageKeyInner + ]; + resolve(rawValue ? JSON.parse(rawValue) : undefined); + }); + }), + storageKey, + ) as Promise; +} + +async function openOptionsPage(browser: Browser, worker: WebWorker) { + await worker.evaluate("chrome.runtime.openOptionsPage();"); + const optionsTarget = await browser.waitForTarget( + (target) => + target.type() === "page" && target.url().endsWith("options/options.html"), + ); + const optionsPage = await optionsTarget.asPage(); + await optionsPage.waitForSelector("#content"); + return optionsPage; +} describe("Chrome Extension E2E Test", () => { let browser: Browser; let page: Page; + let worker: WebWorker; beforeAll(async () => { browser = await puppeteer.launch({ @@ -21,6 +74,13 @@ describe("Chrome Extension E2E Test", () => { }); const pages = await browser.pages(); page = pages[0]; + const serviceWorkerTarget = await browser.waitForTarget( + (target) => + target.type() === "service_worker" && + target.url().endsWith("background.js"), + { timeout: 30000 }, + ); + worker = (await serviceWorkerTarget.worker())!; }, 20000); afterAll(async () => { @@ -35,29 +95,12 @@ describe("Chrome Extension E2E Test", () => { target.url().endsWith("new_installation/index.html"), ); - const serviceWorker = await browser.waitForTarget( - // Assumes that there is only one service worker created by the extension and its URL ends with background.js. - (target) => - target.type() === "service_worker" && - target.url().endsWith("background.js"), - ); expect(newInstallationPage).toBeDefined(); - expect(serviceWorker).toBeDefined(); + expect(worker).toBeDefined(); }, 20000); test("Extension installs and popup loads", async () => { - // Find the extension ID - const serviceWorker = await browser.waitForTarget( - // Assumes that there is only one service worker created by the extension and its URL ends with background.js. - (target) => - target.type() === "service_worker" && - target.url().endsWith("background.js"), - ); - expect(serviceWorker).toBeDefined(); - - const worker = await serviceWorker.worker(); expect(worker).toBeDefined(); - await worker!.evaluate("chrome.action.openPopup();"); const popupTarget = await browser.waitForTarget( @@ -137,7 +180,129 @@ describe("Chrome Extension E2E Test", () => { (textarea) => (textarea as HTMLTextAreaElement).value, ); expect(textAreaText).toBe("with\xa0"); - }, 15000); + }, 30000); + + test("Enabled languages restrict popup language list", async () => { + const enabledLanguages = ["en_US", "de_DE"]; + await setSetting(worker!, KEY_ENABLED_LANGUAGES, enabledLanguages); + await setSetting(worker!, KEY_LANGUAGE, "en_US"); + + await worker!.evaluate("chrome.action.openPopup();"); + const popupTarget = await browser.waitForTarget((target) => + target.url().endsWith("popup.html"), + ); + const popupPage = await popupTarget.asPage(); + await popupPage.waitForSelector("#languageSelect"); + + const options = await popupPage.$$eval("#languageSelect option", (opts) => + opts.map((opt) => (opt as HTMLOptionElement).value), + ); + expect(options).toEqual(["auto_detect", ...enabledLanguages]); + + await popupPage.select("#languageSelect", "de_DE"); + await new Promise((r) => setTimeout(r, 300)); + const storedLanguage = await getSetting(worker!, KEY_LANGUAGE); + expect(storedLanguage).toBe("de_DE"); + + await popupPage.close(); + + await setSetting( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + await setSetting(worker!, KEY_LANGUAGE, "en_US"); + }, 20000); + + test("Auto detect is only allowed when multiple languages are enabled", async () => { + await setSetting(worker!, KEY_ENABLED_LANGUAGES, ["en_US"]); + await setSetting(worker!, KEY_LANGUAGE, "auto_detect"); + await setSetting(worker!, KEY_FALLBACK_LANGUAGE, "auto_detect"); + + const optionsPageSingle = await openOptionsPage(browser, worker!); + await new Promise((r) => setTimeout(r, 300)); + await optionsPageSingle.close(); + + const storedLanguageSingle = await getSetting( + worker!, + KEY_LANGUAGE, + ); + const storedFallbackSingle = await getSetting( + worker!, + KEY_FALLBACK_LANGUAGE, + ); + expect(storedLanguageSingle).toBe("en_US"); + expect(storedFallbackSingle).toBe("en_US"); + + await setSetting(worker!, KEY_ENABLED_LANGUAGES, ["en_US", "de_DE"]); + await setSetting(worker!, KEY_LANGUAGE, "auto_detect"); + await setSetting(worker!, KEY_FALLBACK_LANGUAGE, "auto_detect"); + + const optionsPageMulti = await openOptionsPage(browser, worker!); + await new Promise((r) => setTimeout(r, 300)); + await optionsPageMulti.close(); + + const storedLanguageMulti = await getSetting(worker!, KEY_LANGUAGE); + const storedFallbackMulti = await getSetting( + worker!, + KEY_FALLBACK_LANGUAGE, + ); + expect(storedLanguageMulti).toBe("auto_detect"); + expect(storedFallbackMulti).toBe("en_US"); + + const enabledLanguages = await getSetting( + worker!, + KEY_ENABLED_LANGUAGES, + ); + expect(enabledLanguages).toEqual(["en_US", "de_DE"]); + }, 20000); + + test("Auto detect in popup detects language and predicts", async () => { + page = await browser.newPage(); + await page.goto("file://" + TEST_PAGE_PATH); + page.bringToFront(); + await page.waitForSelector("#test-textarea"); + const textarea = await page.$("#test-textarea"); + + await setSetting(worker!, KEY_ENABLED_LANGUAGES, ["en_US", "el_GR"]); + await setSetting(worker!, KEY_LANGUAGE, "en_US"); + + await worker!.evaluate("chrome.action.openPopup();"); + const popupTarget = await browser.waitForTarget((target) => + target.url().endsWith("popup.html"), + ); + const popupPage = await popupTarget.asPage(); + await popupPage.waitForSelector("#languageSelect"); + await popupPage.select("#languageSelect", "auto_detect"); + await new Promise((r) => setTimeout(r, 300)); + await popupPage.close(); + + const storedLanguage = await getSetting(worker!, KEY_LANGUAGE); + expect(storedLanguage).toBe("auto_detect"); + + await worker!.evaluate( + "chrome.runtime.sendMessage({command: 'CMD_OPTIONS_PAGE_CONFIG_CHANGE', context: {}});", + ); + await new Promise((r) => setTimeout(r, 600)); + + await textarea!.click(); + await page.evaluate( + () => + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), + ); + await textarea!.type("φιλο"); + await new Promise((r) => setTimeout(r, 300)); + await textarea!.type("σ"); + + await page.waitForSelector(".tribute-container li", { timeout: 5000 }); + const firstLiText = await page.$eval( + ".tribute-container li:first-child", + (li) => li.textContent, + ); + expect(firstLiText?.toLowerCase()).toContain("φιλοσοφία"); + }, 30000); const LANGUAGE_TEST_DATA: Record< string, @@ -162,27 +327,18 @@ describe("Chrome Extension E2E Test", () => { await page.waitForSelector("#test-textarea"); const textarea = await page.$("#test-textarea"); - for (const lang of Object.keys(SUPPORTED_LANGUAGES)) { - if (lang === "auto_detect") continue; - - // 1. Open popup and change language - const serviceWorker = await browser.waitForTarget( - (target) => - target.type() === "service_worker" && - target.url().endsWith("background.js"), - ); - const worker = await serviceWorker.worker(); - await worker!.evaluate("chrome.action.openPopup();"); + await setSetting( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); - const popupTarget = await browser.waitForTarget((target) => - target.url().endsWith("popup.html"), + for (const lang of SUPPORTED_PREDICTION_LANGUAGE_KEYS) { + await setSetting(worker!, KEY_LANGUAGE, lang); + await worker!.evaluate( + "chrome.runtime.sendMessage({command: 'CMD_OPTIONS_PAGE_CONFIG_CHANGE', context: {}});", ); - const popupPage = await popupTarget.asPage(); - await popupPage!.waitForSelector("#languageSelect"); - await popupPage!.select("#languageSelect", lang); - // Wait a bit for the config to be saved and propagated - await new Promise((r) => setTimeout(r, 500)); - await popupPage!.close(); + await new Promise((r) => setTimeout(r, 600)); // 2. Type input and verify prediction const testData = LANGUAGE_TEST_DATA[lang]; @@ -204,7 +360,7 @@ describe("Chrome Extension E2E Test", () => { await new Promise((r) => setTimeout(r, 1000)); try { - await page.waitForSelector(".tribute-container li", { timeout: 2000 }); + await page.waitForSelector(".tribute-container li", { timeout: 5000 }); const firstLiText = await page.$eval( ".tribute-container li:first-child", (li) => li.textContent, @@ -230,5 +386,5 @@ describe("Chrome Extension E2E Test", () => { // But clearing the input usually clears predictions. await new Promise((r) => setTimeout(r, 200)); } - }, 60000); // Increased timeout for iterating all languages + }, 90000); // Increased timeout for iterating all languages });