diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index e22a279aad..bd0a963959 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -61,7 +61,7 @@ const changePhaseTo = phase => act(() => callbacks.onPhaseChange(phase)); describe("App", () => { beforeEach(() => { // setting the language through a cookie - document.cookie = "CockpitLang=en-us; path=/;"; + document.cookie = "agamaLang=en-us; path=/;"; createClient.mockImplementation(() => { return { manager: { @@ -94,7 +94,7 @@ describe("App", () => { afterEach(() => { // setting a cookie with already expired date removes it - document.cookie = "CockpitLang=; path=/; expires=" + new Date(0).toUTCString(); + document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString(); }); describe("when the software context is not initialized", () => { diff --git a/web/src/agama.js b/web/src/agama.js new file mode 100644 index 0000000000..f39ecbf1fd --- /dev/null +++ b/web/src/agama.js @@ -0,0 +1,107 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +/** + * This module provides a global "agama" object which can be use from other + * scripts like "po.js". + */ + +const agama = { + // the current language + language: "en", +}; + +// mapping with the current translations +let translations = {}; +// function used for computing the plural form index +let plural_fn; + +// set the current translations, called from po.js +agama.locale = function locale(po) { + if (po) { + Object.assign(translations, po); + + const header = po[""]; + if (header) { + if (header["plural-forms"]) + plural_fn = header["plural-forms"]; + if (header.language) + agama.language = header.language; + } + } else if (po === null) { + translations = {}; + } +}; + +/** + * get a translation for a singular text + * @param {string} str input text + * @return translated text or the original text if the translation is not found + */ +agama.gettext = function gettext(str) { + if (translations) { + const translated = translations[str]; + // skip the `null` item in the list generated by cockpit-po-plugin + // TODO: get rid of that later + if (translated?.[1]) return translated[1]; + } + + // fallback, return the original text + return str; +}; + +/** + * get a translation for a plural text + * @param {string} str1 input singular text + * @param {string} strN input plural text + * @param {number} n the actual number which decides whether to use the + * singular or plural form (of which plural form if there are several of them) + * @return translated text or the original text if the translation is not found + */ +agama.ngettext = function ngettext(str1, strN, n) { + if (translations && plural_fn) { + // plural form translations are indexed by the singular variant + const translation = translations[str1]; + + if (translation) { + const plural_index = plural_fn(n); + + // the plural function either returns direct index (integer) in the plural + // translations or a boolean indicating simple plural form which + // needs to be converted to index 0 (singular) or 1 (plural) + let index = (plural_index === true ? 1 : plural_index || 0); + + // skip the `null` item in the list generated by cockpit-po-plugin + // TODO: get rid of that later + index += 1; + + if (translation[index]) return translation[index]; + } + } + + // fallback, return the original text + return (n === 1) ? str1 : strN; +}; + +// register a global object so it can be accessed from a separate po.js script +window.agama = agama; + +export default agama; diff --git a/web/src/components/l10n/InstallerKeymapSwitcher.jsx b/web/src/components/l10n/InstallerKeymapSwitcher.jsx index 936040a701..2aa3c742b3 100644 --- a/web/src/components/l10n/InstallerKeymapSwitcher.jsx +++ b/web/src/components/l10n/InstallerKeymapSwitcher.jsx @@ -22,7 +22,7 @@ import React from "react"; import { FormSelect, FormSelectOption } from "@patternfly/react-core"; -import cockpit from "../../lib/cockpit"; +import agama from "~/agama"; import { Icon } from "~/components/layout"; import { _ } from "~/i18n"; @@ -33,7 +33,7 @@ import { If } from "~/components/core"; const sort = (keymaps) => { // sort the keymap names using the current locale - const lang = cockpit.language || "en"; + const lang = agama.language || "en"; return keymaps.sort((k1, k2) => k1.name.localeCompare(k2.name, lang)); }; diff --git a/web/src/context/installerL10n.jsx b/web/src/context/installerL10n.jsx index 37aeeb630b..d73353d7ee 100644 --- a/web/src/context/installerL10n.jsx +++ b/web/src/context/installerL10n.jsx @@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useCancellablePromise, locationReload, setLocationSearch } from "~/utils"; import cockpit from "../lib/cockpit"; import { useInstallerClient } from "./installer"; +import agama from "~/agama"; const L10nContext = React.createContext(null); @@ -49,37 +50,43 @@ function useInstallerL10n() { } /** - * Current language according to Cockpit (in xx_XX format). + * Current language (in xx_XX format). * - * It takes the language from the CockpitLang cookie. + * It takes the language from the agamaLang cookie. * * @return {string|undefined} Undefined if language is not set. */ -function cockpitLanguage() { +function agamaLanguage() { // language from cookie, empty string if not set (regexp taken from Cockpit) // https://github.com/cockpit-project/cockpit/blob/98a2e093c42ea8cd2431cf15c7ca0e44bb4ce3f1/pkg/shell/shell-modals.jsx#L91 - const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1")); + const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)agamaLang\s*=\s*([^;]*).*$)|^.*$/, "$1")); if (languageString) { return languageString.toLowerCase(); } } /** - * Helper function for storing the Cockpit language. + * Helper function for storing the Agama language. * - * Automatically converts the language from xx_XX to xx-xx, as it is the one used by Cockpit. + * Automatically converts the language from xx_XX to xx-xx, as it is the one used by Agama. * * @param {string} language - The new locale (e.g., "cs", "cs_CZ"). * @return {boolean} True if the locale was changed. */ -function storeCockpitLanguage(language) { - const current = cockpitLanguage(); +function storeAgamaLanguage(language) { + const current = agamaLanguage(); if (current === language) return false; // Code taken from Cockpit. - const cookie = "CockpitLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; + const cookie = "agamaLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; document.cookie = cookie; + + // for backward compatibility, CockpitLang cookie is needed to load correct po.js content from Cockpit + // TODO: remove after dropping Cockpit completely + const cockpit_cookie = "CockpitLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT"; + document.cookie = cockpit_cookie; window.localStorage.setItem("cockpit.lang", language); + return true; } @@ -238,16 +245,16 @@ function InstallerL10nProvider({ children }) { const wanted = lang || languageFromQuery(); if (wanted === "xx" || wanted === "xx-xx") { - cockpit.language = wanted; + agama.language = wanted; setLanguage(wanted); return; } - const current = cockpitLanguage(); + const current = agamaLanguage(); const candidateLanguages = [wanted, current].concat(navigatorLanguages()).filter(l => l); const newLanguage = findSupportedLanguage(candidateLanguages) || "en-us"; - let mustReload = storeCockpitLanguage(newLanguage); + let mustReload = storeAgamaLanguage(newLanguage); mustReload = await storeInstallerLanguage(newLanguage) || mustReload; if (mustReload) { diff --git a/web/src/context/installerL10n.test.jsx b/web/src/context/installerL10n.test.jsx index 4327927a90..bcf703a51d 100644 --- a/web/src/context/installerL10n.test.jsx +++ b/web/src/context/installerL10n.test.jsx @@ -56,7 +56,7 @@ jest.mock("~/lib/cockpit", () => ({ })); // Helper component that displays a translated message depending on the -// CockpitLang value. +// agamaLang value. const TranslatedContent = () => { const text = { "cs-cz": "ahoj", @@ -65,7 +65,7 @@ const TranslatedContent = () => { "es-ar": "hola!", }; - const regexp = /CockpitLang=([^;]+)/; + const regexp = /agamaLang=([^;]+)/; const found = document.cookie.match(regexp); if (!found) return <>{text["en-us"]}; @@ -85,7 +85,7 @@ describe("InstallerL10nProvider", () => { // remove the Cockpit language cookie after each test afterEach(() => { // setting a cookie with already expired date removes it - document.cookie = "CockpitLang=; path=/; expires=" + new Date(0).toUTCString(); + document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString(); }); describe("when no URL query parameter is set", () => { @@ -95,7 +95,7 @@ describe("InstallerL10nProvider", () => { describe("when the Cockpit language is already set", () => { beforeEach(() => { - document.cookie = "CockpitLang=en-us; path=/;"; + document.cookie = "agamaLang=en-us; path=/;"; getUILocaleFn.mockResolvedValueOnce("en_US.UTF-8"); }); @@ -115,7 +115,7 @@ describe("InstallerL10nProvider", () => { describe("when the Cockpit language is set to an unsupported language", () => { beforeEach(() => { - document.cookie = "CockpitLang=de-de; path=/;"; + document.cookie = "agamaLang=de-de; path=/;"; getUILocaleFn.mockResolvedValueOnce("de_DE.UTF-8"); getUILocaleFn.mockResolvedValueOnce("es_ES.UTF-8"); }); @@ -200,7 +200,7 @@ describe("InstallerL10nProvider", () => { describe("when the Cockpit language is already set to 'cs-cz'", () => { beforeEach(() => { - document.cookie = "CockpitLang=cs-cz; path=/;"; + document.cookie = "agamaLang=cs-cz; path=/;"; getUILocaleFn.mockResolvedValueOnce("cs_CZ.UTF-8"); }); @@ -215,7 +215,7 @@ describe("InstallerL10nProvider", () => { await screen.findByText("ahoj"); expect(setUILocaleFn).not.toHaveBeenCalled(); - expect(document.cookie).toEqual("CockpitLang=cs-cz"); + expect(document.cookie).toMatch(/agamaLang=cs-cz/); expect(utils.locationReload).not.toHaveBeenCalled(); expect(utils.setLocationSearch).not.toHaveBeenCalled(); }); @@ -223,7 +223,7 @@ describe("InstallerL10nProvider", () => { describe("when the Cockpit language is set to 'en-us'", () => { beforeEach(() => { - document.cookie = "CockpitLang=en-us; path=/;"; + document.cookie = "agamaLang=en-us; path=/;"; getUILocaleFn.mockResolvedValueOnce("en_US"); getUILocaleFn.mockResolvedValueOnce("cs_CZ"); setUILocaleFn.mockResolvedValue(); diff --git a/web/src/i18n.js b/web/src/i18n.js index 2850a29277..ac78dd7f5c 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -20,19 +20,19 @@ */ /** - * This is a wrapper module for i18n functions. Currently it uses the cockpit - * implementation but the wrapper allows easy transition to another backend if - * needed. + * This is a wrapper module for i18n functions. Currently it uses the + * implementation similar to cockpit but the wrapper allows easy transition to + * another backend if needed. */ -import cockpit from "./lib/cockpit"; +import agama from "~/agama"; /** * Tests whether a special testing language is used. * * @returns {boolean} true if the testing language is set */ -const isTestingLanguage = () => cockpit.language === "xx"; +const isTestingLanguage = () => agama.language === "xx"; /** * "Translate" the string to special "xx" testing language. @@ -72,7 +72,7 @@ const xTranslate = (str) => { * @param {string} str the input string to translate * @return {string} translated or original text */ -const _ = (str) => isTestingLanguage() ? xTranslate(str) : cockpit.gettext(str); +const _ = (str) => isTestingLanguage() ? xTranslate(str) : agama.gettext(str); /** * Similar to the _() function. This variant returns singular or plural form @@ -88,7 +88,7 @@ const _ = (str) => isTestingLanguage() ? xTranslate(str) : cockpit.gettext(str); const n_ = (str1, strN, n) => { return isTestingLanguage() ? xTranslate((n === 1) ? str1 : strN) - : cockpit.ngettext(str1, strN, n); + : agama.ngettext(str1, strN, n); }; /** diff --git a/web/src/i18n.test.js b/web/src/i18n.test.js index 6cf3513155..60f7db3903 100644 --- a/web/src/i18n.test.js +++ b/web/src/i18n.test.js @@ -20,14 +20,14 @@ */ import { _, n_, N_, Nn_ } from "~/i18n"; -import cockpit from "./lib/cockpit"; +import agama from "~/agama"; // mock the cockpit gettext functions -jest.mock("./lib/cockpit"); +jest.mock("~/agama"); const gettextFn = jest.fn(); -cockpit.gettext.mockImplementation(gettextFn); +agama.gettext.mockImplementation(gettextFn); const ngettextFn = jest.fn(); -cockpit.ngettext.mockImplementation(ngettextFn); +agama.ngettext.mockImplementation(ngettextFn); // some testing texts const text = "text to translate"; @@ -36,7 +36,7 @@ const pluralText = "plural text to translate"; describe("i18n", () => { describe("_", () => { - it("calls the cockpit.gettext() implementation", () => { + it("calls the agama.gettext() implementation", () => { _(text); expect(gettextFn).toHaveBeenCalledWith(text); @@ -44,7 +44,7 @@ describe("i18n", () => { }); describe("n_", () => { - it("calls the cockpit.ngettext() implementation", () => { + it("calls the agama.ngettext() implementation", () => { n_(singularText, pluralText, 1); expect(ngettextFn).toHaveBeenCalledWith(singularText, pluralText, 1); diff --git a/web/src/lib/webpack-po-handler.js b/web/src/lib/webpack-po-handler.js index 78a26f216a..9d9a51a38b 100644 --- a/web/src/lib/webpack-po-handler.js +++ b/web/src/lib/webpack-po-handler.js @@ -11,7 +11,7 @@ const path = require("path"); // @param res HTTP response module.exports = function (req, res) { // the regexp was taken from the original Cockpit code :-) - const language = req.headers.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1") || ""; + const language = req.headers.cookie.replace(/(?:(?:^|.*;\s*)agamaLang\s*=\s*([^;]*).*$)|^.*$/, "$1") || ""; // the cookie uses "pt-br" format while the PO file is "pt_BR" :-/ let [lang, country] = language.split("-"); country = country?.toUpperCase(); diff --git a/web/webpack.config.js b/web/webpack.config.js index 593383066d..8fdda134b0 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -46,7 +46,9 @@ const copy_files = [ const plugins = [ new Copy({ patterns: copy_files }), new Extract({ filename: "[name].css" }), - new CockpitPoPlugin(), + // the wrapper sets the main code called in the po.js files, + // the PO_DATA tag is replaced by the real translation data + new CockpitPoPlugin({ wrapper: "agama.locale(PO_DATA);" }), new CockpitRsyncPlugin({ dest: packageJson.name }), development && new ReactRefreshWebpackPlugin({ overlay: false }), // replace the "process.env.WEBPACK_SERVE" text in the source code by