Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Drop Cockpit dependency for web translations #1118

Merged
merged 2 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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", () => {
Expand Down
107 changes: 107 additions & 0 deletions web/src/agama.js
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions web/src/components/l10n/InstallerKeymapSwitcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));
};

Expand Down
31 changes: 19 additions & 12 deletions web/src/context/installerL10n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// 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;
}

Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 8 additions & 8 deletions web/src/context/installerL10n.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]}</>;

Expand All @@ -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", () => {
Expand All @@ -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");
});

Expand All @@ -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");
});
Expand Down Expand Up @@ -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");
});

Expand All @@ -215,15 +215,15 @@ 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();
});
});

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();
Expand Down
14 changes: 7 additions & 7 deletions web/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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);
};

/**
Expand Down
12 changes: 6 additions & 6 deletions web/src/i18n.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -36,15 +36,15 @@ 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);
});
});

describe("n_", () => {
it("calls the cockpit.ngettext() implementation", () => {
it("calls the agama.ngettext() implementation", () => {
n_(singularText, pluralText, 1);

expect(ngettextFn).toHaveBeenCalledWith(singularText, pluralText, 1);
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/webpack-po-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion web/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);" }),
Copy link
Contributor Author

@lslezak lslezak Mar 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: The default wrapper is cockpit.locale(PO_DATA) which calls the Cockpit implementation.

new CockpitRsyncPlugin({ dest: packageJson.name }),
development && new ReactRefreshWebpackPlugin({ overlay: false }),
// replace the "process.env.WEBPACK_SERVE" text in the source code by
Expand Down
Loading