commit e2c76b5683e0d8300b7b70138567db3e0261b135 parent 1608ac45588ad17d2d558040c266af95f651e817 Author: Florian Dold <dold@taler.net> Date: Thu, 3 Sep 2026 11:07:03 +0200 web UIs: mark visible interface text for translation Diffstat:
131 files changed, 2597 insertions(+), 1020 deletions(-)
diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json @@ -10,6 +10,9 @@ "dev": "./dev.mjs", "clean": "rm -rf dist lib tsconfig.tsbuildinfo", "lint": "../qa-tooling/bin/eslint.mjs .", + "i18n:check": "pogen check", + "i18n:source2po": "pogen extract && pogen merge", + "i18n:po2strings": "pogen emit", "typedoc": "pnpm dlx typedoc --out dist/typedoc ./src/", "test": "./test.mjs && node --test --enable-source-maps 'dist/test/**/*test.js'", "pretty": "prettier --write src" @@ -26,6 +29,7 @@ "qrcode-generator": "^1.4.4" }, "devDependencies": { + "@gnu-taler/pogen": "workspace:*", "@creativebulma/bulma-tooltip": "^1.2.0", "@types/chai": "^4.3.0", "@types/node": "^20.19.41", @@ -35,5 +39,9 @@ "chai": "^6.2.2", "sass": "1.56.1", "typescript": "^7.0.2" + }, + "pogen": { + "domain": "taler-anastasis", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -19,6 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { i18n } from "@gnu-taler/taler-util"; import { ComponentChildren, h, VNode } from "preact"; import { useLayoutEffect, useRef } from "preact/hooks"; import { useAsync } from "../hooks/async.js"; @@ -51,7 +52,11 @@ export function AsyncButton({ // return <LoadingModal onCancel={cancel} />; // } if (isLoading) { - return <button class="button">Loading...</button>; + return ( + <button class="button"> + <i18n.Translate>Loading...</i18n.Translate> + </button> + ); } return ( diff --git a/packages/anastasis-webui/src/components/FlieButton.tsx b/packages/anastasis-webui/src/components/FlieButton.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useRef, useState } from "preact/hooks"; @@ -64,7 +65,9 @@ export function FileButton(props: Props): VNode { }} /> {sizeError && ( - <p class="help is-danger">File should be smaller than 1 MB</p> + <p class="help is-danger"> + <i18n.Translate>File should be smaller than 1 MB</i18n.Translate> + </p> )} </div> ); diff --git a/packages/anastasis-webui/src/components/InvalidState.tsx b/packages/anastasis-webui/src/components/InvalidState.tsx @@ -14,8 +14,13 @@ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; export default function InvalidState(): VNode { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } diff --git a/packages/anastasis-webui/src/components/NoReducer.tsx b/packages/anastasis-webui/src/components/NoReducer.tsx @@ -14,8 +14,13 @@ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; export default function NoReducer(): VNode { - return <div>no reducer</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -13,10 +13,12 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { format, subYears } from "date-fns"; import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { DatePicker } from "../picker/DatePicker.js"; +import { useTranslationContext } from "../../context/translation.js"; export interface DateInputProps { label: string; @@ -29,6 +31,7 @@ export interface DateInputProps { } export function DateInput(props: DateInputProps): VNode { + const { lang } = useTranslationContext(); const inputRef = useRef<HTMLInputElement>(null); useLayoutEffect(() => { if (props.grabFocus) { @@ -88,9 +91,12 @@ export function DateInput(props: DateInputProps): VNode { </p> </div> </div> - <p class="help">Using the format yyyy-mm-dd</p> + <p class="help"> + <i18n.Translate>Using the format yyyy-mm-dd</i18n.Translate> + </p> {showError && <p class="help is-danger">{props.error}</p>} <DatePicker + locale={lang} opened={opened} initialDate={calendar} years={props.years} diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -18,6 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; @@ -101,7 +102,9 @@ export function FileInput(props: FileInputProps): VNode { /> {props.error && <p class="help is-danger">{props.error}</p>} {sizeError && ( - <p class="help is-danger">File should be smaller than 1 MB</p> + <p class="help is-danger"> + <i18n.Translate>File should be smaller than 1 MB</i18n.Translate> + </p> )} </div> </div> diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -19,7 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; +import { useTranslationContext } from "../../context/translation.js"; interface Props { onMobileMenu: () => void; @@ -27,11 +29,12 @@ interface Props { } export function NavigationBar({ onMobileMenu, title }: Props): VNode { + const { lang, changeLanguage, supportedLang } = useTranslationContext(); return ( <nav class="navbar is-fixed-top" role="navigation" - aria-label="main navigation" + aria-label={i18n.str`Main navigation`} > <div class="navbar-brand"> <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> @@ -41,13 +44,13 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode { href="mailto:contact@anastasis.lu" style={{ alignSelf: "center", padding: "0.5em" }} > - Contact us + <i18n.Translate>Contact us</i18n.Translate> </a> <a href="https://bugs.anastasis.lu/" style={{ alignSelf: "center", padding: "0.5em" }} > - Report a bug + <i18n.Translate>Report a bug</i18n.Translate> </a> {/* <a style={{ @@ -76,7 +79,23 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode { <div class="navbar-menu "> <div class="navbar-end"> <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> - {/* <LangSelector /> */} + <label class="is-sr-only" htmlFor="language-selector"> + <i18n.Translate>Select language</i18n.Translate> + </label> + <div class="select is-small"> + <select + id="language-selector" + value={lang} + aria-label={i18n.str`Select language`} + onChange={(event) => changeLanguage(event.currentTarget.value)} + > + {Object.entries(supportedLang).map(([code, name]) => ( + <option key={code} value={code}> + {name} + </option> + ))} + </select> + </div> </div> </div> </div> diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -55,20 +55,22 @@ export function Sidebar({ mobile }: Props): VNode { <div class="aside-tools"> <div class="aside-tools-label"> <div> - <b>Anastasis</b> + <b> + <i18n.Translate>Anastasis</i18n.Translate> + </b> </div> <div class="is-size-7 has-text-right" style={{ lineHeight: 0, marginTop: -10 }} > - Version {VERSION_WITH_HASH} + <i18n.Translate>Version {VERSION_WITH_HASH}</i18n.Translate> </div> </div> </div> <div class="menu is-menu-main"> {!reducer.currentReducerState && ( <p class="menu-label"> - <i18n.Translate>Backup or Recover</i18n.Translate> + <i18n.Translate>Back up or recover</i18n.Translate> </p> )} <ul class="menu-list"> @@ -189,7 +191,7 @@ export function Sidebar({ mobile }: Props): VNode { class="button is-primary is-right" onClick={saveSession} > - Save backup session + <i18n.Translate>Save backup session</i18n.Translate> </button> </div> </li> @@ -202,7 +204,7 @@ export function Sidebar({ mobile }: Props): VNode { class="button is-danger is-right" onClick={() => reducer.reset()} > - Reset session + <i18n.Translate>Reset session</i18n.Translate> </button> </div> </li> @@ -267,7 +269,7 @@ export function Sidebar({ mobile }: Props): VNode { > <div class="ml-4"> <span class="menu-item-label"> - <i18n.Translate>Solve Challenges</i18n.Translate> + <i18n.Translate>Solve challenges</i18n.Translate> </span> </div> </li> @@ -293,7 +295,7 @@ export function Sidebar({ mobile }: Props): VNode { class="button is-primary is-right" onClick={saveSession} > - Save recovery session + <i18n.Translate>Save recovery session</i18n.Translate> </button> </div> </li> @@ -308,7 +310,7 @@ export function Sidebar({ mobile }: Props): VNode { class="button is-danger is-right" onClick={() => reducer.reset()} > - Reset session + <i18n.Translate>Reset session</i18n.Translate> </button> </div> </li> diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx @@ -19,6 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { i18n } from "@gnu-taler/taler-util"; import { Component, h } from "preact"; interface Props { @@ -27,6 +28,7 @@ interface Props { initialDate?: Date; years?: Array<number>; opened?: boolean; + locale?: string; } interface State { displayedMonth: number; @@ -36,40 +38,26 @@ interface State { } const now = new Date(); -const monthArrShortFull = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -const monthArrShort = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; - -const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const yearArr: number[] = []; +function weekdayHeaders(): string[] { + // Translators: One-letter abbreviation for Sunday in a calendar header. + const sunday = i18n.ctx("calendar weekday: Sunday")`S`; + // Translators: One-letter abbreviation for Monday in a calendar header. + const monday = i18n.ctx("calendar weekday: Monday")`M`; + // Translators: One-letter abbreviation for Tuesday in a calendar header. + const tuesday = i18n.ctx("calendar weekday: Tuesday")`T`; + // Translators: One-letter abbreviation for Wednesday in a calendar header. + const wednesday = i18n.ctx("calendar weekday: Wednesday")`W`; + // Translators: One-letter abbreviation for Thursday in a calendar header. + const thursday = i18n.ctx("calendar weekday: Thursday")`T`; + // Translators: One-letter abbreviation for Friday in a calendar header. + const friday = i18n.ctx("calendar weekday: Friday")`F`; + // Translators: One-letter abbreviation for Saturday in a calendar header. + const saturday = i18n.ctx("calendar weekday: Saturday")`S`; + return [sunday, monday, tuesday, wednesday, thursday, friday, saturday]; +} + // inspired by https://codepen.io/m4r1vs/pen/MOOxyE export class DatePicker extends Component<Props, State> { closeDatePicker() { @@ -251,8 +239,11 @@ export class DatePicker extends Component<Props, State> { }} onClick={this.displaySelectedMonth} > - {dayArr[currentDate.getDay()]},{" "} - {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} + {new Intl.DateTimeFormat(this.props.locale, { + weekday: "short", + month: "short", + day: "numeric", + }).format(currentDate)} </button> </div> @@ -262,7 +253,7 @@ export class DatePicker extends Component<Props, State> { type="button" onClick={this.displayPrevMonth} class="icon" - aria-label="Previous month" + aria-label={i18n.str`Previous month`} > <i style={{ transform: "rotate(180deg)" }} @@ -270,13 +261,16 @@ export class DatePicker extends Component<Props, State> { /> </button> <h4> - {monthArrShortFull[displayedMonth]} {displayedYear} + {new Intl.DateTimeFormat(this.props.locale, { + month: "long", + year: "numeric", + }).format(new Date(displayedYear, displayedMonth, 1))} </h4> <button type="button" onClick={this.displayNextMonth} class="icon" - aria-label="Next month" + aria-label={i18n.str`Next month`} > <i class="mdi mdi-forward" /> </button> @@ -287,7 +281,7 @@ export class DatePicker extends Component<Props, State> { {!selectYearMode && ( <div class="datePicker--calendar"> <div class="datePicker--dayNames"> - {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => ( + {weekdayHeaders().map((day, i) => ( <span key={i}>{day}</span> ))} </div> @@ -347,7 +341,7 @@ export class DatePicker extends Component<Props, State> { <button type="button" - aria-label="Close date picker" + aria-label={i18n.str`Close date picker`} class="datePicker--background" onClick={this.closeDatePicker} style={{ diff --git a/packages/anastasis-webui/src/context/translation.ts b/packages/anastasis-webui/src/context/translation.ts @@ -34,17 +34,11 @@ interface Type { } const supportedLang = { - es: "Español [es]", - ja: "日本語 [ja]", en: "English [en]", - fr: "Français [fr]", de: "Deutsch [de]", - sv: "Svenska [sv]", + "de-CH": "Deutsch (Schweiz) [de-CH]", + fr: "Français [fr]", it: "Italiano [it]", - // ko: "한국어 [ko]", - // ru: "Ру́сский язы́к [ru]", - tr: "Türk [tr]", - navigator: "Defined by navigator", }; const initial = { diff --git a/packages/anastasis-webui/src/hooks/useLang.test.ts b/packages/anastasis-webui/src/hooks/useLang.test.ts @@ -0,0 +1,26 @@ +/* + This file is part of GNU Anastasis + (C) 2026 Taler Systems SA + + GNU Anastasis is free software; you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation; either version 3, or (at your option) any later version. + */ + +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { normalizeLanguage } from "./useLang.js"; + +describe("normalizeLanguage", () => { + it("preserves Swiss German as the catalog key", () => { + assert.equal(normalizeLanguage("de-CH"), "de-CH"); + assert.equal(normalizeLanguage("de_CH"), "de-CH"); + assert.equal(normalizeLanguage("de-CH-1996"), "de-CH"); + }); + + it("uses the base language for other locales", () => { + assert.equal(normalizeLanguage("de-DE"), "de"); + assert.equal(normalizeLanguage("fr-CH"), "fr"); + assert.equal(normalizeLanguage("it-CH"), "it"); + }); +}); diff --git a/packages/anastasis-webui/src/hooks/useLang.ts b/packages/anastasis-webui/src/hooks/useLang.ts @@ -22,9 +22,14 @@ function getBrowserLang(): string | undefined { return undefined; } +export function normalizeLanguage(language: string): string { + return /^de[-_]ch\b/iu.test(language) ? "de-CH" : language.substring(0, 2); +} + export function useLang( initial?: string, ): [string, (s: string) => void, boolean] { - const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2); + const browserLang = getBrowserLang() || initial || "en"; + const defaultLang = normalizeLanguage(browserLang); return useNotNullLocalStorage("lang-preference", defaultLang); } diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts @@ -14,6 +14,7 @@ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { AuthenticationProviderStatus } from "@gnu-taler/anastasis-core"; +import { i18n } from "@gnu-taler/taler-util"; import InvalidState from "../../../components/InvalidState.js"; import NoReducer from "../../../components/NoReducer.js"; import { Notification } from "../../../components/Notifications.js"; @@ -70,13 +71,13 @@ export async function testProvider( url: string, expectedMethodType?: string, ): Promise<void> { - const testFatalPrefix = `Encountered a fatal error whilst testing the provider ${url}`; let configUrl = ""; try { configUrl = new URL("config", url).href; } catch (error) { - throw new Error(`${testFatalPrefix}: Invalid Provider URL: ${url} -Error: ${error}`); + // Translators: %1$s is a provider URL; %2$s is a technical error message. + const message = i18n.str`Could not test provider ${url}: invalid provider URL. Error: ${String(error)}`; + throw new Error(message); } // TODO: look into using core.getProviderInfo :) const providerHasUrl = providerResponseCache.has(url); @@ -84,34 +85,37 @@ Error: ${error}`); ? providerResponseCache.get(url) : await fetch(configUrl) .catch((error) => { - throw new Error(`${testFatalPrefix}: Could not connect: ${error} -Please check the URL.`); + // Translators: %1$s is a provider URL; %2$s is a technical error message. + const message = i18n.str`Could not connect to provider ${url}. Check the URL. Error: ${String(error)}`; + throw new Error(message); }) .then(async (response) => { - if (!response.ok) - throw new Error( - `${testFatalPrefix}: The server ${response.url} responded with a non-2xx response.`, - ); + if (!response.ok) { + // Translators: %1$s is the URL of a server that returned an error response. + const message = i18n.str`Could not test provider: the server ${response.url} returned an error response.`; + throw new Error(message); + } try { return await response.json(); } catch (error) { - throw new Error( - `${testFatalPrefix}: The server responded with malformed JSON.\nError: ${error}`, - ); + // Translators: %1$s is a provider URL; %2$s is a technical error message. + const message = i18n.str`Could not test provider ${url}: the server returned malformed JSON. Error: ${String(error)}`; + throw new Error(message); } }); - if (typeof json !== "object") - throw new Error( - `${testFatalPrefix}: Did not get an object after decoding.`, - ); + if (typeof json !== "object") { + // Translators: %1$s is a provider URL. + const message = i18n.str`Could not test provider ${url}: invalid response.`; + throw new Error(message); + } if (!("name" in json) || json.name !== "anastasis") { - throw new Error( - `${testFatalPrefix}: The provider does not appear to be an Anastasis provider. Please check the provider's URL.`, - ); + // Translators: %1$s is a provider URL. + const message = i18n.str`${url} does not appear to be an Anastasis provider. Check the URL.`; + throw new Error(message); } if (!("methods" in json) || !Array.isArray(json.methods)) { throw new Error( - "This provider doesn't have authorization method. Please check the provider's URL and ensure it is properly configured.", + i18n.str`This provider has no authorization methods. Check the URL and make sure the provider is configured correctly.`, ); } if (!providerHasUrl) providerResponseCache.set(url, json); @@ -123,9 +127,9 @@ Please check the URL.`); found = json.methods[i].type === expectedMethodType; } if (!found) { - throw new Error( - `${testFatalPrefix}: This provider does not support authorization method ${expectedMethodType}`, - ); + // Translators: %1$s is a provider URL; %2$s is an authorization method identifier. + const message = i18n.str`Provider ${url} does not support the ${expectedMethodType} authorization method.`; + throw new Error(message); } return; } diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts @@ -14,6 +14,7 @@ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { i18n } from "@gnu-taler/taler-util"; import { Notification } from "../../../components/Notifications.js"; import { useAnastasisContext } from "../../../context/anastasis.js"; import { authMethods, KnownAuthMethods } from "../authMethod/index.jsx"; @@ -83,7 +84,7 @@ export default function useComponentState({ if (!url || authProviders.includes(url)) return; if (url && !url.match(/^(https?:)\/\/.+\/(?:config)?$/iu)) return setError( - "Malformed URL: Must be an HTTP(S) URL ending with a /", + i18n.str`Malformed URL: enter an HTTP(S) URL ending with a slash (/)`, ); if (url.endsWith("/config")) url = url.substring(0, url.length - 6); try { @@ -124,13 +125,15 @@ export default function useComponentState({ reducer.transition("delete_provider", { provider_url }); }; - let errors = !providerURL ? "Add provider URL" : undefined; + let errors: string | undefined = !providerURL + ? i18n.str`Add a provider URL` + : undefined; let url: string | undefined; // We'll validate it in testProvider & via a regex above - there's no need in this :) try { url = new URL("", providerURL).href; } catch { - errors = "Check the URL"; + errors = i18n.str`Check the URL`; } const _url = url; @@ -138,7 +141,7 @@ export default function useComponentState({ errors = error; } if (!errors && authProviders.includes(url!)) { - errors = "That provider is already known"; + errors = i18n.str`That provider is already known`; } const commonState = { diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx @@ -17,6 +17,7 @@ import { AuthenticationProviderStatusError, AuthenticationProviderStatusOk, } from "@gnu-taler/anastasis-core"; +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { TextInput } from "../../../components/fields/TextInput.js"; @@ -30,7 +31,7 @@ export function WithProviderType(props: WithType): VNode { return ( <AnastasisClientFrame hideNav - title="Backup: Manage providers" + title={i18n.str`Backup: Manage providers`} hideNext={props.errors} > <div> @@ -38,15 +39,23 @@ export function WithProviderType(props: WithType): VNode { <p>{i18n.str`Add a provider URL for a ${props.providerLabel} service`}</p> <div class="container"> <TextInput - label="Provider URL" - placeholder="https://provider.com" + label={i18n.str`Provider URL`} + placeholder={i18n.str`https://provider.com`} grabFocus error={props.errors} bind={[props.providerURL, props.setProviderURL]} /> </div> - <p class="block">Example: https://kudos.demo.anastasis.lu</p> - {props.testing && <p class="has-text-info">Testing</p>} + <p class="block"> + <i18n.Translate> + Example: https://kudos.demo.anastasis.lu + </i18n.Translate> + </p> + {props.testing && ( + <p class="has-text-info"> + <i18n.Translate>Testing provider...</i18n.Translate> + </p> + )} <div class="block" @@ -57,7 +66,7 @@ export function WithProviderType(props: WithType): VNode { }} > <button class="button" onClick={props.onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={props.errors}> <button @@ -65,18 +74,22 @@ export function WithProviderType(props: WithType): VNode { disabled={props.error !== "" || props.testing} onClick={props.addProvider} > - Add + <i18n.Translate>Add provider</i18n.Translate> </button> </span> </div> {props.authProvidersByStatus["ok"].length > 0 ? ( <p class="subtitle"> - Current providers for {props.providerLabel} service + <i18n.Translate> + Current providers for {props.providerLabel} service + </i18n.Translate> </p> ) : ( <p class="subtitle"> - No known providers for {props.providerLabel} service + <i18n.Translate> + No known providers for {props.providerLabel} service + </i18n.Translate> </p> )} @@ -91,7 +104,9 @@ export function WithProviderType(props: WithType): VNode { /> ); })} - <p class="subtitle">Providers with errors</p> + <p class="subtitle"> + <i18n.Translate>Providers with errors</i18n.Translate> + </p> {props.authProvidersByStatus["error"].map((k, i) => { const p = k as AuthenticationProviderStatusError; return ( @@ -112,23 +127,33 @@ export function WithoutProviderType(props: WithoutType): VNode { return ( <AnastasisClientFrame hideNav - title="Backup: Manage providers" + title={i18n.str`Backup: Manage providers`} hideNext={props.errors} > <div> <Notifications notifications={props.notifications} /> - <p>Add a provider URL</p> + <p> + <i18n.Translate>Add a provider URL</i18n.Translate> + </p> <div class="container"> <TextInput - label="Provider URL" - placeholder="https://provider.com/" + label={i18n.str`Provider URL`} + placeholder={i18n.str`https://provider.com/`} grabFocus error={props.errors} bind={[props.providerURL, props.setProviderURL]} /> </div> - <p class="block">Example: https://kudos.demo.anastasis.lu/</p> - {props.testing && <p class="has-text-info">Testing</p>} + <p class="block"> + <i18n.Translate> + Example: https://kudos.demo.anastasis.lu/ + </i18n.Translate> + </p> + {props.testing && ( + <p class="has-text-info"> + <i18n.Translate>Testing provider...</i18n.Translate> + </p> + )} <div class="block" @@ -139,7 +164,7 @@ export function WithoutProviderType(props: WithoutType): VNode { }} > <button class="button" onClick={props.onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={props.errors}> <button @@ -147,15 +172,19 @@ export function WithoutProviderType(props: WithoutType): VNode { disabled={props.error !== "" || props.testing} onClick={props.addProvider} > - Add + <i18n.Translate>Add provider</i18n.Translate> </button> </span> </div> {props.authProvidersByStatus["ok"].length > 0 ? ( - <p class="subtitle">Current providers</p> + <p class="subtitle"> + <i18n.Translate>Current providers</i18n.Translate> + </p> ) : ( - <p class="subtitle">No known providers, add one.</p> + <p class="subtitle"> + <i18n.Translate>No known providers, add one.</i18n.Translate> + </p> )} {props.authProvidersByStatus["ok"].map((k, i) => { @@ -169,7 +198,9 @@ export function WithoutProviderType(props: WithoutType): VNode { /> ); })} - <p class="subtitle">Providers with errors</p> + <p class="subtitle"> + <i18n.Translate>Providers with errors</i18n.Translate> + </p> {props.authProvidersByStatus["error"].map((k, i) => { const p = k as AuthenticationProviderStatusError; return ( @@ -217,21 +248,34 @@ function TableRow({ <div class="subtitle">{url}</div> <dl> <dt> - <b>Business Name</b> + <b> + <i18n.Translate>Business name</i18n.Translate> + </b> </dt> <dd>{info.business_name}</dd> <dt> - <b>Supported methods</b> + <b> + <i18n.Translate>Supported methods</i18n.Translate> + </b> </dt> <dd>{info.methods.map((m) => m.type).join(",")}</dd> <dt> - <b>Maximum storage</b> + <b> + <i18n.Translate>Maximum storage</i18n.Translate> + </b> </dt> - <dd>{info.storage_limit_in_megabytes} Mb</dd> + <dd> + {/* Translators: %1$s is the provider's storage limit. MB means megabytes. */} + <i18n.Translate> + {info.storage_limit_in_megabytes} MB + </i18n.Translate> + </dd> <dt> - <b>Status</b> + <b> + <i18n.Translate>Provider status</i18n.Translate> + </b> </dt> - <dd>{status}</dd> + <dd>{providerStatusLabel(status)}</dd> </dl> </div> <div @@ -245,7 +289,7 @@ function TableRow({ }} > <button class="button is-danger" onClick={() => onDelete(url)}> - Remove + <i18n.Translate>Remove provider</i18n.Translate> </button> </div> </div> @@ -283,17 +327,23 @@ function TableRowError({ <div class="subtitle">{url}</div> <dl> <dt> - <b>Error</b> + <b> + <i18n.Translate>Provider error</i18n.Translate> + </b> </dt> <dd>{info.hint}</dd> <dt> - <b>Code</b> + <b> + <i18n.Translate>Error code</i18n.Translate> + </b> </dt> <dd>{info.code}</dd> <dt> - <b>Status</b> + <b> + <i18n.Translate>Provider status</i18n.Translate> + </b> </dt> - <dd>{status}</dd> + <dd>{providerStatusLabel(status)}</dd> </dl> </div> <div @@ -307,9 +357,22 @@ function TableRowError({ }} > <button class="button is-danger" onClick={() => onDelete(url)}> - Remove + <i18n.Translate>Remove provider</i18n.Translate> </button> </div> </div> ); } + +function providerStatusLabel(status: string): string { + switch (status) { + case "checking": + return i18n.str`Checking`; + case "responding": + return i18n.str`Responding`; + case "failed to contact": + return i18n.str`Failed to contact`; + default: + return status; + } +} diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { UserAttributeSpec, validators } from "@gnu-taler/anastasis-core"; import { isAfter, parse } from "date-fns"; import { h, VNode } from "preact"; @@ -38,13 +39,21 @@ export function AttributeEntryScreen(): VNode { const [askUserIfSure, setAskUserIfSure] = useState(false); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if ( !reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState) ) { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const reqAttr = reducer.currentReducerState.required_attributes || []; let hasErrors = false; @@ -100,7 +109,7 @@ export function AttributeEntryScreen(): VNode { if (!printWindow || !divContents) return; printWindow.document.write( - `<html><head><link rel="stylesheet" href="index.css" /><title>Anastasis Recovery Document</title><style>`, + `<html><head><link rel="stylesheet" href="index.css" /><title>${i18n.str`Anastasis recovery document`}</title><style>`, ); printWindow.document.write("</style></head><body> </body></html>"); printWindow.document.close(); @@ -113,26 +122,30 @@ export function AttributeEntryScreen(): VNode { return ( <AnastasisClientFrame - title={withProcessLabel(reducer, "Who are you?")} - hideNext={hasErrors ? "Complete the form." : undefined} + title={withProcessLabel(reducer, i18n.str`Who are you?`)} + hideNext={hasErrors ? i18n.str`Complete the form.` : undefined} onNext={async () => (isBackup ? setAskUserIfSure(true) : doConfirm())} > {askUserIfSure ? ( <ConfirmModal active onCancel={() => setAskUserIfSure(false)} - description="The values in the form must be correct" - label="I am sure" - cancelLabel="Wait, I want to check" + description={i18n.str`The values in the form must be correct`} + label={i18n.str`I am sure`} + cancelLabel={i18n.str`Wait, I want to check`} onConfirm={() => doConfirm().then(() => setAskUserIfSure(false))} > - Your personal information is used to define the location where your - secret will be safely stored. If you forget what you have entered or - if there is a typo you will be unable to recover your secret. + <i18n.Translate> + Your personal information is used to define the location where your + secret will be safely stored. If you forget what you have entered or + if there is a typo you will be unable to recover your secret. + </i18n.Translate> <p> {/* TODO: make this actually work reliably cross-browser lol (opens about:blank for me) */} <button type="button" class="button is-ghost" onClick={saveAsPDF}> - Save the personal information as PDF + <i18n.Translate> + Save the personal information as PDF + </i18n.Translate> </button> </p> </ConfirmModal> @@ -143,21 +156,35 @@ export function AttributeEntryScreen(): VNode { {fieldList} </div> <div class="column"> - <p>This personal information will help to locate your secret.</p> - <h1 class="title">This stays private</h1> - <p>The information you have entered here:</p> + <p> + <i18n.Translate> + This personal information will help to locate your secret. + </i18n.Translate> + </p> + <h1 class="title"> + <i18n.Translate>This stays private</i18n.Translate> + </h1> + <p> + <i18n.Translate> + The information you have entered here: + </i18n.Translate> + </p> <ul> <li> <span class="icon is-right"> <i class="mdi mdi-circle-small" /> </span> - Will be hashed, and therefore unreadable + <i18n.Translate> + Will be hashed, and therefore unreadable + </i18n.Translate> </li> <li> <span class="icon is-right"> <i class="mdi mdi-circle-small" /> </span> - The non-hashed version is not shared + <i18n.Translate> + The non-hashed version is not shared + </i18n.Translate> </li> </ul> </div> @@ -179,12 +206,13 @@ for (let i = 0; i < 100; i++) { possibleBirthdayYear.push(2020 - i); } function AttributeEntryField(props: AttributeEntryFieldProps): VNode { + const label = translateAttributeLabel(props.spec.label); return ( <div style={{ marginTop: 16 }}> {props.spec.type === "date" && ( <DateInput grabFocus={props.isFirst} - label={props.spec.label} + label={label} years={possibleBirthdayYear} onConfirm={props.onConfirm} error={props.errorMessage} @@ -194,7 +222,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { {props.spec.type === "number" && ( <PhoneNumberInput grabFocus={props.isFirst} - label={props.spec.label} + label={label} onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} @@ -203,7 +231,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { {props.spec.type === "string" && ( <TextInput grabFocus={props.isFirst} - label={props.spec.label} + label={label} onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} @@ -211,18 +239,22 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { )} {props.spec.type === "string" && ( <div> - This field is case-sensitive. You must enter exactly the same value - during recovery. + <i18n.Translate> + This field is case-sensitive. You must enter exactly the same value + during recovery. + </i18n.Translate> </div> )} {props.spec.name === "full_name" && ( <div> - If possible, use "LASTNAME, Firstname(s)" without - abbreviations. + <i18n.Translate> + If possible, use "LASTNAME, Firstname(s)" without + abbreviations. + </i18n.Translate> </div> )} <div class="block"> - This stays private + <i18n.Translate>This stays private</i18n.Translate> <span class="icon is-right"> <i class="mdi mdi-eye-off" /> </span> @@ -230,6 +262,51 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { </div> ); } + +function translateAttributeLabel(label: string): string { + switch (label) { + case "Full name": + return i18n.str`Full name`; + case "Birthdate": + return i18n.str`Birthdate`; + case "Birthplace": + return i18n.str`Birthplace`; + case "Identity Number": + case "Numri i Identitetit": + return i18n.str`Identity number`; + case "National Register Number": + return i18n.str`National register number`; + case "Social security number": + return i18n.str`Social security number`; + case "Tax number": + return i18n.str`Tax number`; + case "Taxpayer identification number": + return i18n.str`Taxpayer identification number`; + case "Citizen Service Number": + return i18n.str`Citizen service number`; + case "Aadhar number": + return i18n.str`Aadhaar number`; + case "AHV number": + return i18n.str`AHV number`; + case "Birth Number": + return i18n.str`Birth number`; + case "CPR-nummer": + return i18n.str`CPR number`; + case "Code Insee": + return i18n.str`INSEE code`; + case "Codice fiscale": + return i18n.str`Italian tax code`; + case "My number": + // Translators: “My Number” is the official name of Japan's national identification-number system. + return i18n.str`My Number`; + case "Prime number": + return i18n.str`Prime number`; + case "Square number": + return i18n.str`Square number`; + default: + return label; + } +} const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/; function checkIfValid( @@ -239,33 +316,33 @@ function checkIfValid( const pattern = spec["validation-regex"]; if (pattern) { const re = new RegExp(pattern); - if (!re.test(value)) return "The value is invalid"; + if (!re.test(value)) return i18n.str`The value is invalid`; } const logic = spec["validation-logic"]; if (logic) { const func = (validators as any)[logic]; if (func && typeof func === "function" && !func(value)) - return "Please check the value"; + return i18n.str`Please check the value`; } const optional = spec.optional; if (!optional && !value) { - return "This value is required"; + return i18n.str`This value is required`; } if ("date" === spec.type) { if (!YEAR_REGEX.test(value)) { - return "The date doesn't follow the format"; + return i18n.str`The date doesn't follow the required format`; } try { const v = parse(value, "yyyy-MM-dd", new Date()); if (Number.isNaN(v.getTime())) { - return "Some numeric value is out of range for a date"; + return i18n.str`A number in the date is out of range`; } if ("birthdate" === spec.name && isAfter(v, new Date())) { - return "A birthdate cannot be in the future"; + return i18n.str`A birthdate cannot be in the future`; } } catch { - return "Could not parse the date"; + return i18n.str`Could not parse the date`; } } return undefined; diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { AuthMethod, ReducerStateBackup } from "@gnu-taler/anastasis-core"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -43,10 +44,18 @@ export function AuthenticationEditorScreen(): VNode { // const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined) const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? []; @@ -114,20 +123,25 @@ export function AuthenticationEditorScreen(): VNode { <ConfirmModal active onCancel={cancel} - description="No providers found" - label="Add a provider manually" + description={i18n.str`No providers found`} + label={i18n.str`Add a provider manually`} onConfirm={async () => { setManageProvider(selectedMethod); }} > <p> - We have found no Anastasis providers that support this - authorization method. You can add a provider manually. To add a - provider you must know the provider URL (e.g. - https://provider.com) + <i18n.Translate> + We found no Anastasis providers that support this authorization + method. You can add a provider manually. To add a provider you + must know the provider URL (e.g. https://provider.com) + </i18n.Translate> </p> <p> - <span>Learn more about Anastasis providers</span> + <span> + <i18n.Translate> + Learn more about Anastasis providers + </i18n.Translate> + </span> </p> </ConfirmModal> )} @@ -150,9 +164,19 @@ export function AuthenticationEditorScreen(): VNode { <div style={{ display: "flex" }}> <span class="icon ">{authMethods[props.method].icon}</span> {authAvailableSet.has(props.method) ? ( - <span>Add a {authMethods[props.method].label} challenge</span> + <span> + {/* Translators: %1$s is an authorization method, such as email or SMS. */} + <i18n.Translate> + Add a {authMethods[props.method].label} challenge + </i18n.Translate> + </span> ) : ( - <span>Add a {authMethods[props.method].label} provider</span> + <span> + {/* Translators: %1$s is an authorization method, such as email or SMS. */} + <i18n.Translate> + Add a {authMethods[props.method].label} provider + </i18n.Translate> + </span> )} </div> {!authAvailableSet.has(props.method) && ( @@ -169,7 +193,7 @@ export function AuthenticationEditorScreen(): VNode { } const errors = configuredAuthMethods.length < 2 - ? "There are not enough authorization methods." + ? i18n.str`There are not enough authorization methods.` : undefined; const handleNext = async () => { const st = reducer.currentReducerState as ReducerStateBackup; @@ -181,7 +205,7 @@ export function AuthenticationEditorScreen(): VNode { }; return ( <AnastasisClientFrame - title="Backup: Configure Authorization Methods" + title={i18n.str`Backup: Configure authorization methods`} hideNext={errors} onNext={handleNext} > @@ -196,52 +220,65 @@ export function AuthenticationEditorScreen(): VNode { <ConfirmModal active={tooFewAuths} onCancel={() => setTooFewAuths(false)} - description="Too few auth methods configured" - label="Proceed anyway" + description={i18n.str`Too few authorization methods configured`} + label={i18n.str`Proceed anyway`} onConfirm={() => reducer.transition("next", {})} > - You have selected fewer than 3 authorization methods. We recommend - that you add at least 3. + <i18n.Translate> + You have selected fewer than 3 authorization methods. We + recommend that you add at least 3. + </i18n.Translate> </ConfirmModal> ) : null} {authAvailableSet.size === 0 && ( <ConfirmModal active={!noProvidersAck} onCancel={() => setNoProvidersAck(true)} - description="No providers found" - label="Add a provider manually" + description={i18n.str`No providers found`} + label={i18n.str`Add a provider manually`} onConfirm={async () => { setManageProvider(""); }} > <p> - We have found no Anastasis providers for your chosen country / - currency. You can add providers manually. To add a provider you - must know the provider URL (e.g. https://provider.com) + <i18n.Translate> + We found no Anastasis providers for your chosen country and + currency. You can add providers manually. To add a provider + you must know the provider URL (e.g. https://provider.com) + </i18n.Translate> </p> <p> - <span>Learn more about Anastasis providers</span> + <span> + <i18n.Translate> + Learn more about Anastasis providers + </i18n.Translate> + </span> </p> </ConfirmModal> )} </div> <div class="column"> <p class="block"> - When recovering your secret data, you will be asked to verify your - identity via the methods you configure here. The list of - authorization methods are defined by the backup provider list. + <i18n.Translate> + When recovering your secret, you will be asked to verify your + identity using the methods you configure here. The available + authorization methods are determined by the backup providers. + </i18n.Translate> </p> <p class="block"> <button class="button is-info" onClick={() => setManageProvider("")} > - Manage backup providers + <i18n.Translate>Manage backup providers</i18n.Translate> </button> </p> {authAvailableSet.size > 0 && ( <p class="block"> - We couldn't find a provider for some of the authorization methods. + <i18n.Translate> + We couldn't find a provider for some of the authorization + methods. + </i18n.Translate> </p> )} </div> @@ -252,9 +289,20 @@ export function AuthenticationEditorScreen(): VNode { function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { return ( - <AnastasisClientFrame hideNav title={`Add ${props.method} authorization`}> - <p>This auth method is not implemented yet, please choose another one.</p> - <button onClick={() => props.cancel()}>Cancel</button> + <AnastasisClientFrame + hideNav + // Translators: %1$s is the identifier of an unsupported authorization method. + title={i18n.str`Add ${props.method} authorization`} + > + <p> + <i18n.Translate> + This authorization method is not implemented yet. Choose another + method. + </i18n.Translate> + </p> + <button onClick={() => props.cancel()}> + <i18n.Translate>Cancel</i18n.Translate> + </button> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -13,47 +13,63 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core"; -import { format } from "date-fns"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis.js"; +import { useTranslationContext } from "../../context/translation.js"; import { AnastasisClientFrame } from "./index.js"; export function BackupFinishedScreen(): VNode { const reducer = useAnastasisContext(); + const { lang } = useTranslationContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const details = reducer.currentReducerState.success_details; const providers = reducer.currentReducerState.authentication_providers ?? {}; return ( - <AnastasisClientFrame hideNav title="Backup success!"> - <p>Your backup is complete.</p> + <AnastasisClientFrame hideNav title={i18n.str`Backup complete`}> + <p> + <i18n.Translate>Your backup is complete.</i18n.Translate> + </p> {details && ( <div class="block"> - <p>The backup is stored by the following providers:</p> + <p> + <i18n.Translate> + The backup is stored by the following providers: + </i18n.Translate> + </p> {Object.keys(details).map((url, i) => { const sd = details[url]; const p = providers[url] as AuthenticationProviderStatusOk; + const description = + sd.policy_expiration.t_s !== "never" + ? // Translators: %1$s is a version number; %2$s is a localized date. + i18n.str`Version ${sd.policy_version} expires on ${new Intl.DateTimeFormat( + lang, + ).format(new Date(sd.policy_expiration.t_s * 1000))}` + : // Translators: %1$s is a version number. + i18n.str`Version ${sd.policy_version} has no expiration date`; return ( <div key={i} class="box"> <a href={url} target="_blank" rel="noreferrer"> {p.business_name} </a> - <p> - version {sd.policy_version} - {sd.policy_expiration.t_s !== "never" - ? ` expires at: ${format( - new Date(sd.policy_expiration.t_s * 1000), - "dd-MM-yyyy", - )}` - : " without expiration date"} - </p> + <p>{description}</p> </div> ); })} @@ -69,7 +85,7 @@ export function BackupFinishedScreen(): VNode { class="button is-primary is-right" onClick={() => reducer.reset()} > - Start again + <i18n.Translate>Start again</i18n.Translate> </button> </div> </p> diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -17,6 +17,7 @@ import { ChallengeFeedback, ChallengeFeedbackStatus, } from "@gnu-taler/anastasis-core"; +import { i18n } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { AsyncButton } from "../../components/AsyncButton.js"; import { useAnastasisContext } from "../../context/anastasis.js"; @@ -35,39 +36,65 @@ function OverviewFeedbackDisplay(props: { case ChallengeFeedbackStatus.Solved: return <div />; case ChallengeFeedbackStatus.IbanInstructions: - return <div class="block has-text-info">Payment required.</div>; + return ( + <div class="block has-text-info"> + <i18n.Translate>Payment required.</i18n.Translate> + </div> + ); case ChallengeFeedbackStatus.ServerFailure: - return <div class="block has-text-danger">Server error.</div>; + return ( + <div class="block has-text-danger"> + <i18n.Translate>Server error.</i18n.Translate> + </div> + ); case ChallengeFeedbackStatus.RateLimitExceeded: return ( <div class="block has-text-danger"> - There were too many failed attempts. + <i18n.Translate>There were too many failed attempts.</i18n.Translate> </div> ); case ChallengeFeedbackStatus.Unsupported: return ( <div class="block has-text-danger"> - This client doesn't support solving this type of challenge. Use - another version or contact the provider. + <i18n.Translate> + This app doesn't support this type of challenge. Use another + version or contact the provider. + </i18n.Translate> </div> ); case ChallengeFeedbackStatus.TruthUnknown: return ( <div class="block has-text-danger"> - Provider doesn't recognize the type of challenge. Use another - version or contact the provider. + <i18n.Translate> + The provider doesn't recognize the type of challenge. Use + another version or contact the provider. + </i18n.Translate> </div> ); case ChallengeFeedbackStatus.IncorrectAnswer: return ( - <div class="block has-text-danger">The answer was not correct.</div> + <div class="block has-text-danger"> + <i18n.Translate>The answer was not correct.</i18n.Translate> + </div> ); case ChallengeFeedbackStatus.CodeInFile: - return <div class="block has-text-info">Code available in file</div>; + return ( + <div class="block has-text-info"> + <i18n.Translate>Code available in a file</i18n.Translate> + </div> + ); case ChallengeFeedbackStatus.CodeSent: - return <div class="block has-text-info">Code sent</div>; + return ( + <div class="block has-text-info"> + <i18n.Translate>Code sent</i18n.Translate> + </div> + ); case ChallengeFeedbackStatus.TalerPayment: - return <div class="block has-text-info">Payment required</div>; + return ( + <div class="block has-text-info"> + <i18n.Translate>Payment required</i18n.Translate> + </div> + ); } } @@ -75,10 +102,18 @@ export function ChallengeOverviewScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const policies = @@ -126,23 +161,32 @@ export function ChallengeOverviewScreen(): VNode { policiesWithInfo.find((p) => p.isPolicySolved) !== undefined; const errors = !atLeastThereIsOnePolicySolved - ? "Solve one policy before proceeding" + ? i18n.str`Solve one policy before proceeding` : undefined; return ( - <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges"> + <AnastasisClientFrame + hideNext={errors} + title={i18n.str`Recovery: Solve challenges`} + > {!policiesWithInfo.length ? ( <p class="block"> - No policies found, try with another version of the secret + <i18n.Translate> + No recovery policies were found. Try another version of the secret. + </i18n.Translate> </p> ) : policiesWithInfo.length === 1 ? ( <p class="block"> - One policy found for this secret. You need to solve all the challenges - in order to recover your secret. + <i18n.Translate> + One policy was found for this secret. To recover your secret, solve + every challenge in the policy. + </i18n.Translate> </p> ) : ( <p class="block"> - We have found {policiesWithInfo.length} policies. You need to solve - all the challenges from one policy in order to recover your secret. + <i18n.Translate> + We found {policiesWithInfo.length} policies. To recover your secret, + solve every challenge in one policy. + </i18n.Translate> </p> )} {policiesWithInfo.map((policy, policy_index) => { @@ -157,7 +201,9 @@ export function ChallengeOverviewScreen(): VNode { style={{ display: "flex", justifyContent: "space-between" }} > <div style={{ display: "flex", alignItems: "center" }}> - <span>unknown challenge</span> + <span> + <i18n.Translate>Unknown challenge</i18n.Translate> + </span> </div> </div> ); @@ -185,7 +231,7 @@ export function ChallengeOverviewScreen(): VNode { } onClick={selectChallenge} > - Solve + <i18n.Translate>Solve challenge</i18n.Translate> </AsyncButton> </div> ); @@ -207,14 +253,16 @@ export function ChallengeOverviewScreen(): VNode { } onClick={selectChallenge} > - Pay + <i18n.Translate>Pay provider</i18n.Translate> </AsyncButton> </div> ); case ChallengeFeedbackStatus.Solved: return ( <div> - <div class="tag is-success is-large">Solved</div> + <div class="tag is-success is-large"> + <i18n.Translate>Solved</i18n.Translate> + </div> </div> ); default: @@ -227,7 +275,7 @@ export function ChallengeOverviewScreen(): VNode { } onClick={selectChallenge} > - Solve + <i18n.Translate>Solve challenge</i18n.Translate> </AsyncButton> </div> ); @@ -276,16 +324,27 @@ export function ChallengeOverviewScreen(): VNode { }} > <h3 class="subtitle"> - Policy #{policy_index + 1}: {policyName} + {/* Translators: %1$s is the policy number; %2$s is a list of challenge types. */} + <i18n.Translate> + Policy #{policy_index + 1}: {policyName} + </i18n.Translate> </h3> {policy.challenges.length === 0 && ( - <p>This policy doesn't have any challenges.</p> + <p> + <i18n.Translate>This policy has no challenges.</i18n.Translate> + </p> )} {policy.challenges.length === 1 && ( - <p>This policy has one challenge.</p> + <p> + <i18n.Translate>This policy has one challenge.</i18n.Translate> + </p> )} {policy.challenges.length > 1 && ( - <p>This policy has {policy.challenges.length} challenges.</p> + <p> + <i18n.Translate> + This policy has {policy.challenges.length} challenges. + </i18n.Translate> + </p> )} {tableBody} </div> diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis.js"; import { AnastasisClientFrame } from "./index.js"; @@ -20,17 +21,27 @@ import { AnastasisClientFrame } from "./index.js"; export function ChallengePayingScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const payments = [""]; //reducer.currentReducerState.payments ?? return ( - <AnastasisClientFrame hideNav title="Recovery: Challenge Paying"> + <AnastasisClientFrame hideNav title={i18n.str`Recovery: Challenge payment`}> <p> - Some of the providers require a payment to store the encrypted - authorization information. + <i18n.Translate> + Some of the providers require a payment to store the encrypted + authorization information. + </i18n.Translate> </p> <ul> {payments.map((x, i) => { @@ -38,7 +49,7 @@ export function ChallengePayingScreen(): VNode { })} </ul> <button onClick={() => reducer.transition("pay", {})}> - Check payment status now + <i18n.Translate>Check payment status now</i18n.Translate> </button> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { ComponentChildren, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; import { AsyncButton } from "../../components/AsyncButton.js"; @@ -37,8 +38,8 @@ export function ConfirmModal({ children, danger, disabled, - label = "Confirm", - cancelLabel = "Dismiss", + label = i18n.str`Confirm`, + cancelLabel = i18n.str`Dismiss`, }: ConfirmModelProps): VNode { useEffect(() => { if (!active || !onCancel) return; @@ -58,7 +59,7 @@ export function ConfirmModal({ <button type="button" class="modal-background" - aria-label="Close dialog" + aria-label={i18n.str`Close dialog`} onClick={onCancel} /> <div class="modal-card" style={{ maxWidth: 700 }}> @@ -68,7 +69,11 @@ export function ConfirmModal({ <b>{description}</b> </p> )} - <button class="delete " aria-label="close" onClick={onCancel} /> + <button + class="delete " + aria-label={i18n.str`Close dialog`} + onClick={onCancel} + /> </header> <section class="modal-card-body">{children}</section> <footer class="modal-card-foot"> @@ -89,7 +94,7 @@ export function ConfirmModal({ </div> <button class="modal-close is-large " - aria-label="close" + aria-label={i18n.str`Close dialog`} onClick={onCancel} /> </div> diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -13,13 +13,16 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis.js"; +import { useTranslationContext } from "../../context/translation.js"; import { AnastasisClientFrame, withProcessLabel } from "./index.js"; export function ContinentSelectionScreen(): VNode { const reducer = useAnastasisContext(); + const { lang } = useTranslationContext(); // FIXME: remove this when #7056 is fixed const countryFromReducer = @@ -56,8 +59,8 @@ export function ContinentSelectionScreen(): VNode { // const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || // reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting; - // FIXME: i18n - const errors = !theCountry ? "Select a country" : undefined; + const errors = !theCountry ? i18n.str`Select a country` : undefined; + const regionNames = new Intl.DisplayNames([lang], { type: "region" }); const handleBack = async () => { // We want to go to the start, even if we already selected @@ -70,7 +73,7 @@ export function ContinentSelectionScreen(): VNode { return ( <AnastasisClientFrame hideNext={errors} - title={withProcessLabel(reducer, "Where do you live?")} + title={withProcessLabel(reducer, i18n.str`Where do you live?`)} onNext={selectCountryAction} onBack={handleBack} > @@ -78,7 +81,7 @@ export function ContinentSelectionScreen(): VNode { <div class="column is-one-third"> <div class="field"> <label class="label" htmlFor="continent-selection"> - Continent + <i18n.Translate>Continent</i18n.Translate> </label> <div class="control is-expanded has-icons-left"> <div class="select is-fullwidth"> @@ -88,12 +91,11 @@ export function ContinentSelectionScreen(): VNode { value={theContinent} > <option key="none" disabled selected value=""> - {" "} - Choose a continent{" "} + <i18n.Translate> Choose a continent </i18n.Translate> </option> {continentList.map((prov) => ( <option key={prov.name} value={prov.name}> - {prov.name} + {translateContinent(prov.name, regionNames)} </option> ))} </select> @@ -106,7 +108,7 @@ export function ContinentSelectionScreen(): VNode { <div class="field"> <label class="label" htmlFor="country-selection"> - Country + <i18n.Translate>Country</i18n.Translate> </label> <div class="control is-expanded has-icons-left"> <div class="select is-fullwidth"> @@ -117,12 +119,11 @@ export function ContinentSelectionScreen(): VNode { value={theCountry?.code || ""} > <option key="none" disabled selected value=""> - {" "} - Choose a country{" "} + <i18n.Translate> Choose a country </i18n.Translate> </option> {countryList.map((prov) => ( <option key={prov.name} value={prov.code}> - {prov.name} + {translateCountry(prov.code, prov.name, regionNames)} </option> ))} </select> @@ -135,12 +136,16 @@ export function ContinentSelectionScreen(): VNode { </div> <div class="column is-two-third"> <p> - Your selection will help us ask for the right information to - uniquely identify you when you want to recover your secret. + <i18n.Translate> + Your selection will help us ask for the right information to + uniquely identify you when you want to recover your secret. + </i18n.Translate> </p> <p> - Choose the country that issued most of your long-term legal - documents or personal identifiers. + <i18n.Translate> + Choose the country that issued most of your long-term legal + documents or personal identifiers. + </i18n.Translate> </p> {/* <div style={{ @@ -162,3 +167,47 @@ export function ContinentSelectionScreen(): VNode { </AnastasisClientFrame> ); } + +function translateCountry( + code: string, + name: string, + regionNames: Intl.DisplayNames, +): string { + if (code !== "xx" && code !== "xy") { + return regionNames.of(code.toUpperCase()) ?? name; + } + switch (name) { + case "Demoland": + return i18n.str`Demoland`; + case "Testland": + return i18n.str`Testland`; + default: + return name; + } +} + +function translateContinent( + name: string, + regionNames: Intl.DisplayNames, +): string { + switch (name) { + case "Africa": + return i18n.str`Africa`; + case "Asia": + return i18n.str`Asia`; + case "Europe": + return i18n.str`Europe`; + case "North America": + return i18n.str`North America`; + case "South America": + return i18n.str`South America`; + case "India": + return regionNames.of("IN") ?? name; + case "Demoworld": + return i18n.str`Demoworld`; + case "Testcontinent": + return i18n.str`Test continent`; + default: + return name; + } +} diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis.js"; @@ -49,10 +50,18 @@ export function EditPoliciesScreen({ const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const selectableProviders: ProviderInfoByType = {}; @@ -102,13 +111,23 @@ export function EditPoliciesScreen({ return ( <AnastasisClientFrame hideNav - title={!policy ? "Backup: New Policy" : "Backup: Edit Policy"} + title={ + !policy + ? i18n.str`Backup: New recovery policy` + : i18n.str`Backup: Edit recovery policy` + } > <section class="section"> {!policy ? ( - <p>Creating a new policy #{policy_index}</p> + <p> + <i18n.Translate> + Creating a new policy #{policy_index} + </i18n.Translate> + </p> ) : ( - <p>Editing policy #{policy_index}</p> + <p> + <i18n.Translate>Editing policy #{policy_index}</i18n.Translate> + </p> )} {allAuthMethods.map((method, index) => { //take the url from the updated change or from the policy @@ -139,8 +158,7 @@ export function EditPoliciesScreen({ value={providerURL ?? ""} > <option key="none" value=""> - {" "} - << off >>{" "} + <i18n.Translate> << off >> </i18n.Translate> </option> {selectableProviders[type]?.map((prov) => ( <option key={prov.url} value={prov.url}> @@ -161,14 +179,14 @@ export function EditPoliciesScreen({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span class="buttons"> <button class="button" onClick={() => setChangedProvider([])}> - Reset + <i18n.Translate>Reset</i18n.Translate> </button> <button class="button is-info" onClick={sendChanges}> - Confirm + <i18n.Translate>Confirm</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis.js"; import { AnastasisClientFrame } from "./index.js"; @@ -20,18 +21,31 @@ import { AnastasisClientFrame } from "./index.js"; export function PoliciesPayingScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const payments = reducer.currentReducerState.policy_payment_requests ?? []; return ( - <AnastasisClientFrame hideNav title="Backup: Recovery Document Payments"> + <AnastasisClientFrame + hideNav + title={i18n.str`Backup: Recovery document payments`} + > <p> - Some of the providers require a payment to store the encrypted recovery - document. + <i18n.Translate> + Some of the providers require a payment to store the encrypted + recovery document. + </i18n.Translate> </p> <ul> {payments.map((x, i) => { @@ -43,7 +57,7 @@ export function PoliciesPayingScreen(): VNode { })} </ul> <button onClick={() => reducer.transition("pay", {})}> - Check payment status now + <i18n.Translate>Check payment status now</i18n.Translate> </button> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { bytesToString, decodeCrock } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -23,17 +24,27 @@ import { AnastasisClientFrame } from "./index.js"; export function RecoveryFinishedScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const secretName = reducer.currentReducerState.recovery_document?.secret_name; const encodedSecret = reducer.currentReducerState.core_secret; if (!encodedSecret) { return ( - <AnastasisClientFrame title="Recovery Problem" hideNav> - <p>Secret not found</p> + <AnastasisClientFrame title={i18n.str`Recovery problem`} hideNav> + <p> + <i18n.Translate>Secret not found</i18n.Translate> + </p> <div style={{ marginTop: "2em", @@ -42,7 +53,7 @@ export function RecoveryFinishedScreen(): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -89,11 +100,16 @@ function RecoveredSecret({ }, [mime, secret]); return ( - <AnastasisClientFrame title="Recovery Success" hideNav> - <h2 class="subtitle">Your secret was recovered</h2> + <AnastasisClientFrame title={i18n.str`Recovery complete`} hideNav> + <h2 class="subtitle"> + <i18n.Translate>Your secret was recovered</i18n.Translate> + </h2> {secretName && ( <p class="block"> - <b>Secret name:</b> {secretName} + <b> + <i18n.Translate>Secret name:</i18n.Translate> + </b>{" "} + {secretName} </p> )} <div class="block buttons" disabled={copied}> @@ -105,7 +121,7 @@ function RecoveredSecret({ setCopied(true); }} > - {!copied ? "Copy" : "Copied"} + {!copied ? i18n.str`Copy` : i18n.str`Copied`} </button> ) : undefined} @@ -117,7 +133,9 @@ function RecoveredSecret({ <div class="icon is-small "> <i class="mdi mdi-download" /> </div> - <span>Download content</span> + <span> + <i18n.Translate>Download content</i18n.Translate> + </span> </a> </div> @@ -136,7 +154,7 @@ function RecoveredSecret({ <p> <div class="buttons ml-4"> <button class="button is-primary is-right" onClick={onReset}> - Start again + <i18n.Translate>Start again</i18n.Translate> </button> </div> </p> diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -25,10 +26,18 @@ export function ReviewPoliciesScreen(): VNode { const [editingPolicy, setEditingPolicy] = useState<number | undefined>(); const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const configuredAuthMethods = @@ -53,23 +62,30 @@ export function ReviewPoliciesScreen(): VNode { ); } - const errors = policies.length < 1 ? "Need more policies" : undefined; + const errors = + policies.length < 1 + ? i18n.str`Add at least one recovery policy` + : undefined; return ( <AnastasisClientFrame hideNext={errors} - title="Backup: Review Recovery Policies" + title={i18n.str`Backup: Review recovery policies`} > {policies.length > 0 && ( <p class="block"> - Based on the authorization methods you configured, some policies have - been configured. In order to recover your secret you have to solve all - the challenges of at least one policy. + <i18n.Translate> + Recovery policies have been created from the authorization methods + you configured. To recover your secret, you must solve every + challenge in at least one policy. + </i18n.Translate> </p> )} {policies.length < 1 && ( <p class="block"> - No policies have been created. Go back and add more authorization - methods. + <i18n.Translate> + No policies have been created. Go back and add more authorization + methods. + </i18n.Translate> </p> )} <div class="block"> @@ -78,7 +94,7 @@ export function ReviewPoliciesScreen(): VNode { style={{ marginLeft: 10 }} onClick={() => setEditingPolicy(policies.length)} > - Add new policy + <i18n.Translate>Add new policy</i18n.Translate> </button> </div> {policies.map((p, policy_index) => { @@ -107,9 +123,15 @@ export function ReviewPoliciesScreen(): VNode { > <div> <h3 class="subtitle"> - Policy #{policy_index + 1}: {policyName} + <i18n.Translate> + Policy #{policy_index + 1}: {policyName} + </i18n.Translate> </h3> - {!methods.length && <p>No auth method found</p>} + {!methods.length && ( + <p> + <i18n.Translate>No authorization method found</i18n.Translate> + </p> + )} {methods.map((m, i) => { const p = providers[ m.provider @@ -124,10 +146,13 @@ export function ReviewPoliciesScreen(): VNode { {authMethods[m.type as KnownAuthMethods]?.icon} </span> <span> - {m.instructions} recovery provided by{" "} - <a href={m.provider} target="_blank" rel="noreferrer"> - {p.business_name} - </a> + {/* Translators: %1$s describes the recovery method; %2$s is the provider's business name. */} + <i18n.Translate> + {m.instructions} recovery provided by{" "} + <a href={m.provider} target="_blank" rel="noreferrer"> + {p.business_name} + </a> + </i18n.Translate> </span> </p> ); @@ -146,7 +171,7 @@ export function ReviewPoliciesScreen(): VNode { class="button is-info block" onClick={() => setEditingPolicy(policy_index)} > - Edit + <i18n.Translate>Edit policy</i18n.Translate> </button> <button class="button is-danger block" @@ -154,7 +179,7 @@ export function ReviewPoliciesScreen(): VNode { reducer.transition("delete_policy", { policy_index }) } > - Delete + <i18n.Translate>Delete policy</i18n.Translate> </button> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -19,6 +19,7 @@ import { encodeCrock, stringToBytes, } from "@gnu-taler/taler-util"; +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { @@ -48,10 +49,18 @@ export function SecretEditorScreen(): VNode { const [secretName, setSecretName] = useState(currentSecretName || ""); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const secretNext = async (): Promise<void> => { @@ -83,9 +92,9 @@ export function SecretEditorScreen(): VNode { }); }; const errors = !secretName - ? "Add a secret name" + ? i18n.str`Add a secret name` : !secretValue && !secretFile - ? "Add a secret value or choose a file to upload" + ? i18n.str`Enter a secret or choose a file to upload` : undefined; function goNextIfNoErrors(): void { if (!errors) secretNext(); @@ -93,20 +102,22 @@ export function SecretEditorScreen(): VNode { return ( <AnastasisClientFrame hideNext={errors} - title="Backup: Provide secret to backup" + title={i18n.str`Backup: Provide the secret to back up`} onNext={() => secretNext()} > <div class="block"> <TextInput - label="Secret name:" - tooltip="This allows you to uniquely identify a secret if you have made multiple back ups. The value entered here will NOT be protected by the authorization checks!" + label={i18n.str`Secret name:`} + tooltip={i18n.str`This name helps you distinguish this secret from other backups. It is not protected by the authorization checks.`} grabFocus onConfirm={goNextIfNoErrors} bind={[secretName, setSecretName]} /> <div> - Names should be unique, so that you can easily identify your secret - later. + <i18n.Translate> + Names should be unique, so that you can easily identify your secret + later. + </i18n.Translate> </div> </div> <div class="block"> @@ -114,22 +125,27 @@ export function SecretEditorScreen(): VNode { inputType="multiline" disabled={!!secretFile} onConfirm={goNextIfNoErrors} - label="Enter the secret as text:" + label={i18n.str`Enter the secret as text:`} bind={[secretValue, setSecretValue]} /> </div> <div class="block"> - Or upload a secret file - <FileInput label="Choose file" onChange={setSecretFile} /> + <p> + <i18n.Translate>Or upload a secret file</i18n.Translate> + </p> + <FileInput label={i18n.str`Choose file`} onChange={setSecretFile} /> {secretFile && ( <div> - Uploading secret file <b>{secretFile.name}</b>{" "} + {/* Translators: %1$s is the selected file name. */} + <i18n.Translate> + Uploading secret file <b>{secretFile.name}</b> + </i18n.Translate>{" "} <button type="button" class="button is-ghost" onClick={() => setSecretFile(undefined)} > - cancel + <i18n.Translate>Cancel upload</i18n.Translate> </button> </div> )} diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { AggregatedPolicyMetaInfo } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -32,23 +33,33 @@ export function SecretSelectionScreenFound({ }): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if ( !reducer.currentReducerState || reducer.currentReducerState.reducer_type !== "recovery" ) { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } return ( <AnastasisClientFrame - title="Recovery: Select secret" - hideNext="Please select version to recover" + title={i18n.str`Recovery: Select secret`} + hideNext={i18n.str`Select a version to recover`} > <div class="columns"> <div class="column"> - <p class="block">Found versions:</p> + <p class="block"> + <i18n.Translate>Available versions:</i18n.Translate> + </p> {policies.map((version, i) => ( <div key={i} class="box"> <div @@ -62,10 +73,17 @@ export function SecretSelectionScreenFound({ }} > <div style={{ display: "flex", alignItems: "center" }}> - <b>Name:</b> <span>{version.secret_name}</span> + <b> + <i18n.Translate>Name:</i18n.Translate> + </b> + <span>{version.secret_name}</span> </div> <div style={{ display: "flex", alignItems: "center" }}> - <b>Id:</b> + <b> + {/* Translators: Label for a secret's technical identifier. */} + <i18n.Translate>ID:</i18n.Translate> + </b> + <span class="icon has-tooltip-top" data-tooltip={version.policy_hash} @@ -81,7 +99,7 @@ export function SecretSelectionScreenFound({ class="button" onClick={async () => onNext(version)} > - Recover + <i18n.Translate>Recover</i18n.Translate> </AsyncButton> </div> </div> @@ -90,8 +108,10 @@ export function SecretSelectionScreenFound({ </div> <div class="column"> <p> - Secret found, you can select another version or continue to the - challenges solving + <i18n.Translate> + Secret found. Select a different version, or continue to solve the + recovery challenges. + </i18n.Translate> </p> <p class="block"> <button @@ -99,7 +119,7 @@ export function SecretSelectionScreenFound({ class="button is-ghost" onClick={onManageProvider} > - Manage recovery providers + <i18n.Translate>Manage recovery providers</i18n.Translate> </button> </p> </div> @@ -122,14 +142,22 @@ export function SecretSelectionScreen(): VNode { }, [reducer]); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if ( !reducer.currentReducerState || reducer.currentReducerState.reducer_type !== "recovery" ) { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } if (manageProvider) { @@ -154,10 +182,9 @@ export function SecretSelectionScreen(): VNode { onCancel={async () => setManageProvider(false)} notifications={[ { - message: "Secret not found", + message: i18n.str`Secret not found`, type: "ERROR", - description: - "With the information you provided we could not find your secret in any of the providers. You can try adding more providers if you think the data is correct.", + description: i18n.str`We could not find your secret at any provider using the information you entered. If the information is correct, try adding more providers.`, }, ]} /> @@ -194,13 +221,13 @@ export function SecretSelectionScreen(): VNode { // 0; // if (!reducer) { -// return <div>no reducer in context</div>; +// return <div>No reducer is available.</div>; // } // if ( // !reducer.currentReducerState || // reducer.currentReducerState.reducer_type !== "recovery" // ) { -// return <div>invalid state</div>; +// return <div>The application is in an invalid state.</div>; // } // async function doSelectVersion(p: string, n: number): Promise<void> { @@ -287,8 +314,10 @@ export function SecretSelectionScreen(): VNode { function SecretSelectionScreenWaiting(): VNode { return ( - <AnastasisClientFrame title="Recovery: Select secret"> - <div>loading secret versions</div> + <AnastasisClientFrame title={i18n.str`Recovery: Select secret`}> + <div> + <i18n.Translate>Loading secret versions...</i18n.Translate> + </div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -17,6 +17,7 @@ import { ChallengeFeedback, ChallengeFeedbackStatus, } from "@gnu-taler/anastasis-core"; +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { Notifications } from "../../components/Notifications.js"; import { useAnastasisContext } from "../../context/anastasis.js"; @@ -37,17 +38,19 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "INFO", - message: `Message from provider`, + message: i18n.str`Message from provider`, description: ( <span> - To pay you can{" "} - <a - href={feedback.taler_pay_uri} - target="_blank" - rel="noreferrer" - > - click here - </a> + <i18n.Translate> + Pay the provider using this payment link:{" "} + <a + href={feedback.taler_pay_uri} + target="_blank" + rel="noreferrer" + > + <i18n.Translate>Open payment page</i18n.Translate> + </a> + </i18n.Translate> </span> ), }, @@ -60,8 +63,8 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "INFO", - message: `Message from provider`, - description: `Need to send a wire transfer to "${feedback.target_business_name}"`, + message: i18n.str`Message from provider`, + description: i18n.str`Send a wire transfer to "${feedback.target_business_name}"`, }, ]} /> @@ -72,10 +75,10 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "ERROR", - message: `Server error: response code ${feedback.http_status}`, + message: i18n.str`Server error: response code ${feedback.http_status}`, description: !feedback.error_response ? undefined - : `More information: ${JSON.stringify( + : i18n.str`More information: ${JSON.stringify( feedback.error_response, )}`, }, @@ -88,7 +91,7 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "ERROR", - message: "There were too many failed attempts.", + message: i18n.str`There were too many failed attempts.`, }, ]} /> @@ -99,8 +102,8 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "ERROR", - message: `This client doesn't support solving this type of challenge`, - description: `Use another version or contact the provider. Type of challenge "${feedback.unsupported_method}"`, + message: i18n.str`This app doesn't support this type of challenge`, + description: i18n.str`Use another version or contact the provider. Type of challenge: "${feedback.unsupported_method}"`, }, ]} /> @@ -111,8 +114,8 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "ERROR", - message: `Provider doesn't recognize the type of challenge`, - description: "Contact the provider for further information", + message: i18n.str`The provider doesn't recognize the type of challenge`, + description: i18n.str`Contact the provider for further information`, }, ]} /> @@ -123,9 +126,9 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "INFO", - message: `Required TAN can be found in file "${feedback.filename}"`, + message: i18n.str`The required one-time code is available in the file "${feedback.filename}"`, description: feedback.display_hint - ? `HINT: ${feedback.display_hint}` + ? i18n.str`Provider hint: ${feedback.display_hint}` : undefined, }, ]} @@ -137,9 +140,9 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "INFO", - message: `Code sent to address "${feedback.address_hint}"`, + message: i18n.str`Code sent to address "${feedback.address_hint}"`, description: feedback.display_hint - ? `HINT: ${feedback.display_hint}` + ? i18n.str`Provider hint: ${feedback.display_hint}` : undefined, }, ]} @@ -151,7 +154,7 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "ERROR", - message: `The answer is wrong.`, + message: i18n.str`The answer is wrong.`, }, ]} /> @@ -162,7 +165,7 @@ export function SolveOverviewFeedbackDisplay(props: { notifications={[ { type: "SUCCESS", - message: `This challenge is solved`, + message: i18n.str`This challenge is solved`, }, ]} /> @@ -175,15 +178,21 @@ export function SolveScreen(): VNode { if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -191,17 +200,23 @@ export function SolveScreen(): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -210,7 +225,7 @@ export function SolveScreen(): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -218,10 +233,12 @@ export function SolveScreen(): VNode { } function SolveNotImplemented(): VNode { return ( - <AnastasisClientFrame hideNav title="Not implemented"> + <AnastasisClientFrame hideNav title={i18n.str`Not implemented`}> <p> - The challenge selected is not supported for this UI. Please update - this version or try using another policy. + <i18n.Translate> + The selected challenge is not supported by this app. Update the app + or try another recovery policy. + </i18n.Translate> </p> {reducer && ( <div @@ -232,7 +249,7 @@ export function SolveScreen(): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> )} diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { FileButton } from "../../components/FlieButton.js"; import { useAnastasisContext } from "../../context/anastasis.js"; @@ -21,10 +22,14 @@ import { AnastasisClientFrame } from "./index.js"; export function StartScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } return ( - <AnastasisClientFrame hideNav title="Home"> + <AnastasisClientFrame hideNav title={i18n.str`Home`}> <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> @@ -36,7 +41,9 @@ export function StartScreen(): VNode { <div class="icon"> <i class="mdi mdi-arrow-up" /> </div> - <span>Backup a secret</span> + <span> + <i18n.Translate>Backup a secret</i18n.Translate> + </span> </button> <button @@ -46,11 +53,13 @@ export function StartScreen(): VNode { <div class="icon"> <i class="mdi mdi-arrow-down" /> </div> - <span>Recover a secret</span> + <span> + <i18n.Translate>Recover a secret</i18n.Translate> + </span> </button> <FileButton - label="Restore a session" + label={i18n.str`Restore a session`} onChange={(content) => { if (content?.type === "application/json") { reducer.importState(content.content); diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis.js"; import { AnastasisClientFrame } from "./index.js"; @@ -20,17 +21,30 @@ import { AnastasisClientFrame } from "./index.js"; export function TruthsPayingScreen(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div>; + return ( + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> + ); } if (reducer.currentReducerState?.reducer_type !== "backup") { - return <div>invalid state</div>; + return ( + <div> + <i18n.Translate>The application is in an invalid state.</i18n.Translate> + </div> + ); } const payments = reducer.currentReducerState.payments ?? []; return ( - <AnastasisClientFrame hideNext={"FIXME"} title="Backup: Truths Paying"> + <AnastasisClientFrame + hideNext={i18n.str`Complete the payments before proceeding`} + title={i18n.str`Backup: Authorization-method payments`} + > <p> - Some of the providers require a payment to store the encrypted - authorization information. + <i18n.Translate> + Some of the providers require a payment to store the encrypted + authorization information. + </i18n.Translate> </p> <ul> {payments.map((x, i) => { @@ -38,7 +52,7 @@ export function TruthsPayingScreen(): VNode { })} </ul> <button onClick={() => reducer.transition("pay", {})}> - Check payment status now + <i18n.Translate>Check payment status now</i18n.Translate> </button> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -33,37 +34,41 @@ export function AuthMethodEmailSetup({ addAuthMethod({ authentication_method: { type: "email", - instructions: `Email to ${email}`, + instructions: i18n.str`Email to ${email}`, challenge: encodeCrock(stringToBytes(email)), }, }); const emailError = !EMAIL_PATTERN.test(email) - ? "Email address is not valid" + ? i18n.str`Enter a valid email address` : undefined; - const errors = !email ? "Add your email" : emailError; + const errors = !email ? i18n.str`Enter your email address` : emailError; function goNextIfNoErrors(): void { if (!errors) addEmailAuth(); } return ( - <AnastasisClientFrame hideNav title="Add email authorization"> + <AnastasisClientFrame hideNav title={i18n.str`Add email authorization`}> <p> - For email authorization, you need to provide an email address. When - recovering your secret, you will need to enter the code you receive by - email. + <i18n.Translate> + For email authorization, you need to provide an email address. When + recovering your secret, you will need to enter the code you receive by + email. + </i18n.Translate> </p> <div> <EmailInput - label="Email address" + label={i18n.str`Email address`} error={emailError} onConfirm={goNextIfNoErrors} - placeholder="email@domain.com" + placeholder={i18n.str`email@domain.com`} bind={[email, setEmail]} /> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your emails:</div> + <div class="block"> + <i18n.Translate>Your emails:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -77,7 +82,9 @@ export function AuthMethodEmailSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> @@ -95,7 +102,7 @@ export function AuthMethodEmailSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -103,7 +110,7 @@ export function AuthMethodEmailSetup({ disabled={errors !== undefined} onClick={addEmailAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx @@ -58,15 +58,21 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state, no recovery state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -74,17 +80,23 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state, no challenge id</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -93,7 +105,7 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -124,28 +136,27 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { const error = answer.length > 21 - ? i18n.str`The answer should not be greater than 21 characters.` + ? i18n.str`The answer must not exceed 21 characters.` : undefined; + // Translators: %1$s identifies the email recipient. + const deliveryMessage = i18n.str`An email has been sent to ${selectedChallenge.instructions}. The message contains an identification code and a recovery code that starts with A-. Wait for the message to arrive, then enter the recovery code below.`; + // Translators: %1$s is the beginning of the identification code. + const shortIdentificationMessage = i18n.str`The identification code in the email should start with ${selectedUuid.substring(0, 10)}.`; + // Translators: %1$s is the full identification code. + const fullIdentificationMessage = i18n.str`The identification code in the email is ${selectedUuid}.`; return ( - <AnastasisClientFrame hideNav title="Email challenge"> + <AnastasisClientFrame hideNav title={i18n.str`Email challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> - <p> - An email has been sent to "<b>{selectedChallenge.instructions}</b> - ". The message contains an identification code and a recovery code - that starts with " - <b>A-</b>". Wait for the message to arrive, then enter the recovery - code below. - </p> + <p>{deliveryMessage}</p> {!expanded ? ( <p> - The identification code in the email should start with " - {selectedUuid.substring(0, 10)}" + {shortIdentificationMessage} <button type="button" - aria-label="Show full identification code" + aria-label={i18n.str`Show full identification code`} class="icon has-tooltip-top" - data-tooltip="click to expand" + data-tooltip={i18n.str`Click to expand`} onClick={() => setExpanded((e) => !e)} > <i class="mdi mdi-information" /> @@ -153,12 +164,12 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { </p> ) : ( <p> - The identification code in the email is "{selectedUuid}" + {fullIdentificationMessage} <button type="button" - aria-label="Shorten identification code" + aria-label={i18n.str`Shorten identification code`} class="icon has-tooltip-top" - data-tooltip="click to show less code" + data-tooltip={i18n.str`Click to show less code`} onClick={() => setExpanded((e) => !e)} > <i class="mdi mdi-information" /> @@ -166,12 +177,12 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { </p> )} <TextInput - label="Answer" + label={i18n.str`Answer`} grabFocus onConfirm={onNext} bind={[answer, setAnswer]} error={error} - placeholder="A-12345-678-1234-5678" + placeholder={i18n.str`A-12345-678-1234-5678`} /> <div @@ -182,7 +193,7 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton @@ -190,7 +201,7 @@ export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { onClick={onNext} disabled={!!error} > - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx @@ -18,6 +18,7 @@ import { encodeCrock, stringToBytes, } from "@gnu-taler/taler-util"; +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { TextInput } from "../../../components/fields/TextInput.js"; @@ -35,7 +36,7 @@ export function AuthMethodIbanSetup({ addAuthMethod({ authentication_method: { type: "iban", - instructions: `Wire transfer from ${account} with holder ${name}`, + instructions: i18n.str`Wire transfer from ${account}, held by ${name}`, challenge: encodeCrock( stringToBytes( canonicalJson({ @@ -47,39 +48,46 @@ export function AuthMethodIbanSetup({ }, }); const errors = !name - ? "Add an account name" + ? i18n.str`Enter the account holder's name` : !account - ? "Add an account IBAN number" + ? i18n.str`Enter the account's IBAN` : undefined; function goNextIfNoErrors(): void { if (!errors) addIbanAuth(); } return ( - <AnastasisClientFrame hideNav title="Add bank transfer authorization"> + <AnastasisClientFrame + hideNav + title={i18n.str`Add bank transfer authorization`} + > <p> - For bank transfer authorization, you need to provide a bank account - (account holder name and IBAN). When recovering your secret, you will be - asked to pay the recovery fee via bank transfer from the account you - provided here. + <i18n.Translate> + For bank transfer authorization, you need to provide a bank account + (account holder name and IBAN). When recovering your secret, you will + be asked to pay the recovery fee via bank transfer from the account + you provided here. + </i18n.Translate> </p> <div> <TextInput - label="Bank account holder name" + label={i18n.str`Bank account holder name`} grabFocus - placeholder="John Smith" + placeholder={i18n.str`John Smith`} onConfirm={goNextIfNoErrors} bind={[name, setName]} /> <TextInput - label="IBAN" - placeholder="DE91100000000123456789" + label={i18n.str`IBAN`} + placeholder={i18n.str`DE91100000000123456789`} onConfirm={goNextIfNoErrors} bind={[account, setAccount]} /> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your bank accounts:</div> + <div class="block"> + <i18n.Translate>Your bank accounts:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -93,7 +101,9 @@ export function AuthMethodIbanSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> @@ -111,7 +121,7 @@ export function AuthMethodIbanSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -119,7 +129,7 @@ export function AuthMethodIbanSetup({ disabled={errors !== undefined} onClick={addIbanAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { AsyncButton } from "../../../components/AsyncButton.js"; import { useAnastasisContext } from "../../../context/anastasis.js"; @@ -27,15 +28,21 @@ export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -43,17 +50,23 @@ export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -62,7 +75,7 @@ export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -82,10 +95,17 @@ export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { } return ( - <AnastasisClientFrame hideNav title="IBAN Challenge"> + <AnastasisClientFrame hideNav title={i18n.str`IBAN challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> - <p>Send a wire transfer to the address,</p> - <button class="button">Check</button> + <p> + <i18n.Translate> + Send a bank transfer using the instructions above. + </i18n.Translate> + </p> + <button class="button"> + {/* Translators: Checks whether the user's bank transfer has arrived. */} + <i18n.Translate>Check transfer status</i18n.Translate> + </button> <div style={{ @@ -95,11 +115,11 @@ export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton class="button is-info" onClick={onNext}> - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx @@ -18,6 +18,7 @@ import { encodeCrock, stringToBytes, } from "@gnu-taler/taler-util"; +import { i18n } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { TextInput } from "../../../components/fields/TextInput.js"; @@ -46,38 +47,40 @@ export function AuthMethodPostSetup({ addAuthMethod({ authentication_method: { type: "post", - instructions: `Letter to address in postal code ${postcode}`, + instructions: i18n.str`Letter to the address in postal code ${postcode}`, challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), }, }); }; const errors = !fullName - ? "The full name is missing" + ? i18n.str`Enter the full name` : !street - ? "The street is missing" + ? i18n.str`Enter the street` : !city - ? "The city is missing" + ? i18n.str`Enter the city` : !postcode - ? "The postcode is missing" + ? i18n.str`Enter the postal code` : !country - ? "The country is missing" + ? i18n.str`Enter the country` : undefined; function goNextIfNoErrors(): void { if (!errors) addPostAuth(); } return ( - <AnastasisClientFrame hideNav title="Add postal authorization"> + <AnastasisClientFrame hideNav title={i18n.str`Add postal authorization`}> <p> - For postal letter authorization, you need to provide a postal address. - When recovering your secret, you will be asked to enter a code that you - will receive in a letter to that address. + <i18n.Translate> + For postal letter authorization, you need to provide a postal address. + When recovering your secret, you will be asked to enter a code that + you will receive in a letter to that address. + </i18n.Translate> </p> <div> <TextInput grabFocus - label="Full Name" + label={i18n.str`Full name`} bind={[fullName, setFullName]} onConfirm={goNextIfNoErrors} /> @@ -85,35 +88,37 @@ export function AuthMethodPostSetup({ <div> <TextInput onConfirm={goNextIfNoErrors} - label="Street" + label={i18n.str`Street`} bind={[street, setStreet]} /> </div> <div> <TextInput onConfirm={goNextIfNoErrors} - label="City" + label={i18n.str`City`} bind={[city, setCity]} /> </div> <div> <TextInput onConfirm={goNextIfNoErrors} - label="Postal Code" + label={i18n.str`Postal code`} bind={[postcode, setPostcode]} /> </div> <div> <TextInput onConfirm={goNextIfNoErrors} - label="Country" + label={i18n.str`Country`} bind={[country, setCountry]} /> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your postal code:</div> + <div class="block"> + <i18n.Translate>Your postal addresses:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -127,7 +132,9 @@ export function AuthMethodPostSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> @@ -144,7 +151,7 @@ export function AuthMethodPostSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -152,7 +159,7 @@ export function AuthMethodPostSetup({ disabled={errors !== undefined} onClick={addPostAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx @@ -52,15 +52,21 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -68,17 +74,23 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -87,7 +99,7 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -110,18 +122,20 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { const error = answer.length > 21 - ? i18n.str`The answer should not be greater than 21 characters.` + ? i18n.str`The answer must not exceed 21 characters.` : undefined; return ( - <AnastasisClientFrame hideNav title="Postal Challenge"> + <AnastasisClientFrame hideNav title={i18n.str`Postal challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> - <p>Wait for the answer</p> + <p> + <i18n.Translate>Enter the code from the letter below.</i18n.Translate> + </p> <TextInput onConfirm={onNext} - label="Answer" + label={i18n.str`Answer`} grabFocus - placeholder="A-12345-678-1234-5678" + placeholder={i18n.str`A-12345-678-1234-5678`} error={error} bind={[answer, setAnswer]} /> @@ -134,7 +148,7 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton @@ -142,7 +156,7 @@ export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { onClick={onNext} disabled={!!error} > - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -37,40 +38,47 @@ export function AuthMethodQuestionSetup({ }); const errors = !questionText - ? "Add your security question" + ? i18n.str`Enter your security question` : !answerText - ? "Add the answer to your question" + ? i18n.str`Enter the answer to your security question` : undefined; function goNextIfNoErrors(): void { if (!errors) addQuestionAuth(); } return ( - <AnastasisClientFrame hideNav title="Add Security Question"> + <AnastasisClientFrame + hideNav + title={i18n.str`Add security question authorization`} + > <div> <p> - For security question authorization, you need to provide a question - and its answer. When recovering your secret, you will be shown the - question and you will need to type the answer exactly as you typed it - here. + <i18n.Translate> + For security question authorization, you need to provide a question + and its answer. When recovering your secret, you will be shown the + question and you will need to type the answer exactly as you typed + it here. + </i18n.Translate> </p> <p class="notification is-warning"> - Note that the answer is case-sensitive and must be entered in exactly - the same way (punctuation, spaces) during recovery. + <i18n.Translate> + Note that the answer is case-sensitive and must be entered in + exactly the same way (punctuation, spaces) during recovery. + </i18n.Translate> </p> <div> <TextInput - label="Security question" + label={i18n.str`Security question`} grabFocus onConfirm={goNextIfNoErrors} - placeholder="Your question" + placeholder={i18n.str`Your question`} bind={[questionText, setQuestionText]} /> </div> <div> <TextInput - label="Answer" + label={i18n.str`Answer`} onConfirm={goNextIfNoErrors} - placeholder="Your answer" + placeholder={i18n.str`Your answer`} bind={[answerText, setAnswerText]} /> </div> @@ -83,7 +91,7 @@ export function AuthMethodQuestionSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -91,14 +99,16 @@ export function AuthMethodQuestionSetup({ disabled={errors !== undefined} onClick={addQuestionAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your security questions:</div> + <div class="block"> + <i18n.Translate>Your security questions:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -112,7 +122,9 @@ export function AuthMethodQuestionSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { ChallengeInfo } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -30,15 +31,21 @@ export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -46,17 +53,23 @@ export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -65,7 +78,7 @@ export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -93,15 +106,19 @@ export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { } return ( - <AnastasisClientFrame hideNav title="Question challenge"> + <AnastasisClientFrame hideNav title={i18n.str`Question challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> <p> - In this challenge you need to answer the following security question: + <i18n.Translate> + In this challenge you need to answer the following security question: + </i18n.Translate> </p> <pre>{selectedChallenge.instructions}</pre> - <p>Type the answer below</p> + <p> + <i18n.Translate>Enter the answer below</i18n.Translate> + </p> <TextInput - label="Answer" + label={i18n.str`Answer`} onConfirm={onNext} grabFocus bind={[answer, setAnswer]} @@ -115,11 +132,11 @@ export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton class="button is-info" onClick={onNext}> - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; @@ -36,7 +37,7 @@ export function AuthMethodSmsSetup({ addAuthMethod({ authentication_method: { type: "sms", - instructions: `SMS to ${mobileNumber}`, + instructions: i18n.str`SMS to ${mobileNumber}`, challenge: encodeCrock(stringToBytes(mobileNumber)), }, }); @@ -46,39 +47,45 @@ export function AuthMethodSmsSetup({ inputRef.current?.focus(); }, []); const errors = !mobileNumber - ? "Add a mobile number" + ? i18n.str`Enter a mobile number` : !mobileNumber.startsWith("+") - ? "Mobile number should start with '+'" + ? i18n.str`The mobile number must start with '+'` : !isJustNumbers(mobileNumber) - ? "Mobile number can only contain digits" + ? i18n.str`The mobile number may only contain digits` : undefined; function goNextIfNoErrors(): void { if (!errors) addSmsAuth(); } return ( - <AnastasisClientFrame hideNav title="Add SMS authorization"> + <AnastasisClientFrame hideNav title={i18n.str`Add SMS authorization`}> <div> <p> - For SMS authorization, you need to provide a mobile number. When - recovering your secret, you will be asked to enter the code you - receive via SMS. + <i18n.Translate> + For SMS authorization, you need to provide a mobile number. When + recovering your secret, you will be asked to enter the code you + receive via SMS. + </i18n.Translate> </p> <div class="container"> <PhoneNumberInput - label="Mobile number" - placeholder="Your mobile number" + label={i18n.str`Mobile number`} + placeholder={i18n.str`Your mobile number`} onConfirm={goNextIfNoErrors} error={errors} grabFocus bind={[mobileNumber, setMobileNumber]} /> <div> - Enter mobile number including +CC international dialing prefix. + <i18n.Translate> + Include the international dialing prefix (for example, +41). + </i18n.Translate> </div> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your mobile numbers:</div> + <div class="block"> + <i18n.Translate>Your mobile numbers:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -92,7 +99,9 @@ export function AuthMethodSmsSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> @@ -109,7 +118,7 @@ export function AuthMethodSmsSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -117,7 +126,7 @@ export function AuthMethodSmsSetup({ disabled={errors !== undefined} onClick={addSmsAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx @@ -54,15 +54,21 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -70,17 +76,23 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -89,7 +101,7 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -120,28 +132,27 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { const error = answer.length > 21 - ? i18n.str`The answer should not be greater than 21 characters.` + ? i18n.str`The answer must not exceed 21 characters.` : undefined; + // Translators: %1$s identifies the SMS recipient. + const deliveryMessage = i18n.str`An SMS has been sent to ${selectedChallenge.instructions}. The message contains an identification code and a recovery code that starts with A-. Wait for the message to arrive, then enter the recovery code below.`; + // Translators: %1$s is the beginning of the identification code. + const shortIdentificationMessage = i18n.str`The identification code in the SMS should start with ${selectedUuid.substring(0, 10)}.`; + // Translators: %1$s is the full identification code. + const fullIdentificationMessage = i18n.str`The identification code in the SMS is ${selectedUuid}.`; return ( - <AnastasisClientFrame hideNav title="SMS Challenge"> + <AnastasisClientFrame hideNav title={i18n.str`SMS challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> - <p> - An SMS has been sent to "<b>{selectedChallenge.instructions}</b> - ". The message contains an identification code and a recovery code - that starts with " - <b>A-</b>". Wait for the message to arrive, then enter the recovery - code below. - </p> + <p>{deliveryMessage}</p> {!expanded ? ( <p> - The identification code in the SMS should start with " - {selectedUuid.substring(0, 10)}" + {shortIdentificationMessage} <button type="button" - aria-label="Show full identification code" + aria-label={i18n.str`Show full identification code`} class="icon has-tooltip-top" - data-tooltip="click to expand" + data-tooltip={i18n.str`Click to expand`} onClick={() => setExpanded((e) => !e)} > <i class="mdi mdi-information" /> @@ -149,12 +160,12 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { </p> ) : ( <p> - The identification code in the SMS is "{selectedUuid}" + {fullIdentificationMessage} <button type="button" - aria-label="Shorten identification code" + aria-label={i18n.str`Shorten identification code`} class="icon has-tooltip-top" - data-tooltip="click to show less code" + data-tooltip={i18n.str`Click to show less code`} onClick={() => setExpanded((e) => !e)} > <i class="mdi mdi-information" /> @@ -162,12 +173,12 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { </p> )} <TextInput - label="Answer" + label={i18n.str`Answer`} grabFocus onConfirm={onNext} bind={[answer, setAnswer]} error={error} - placeholder="A-12345-678-1234-5678" + placeholder={i18n.str`A-12345-678-1234-5678`} /> <div @@ -178,7 +189,7 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton @@ -186,7 +197,7 @@ export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { onClick={onNext} disabled={!!error} > - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { encodeCrock } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useMemo, useState } from "preact/hooks"; @@ -47,7 +48,7 @@ export function AuthMethodTotpSetup({ addAuthMethod({ authentication_method: { type: "totp", - instructions: `Enter ${ANASTASIS_TOTP_DIGITS} digits code for "${name}"`, + instructions: i18n.str`Enter the ${ANASTASIS_TOTP_DIGITS}-digit code for "${name}"`, challenge: encodeCrock(secretKey), }, }); @@ -55,43 +56,55 @@ export function AuthMethodTotpSetup({ const testCodeMatches = computeTOTPandCheck(secretKey, 8, parseInt(test, 10)); const errors = !name - ? "The TOTP name is missing" + ? i18n.str`Enter a name for the TOTP secret` : !testCodeMatches - ? "The test code doesn't match" + ? i18n.str`The test code doesn't match` : undefined; function goNextIfNoErrors(): void { if (!errors) addTotpAuth(); } return ( - <AnastasisClientFrame hideNav title="Add TOTP authorization"> + <AnastasisClientFrame hideNav title={i18n.str`Add TOTP authorization`}> <p> - For Time-based One-Time Password (TOTP) authorization, you need to set a - name for the TOTP secret. Then, you must scan the generated QR code with - your TOTP App to import the TOTP secret into your TOTP App. + <i18n.Translate> + For Time-based One-Time Password (TOTP) authorization, enter a name + for the TOTP secret. Then scan the generated QR code with your + authenticator app. + </i18n.Translate> </p> <div class="block"> - <TextInput label="TOTP Name" grabFocus bind={[name, setName]} /> + <TextInput + label={i18n.str`TOTP name`} + grabFocus + bind={[name, setName]} + /> </div> <div style={{ height: 300 }}> <QR text={totpURL} /> </div> <p> - Confirm that your TOTP App works by entering the current 8-digit TOTP - code here: + <i18n.Translate> + Confirm that your authenticator app works by entering the current + 8-digit TOTP code here: + </i18n.Translate> </p> <TextInput - label="Test code" + label={i18n.str`Test code`} onConfirm={goNextIfNoErrors} bind={[test, setTest]} /> <div> - We note that Google's implementation of TOTP is incomplete and will - not work. We recommend using FreeOTP+. + <i18n.Translate> + Google Authenticator's TOTP implementation is not compatible. We + recommend using FreeOTP+. + </i18n.Translate> </div> {configured.length > 0 && ( <section class="section"> - <div class="block">Your TOTP numbers:</div> + <div class="block"> + <i18n.Translate>Your TOTP configurations:</i18n.Translate> + </div> <div class="block"> {configured.map((c, i) => { return ( @@ -105,7 +118,9 @@ export function AuthMethodTotpSetup({ </p> <div> <button class="button is-danger" onClick={c.remove}> - Delete + <i18n.Translate> + Delete authorization method + </i18n.Translate> </button> </div> </div> @@ -123,7 +138,7 @@ export function AuthMethodTotpSetup({ }} > <button class="button" onClick={cancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> <span data-tooltip={errors}> <button @@ -131,7 +146,7 @@ export function AuthMethodTotpSetup({ disabled={errors !== undefined} onClick={addTotpAuth} > - Add + <i18n.Translate>Add authorization method</i18n.Translate> </button> </span> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { ChallengeInfo } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -30,15 +31,21 @@ export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { const reducer = useAnastasisContext(); if (!reducer) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>no reducer in context</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate>No reducer is available.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (reducer.currentReducerState?.reducer_type !== "recovery") { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> </AnastasisClientFrame> ); } @@ -46,17 +53,23 @@ export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { if (!reducer.currentReducerState.recovery_information) { return ( <AnastasisClientFrame - hideNext="Recovery document not found" - title="Recovery problem" + hideNext={i18n.str`Recovery document not found`} + title={i18n.str`Recovery problem`} > - <div>no recovery information found</div> + <div> + <i18n.Translate>No recovery information was found.</i18n.Translate> + </div> </AnastasisClientFrame> ); } if (!reducer.currentReducerState.selected_challenge_uuid) { return ( - <AnastasisClientFrame hideNav title="Recovery problem"> - <div>invalid state</div> + <AnastasisClientFrame hideNav title={i18n.str`Recovery problem`}> + <div> + <i18n.Translate> + The application is in an invalid state. + </i18n.Translate> + </div> <div style={{ marginTop: "2em", @@ -65,7 +78,7 @@ export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={() => reducer.back()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> </div> </AnastasisClientFrame> @@ -94,11 +107,15 @@ export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { } return ( - <AnastasisClientFrame hideNav title="TOTP Challenge"> + <AnastasisClientFrame hideNav title={i18n.str`TOTP challenge`}> <SolveOverviewFeedbackDisplay feedback={feedback} /> - <p>Enter the current TOTP code from your authenticator app.</p> + <p> + <i18n.Translate> + Enter the current TOTP code from your authenticator app. + </i18n.Translate> + </p> <TextInput - label="Answer" + label={i18n.str`Answer`} onConfirm={onNext} grabFocus bind={[answerCode, setAnswerCode]} @@ -112,11 +129,11 @@ export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode { }} > <button class="button" onClick={onCancel}> - Cancel + <i18n.Translate>Cancel</i18n.Translate> </button> {!shouldHideConfirm(feedback) && ( <AsyncButton class="button is-info" onClick={onNext}> - Confirm + <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> )} </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { AuthMethod } from "@gnu-taler/anastasis-core"; import { h, VNode } from "preact"; import postalIcon from "../../../assets/icons/auth_method/postal.svg"; @@ -71,38 +72,56 @@ type KnowMethodConfig = { export const authMethods: KnowMethodConfig = { question: { - icon: <img src={questionIcon} alt="Security question" />, - label: "Question", + get icon() { + return <img src={questionIcon} alt={i18n.str`Security question`} />; + }, + get label() { + return i18n.str`Security question`; + }, setup: QuestionSetup, solve: QuestionSolve, }, sms: { - icon: <img src={smsIcon} alt="SMS" />, - label: "SMS", + get icon() { + return <img src={smsIcon} alt={i18n.str`SMS`} />; + }, + get label() { + return i18n.str`SMS`; + }, setup: SmsSetup, solve: SmsSolve, }, email: { icon: <i class="mdi mdi-email" />, - label: "Email", + get label() { + return i18n.str`Email`; + }, setup: EmailSetup, solve: EmailSolve, }, iban: { icon: <i class="mdi mdi-bank" />, - label: "IBAN", + get label() { + return i18n.str`IBAN`; + }, setup: IbanSetup, solve: IbanSolve, }, post: { - icon: <img src={postalIcon} alt="Physical mail" />, - label: "Physical mail", + get icon() { + return <img src={postalIcon} alt={i18n.str`Physical mail`} />; + }, + get label() { + return i18n.str`Physical mail`; + }, setup: PostalSetup, solve: PostalSolve, }, totp: { icon: <i class="mdi mdi-devices" />, - label: "TOTP", + get label() { + return i18n.str`TOTP`; + }, setup: TotpSetup, solve: TotpSolve, }, diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx @@ -13,6 +13,7 @@ You should have received a copy of the GNU Affero General Public License along with GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { i18n } from "@gnu-taler/taler-util"; import { BackupStates, RecoveryStates } from "@gnu-taler/anastasis-core"; import { ComponentChildren, @@ -57,9 +58,11 @@ export function withProcessLabel( text: string, ): string { if (isBackup(reducer)) { - return `Backup: ${text}`; + // Translators: %1$s is the title of the current backup step. + return i18n.str`Backup: ${text}`; } - return `Recovery: ${text}`; + // Translators: %1$s is the title of the current recovery step. + return i18n.str`Recovery: ${text}`; } interface AnastasisClientFrameProps { @@ -96,10 +99,12 @@ function ErrorBoundary(props: { resetError(); }} > - Reset + <i18n.Translate>Reset</i18n.Translate> </button> <p> - Error: <pre>{error.stack}</pre> + <i18n.Translate> + Error: <pre>{error.stack}</pre> + </i18n.Translate> </p> </div> ); @@ -191,7 +196,7 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { }} > <button class="button" onClick={() => doBack()}> - Back + <i18n.Translate>Back</i18n.Translate> </button> <AsyncButton class="button is-info" @@ -199,7 +204,7 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { onClick={() => doNext()} disabled={props.hideNext !== undefined} > - Next + <i18n.Translate>Next</i18n.Translate> </AsyncButton> </div> ) : null} @@ -214,7 +219,7 @@ const AnastasisClient: FunctionalComponent = () => { return ( <AnastasisProvider value={reducer}> <ErrorBoundary reducer={reducer}> - <Menu title="Anastasis" /> + <Menu title={i18n.str`Anastasis`} /> <AnastasisClientImpl /> </ErrorBoundary> </AnastasisProvider> @@ -224,7 +229,11 @@ const AnastasisClient: FunctionalComponent = () => { function AnastasisClientImpl(): VNode { const reducer = useAnastasisContext(); if (!reducer) { - return <p>Fatal: Reducer must be in context.</p>; + return ( + <p> + <i18n.Translate>Fatal: Reducer must be in context.</i18n.Translate> + </p> + ); } const state = reducer.currentReducerState; if (!state) { @@ -328,11 +337,13 @@ function AnastasisClientImpl(): VNode { } console.log("unknown state", reducer.currentReducerState); return ( - <AnastasisClientFrame hideNav title="Bug"> - <p>Bug: Unknown state.</p> + <AnastasisClientFrame hideNav title={i18n.str`Bug`}> + <p> + <i18n.Translate>Bug: Unknown state.</i18n.Translate> + </p> <div class="buttons is-right"> <button class="button" onClick={() => reducer.reset()}> - Reset + <i18n.Translate>Reset</i18n.Translate> </button> </div> </AnastasisClientFrame> diff --git a/packages/challenger-webui/package.json b/packages/challenger-webui/package.json @@ -31,7 +31,8 @@ "typescript": "7.0.2" }, "pogen": { - "domain": "challenger-ui" + "domain": "challenger-ui", + "requiredLanguages": ["de", "de-CH", "fr", "it"] }, "dependencies": { "@gnu-taler/taler-util": "workspace:*", diff --git a/packages/libeufin-bank-webui/package.json b/packages/libeufin-bank-webui/package.json @@ -38,6 +38,7 @@ "typescript": "7.0.2" }, "pogen": { - "domain": "bank" + "domain": "bank", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx @@ -610,7 +610,16 @@ function PrivatePageRouting({ case "conversionRateClassDetails": { const id = Number.parseInt(location.values.classId, 10); if (Number.isNaN(id)) { - return <div>class id is not a number "{location.values.classId}"</div>; + // Translators: %1$s is the invalid value from the conversion-rate + // class identifier in the page URL. + return ( + <div> + <i18n.Translate> + Conversion rate class ID “{location.values.classId}” is not a + number. + </i18n.Translate> + </div> + ); } return ( <ConversionRateClassDetails diff --git a/packages/libeufin-bank-webui/src/app.tsx b/packages/libeufin-bank-webui/src/app.tsx @@ -32,6 +32,7 @@ import { TranslationProvider, urlPattern, useCurrentLocation, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -74,6 +75,7 @@ export function App() { } function ConfiguredApp() { + const { i18n } = useTranslationContext(); const [settings, setSettings] = useState<UiSettings>(); const [developerOverrides, setDeveloperOverrides] = useState<DeveloperOverrides>(() => @@ -84,6 +86,9 @@ function ConfiguredApp() { useEffect(() => { fetchSettings(setSettings); }, []); + useEffect(() => { + document.title = i18n.str`Bank`; + }, [i18n]); if (!settings) return <Loading />; const effectiveSettings: UiSettings = { diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx b/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx @@ -44,8 +44,8 @@ export function FailedView({ error }: State.Failed) { return ( <Attention type="danger" title={i18n.str`Cashout is disabled`}> <i18n.Translate> - Cashout should be enable by configuration and the conversion rate - should be initialized with fee, ratio and rounding mode. + Cashout should be enabled in the configuration, the conversion rate + should be initialized with fee(s), rates and a rounding mode. </i18n.Translate> </Attention> ); diff --git a/packages/libeufin-bank-webui/src/components/Transactions/views.tsx b/packages/libeufin-bank-webui/src/components/Transactions/views.tsx @@ -52,6 +52,8 @@ export function ReadyView({ onGoPrevious, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); + // Translators: Bank-transfer subject or payment reference. + const subjectLabel = i18n.str`Subject`; const { config } = useBankCoreApiContext(); const groups = transactions.reduce<Record<string, Transaction[]>>( (result, transaction) => { @@ -191,7 +193,7 @@ export function ReadyView({ class="px-4 py-3 text-left text-sm font-semibold" scope="col" > - <i18n.Translate>Subject</i18n.Translate> + {subjectLabel} </th> <th class="px-4 py-3 text-right text-sm font-semibold" diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -157,6 +157,12 @@ function Form({ onClassDeleted: () => void; }) { const { i18n } = useTranslationContext(); + // Translators: Incoming conversion from the regional currency into the + // bank's fiat currency. + const cashinLabel = i18n.str`Cashin`; + // Translators: Outgoing conversion from the bank's fiat currency into the + // regional currency. + const cashoutLabel = i18n.str`Cashout`; const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { lib } = useBankCoreApiContext(); @@ -207,9 +213,9 @@ function Form({ case HttpStatusCode.Forbidden: return i18n.str`Forbidden`; case HttpStatusCode.NotFound: - return i18n.str`NotFound`; + return i18n.str`Conversion rate class was not found.`; case HttpStatusCode.NotImplemented: - return i18n.str`NotImplemented`; + return i18n.str`Deleting conversion rate classes is not supported by this bank.`; default: assertUnreachable(fail); } @@ -500,7 +506,7 @@ function Form({ <dl class="divide-y divide-gray-100 rounded-lg border border-gray-200 px-4"> <div class="py-4 sm:grid sm:grid-cols-[8rem_1fr] sm:gap-4"> <dt class="text-sm font-medium text-gray-600"> - <i18n.Translate>Cashin</i18n.Translate> + {cashinLabel} </dt> <dd class="mt-1 text-sm text-onBackground sm:mt-0"> <DescribeConversion @@ -515,7 +521,7 @@ function Form({ </div> <div class="py-4 sm:grid sm:grid-cols-[8rem_1fr] sm:gap-4"> <dt class="text-sm font-medium text-gray-600"> - <i18n.Translate>Cashout</i18n.Translate> + {cashoutLabel} </dt> <dd class="mt-1 text-sm text-onBackground sm:mt-0"> <DescribeConversion @@ -541,8 +547,8 @@ function Form({ {both_low || both_high ? ( <Attention title={i18n.str`Bad ratios`} type="warning"> <i18n.Translate> - One of the ratios should be higher or equal than 1 and the - other should be lower or equal than 1. + One ratio must be greater than or equal to 1, and the + other must be less than or equal to 1. </i18n.Translate> </Attention> ) : undefined} @@ -760,6 +766,8 @@ function TestConversionClass({ info: TalerBankConversionApi.TalerConversionInfoConfig; }): VNode { const { i18n } = useTranslationContext(); + // Translators: Label for the amount after currency conversion. + const convertedLabel = i18n.str`Converted`; const { showError } = useNotificationContext(); const { estimateByDebit: calculateCashoutFromDebit } = @@ -818,9 +826,9 @@ function TestConversionClass({ case HttpStatusCode.NotImplemented: return i18n.str`Conversion is not implemented.`; case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; + return i18n.str`At least a debit or credit amount must be provided.`; case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malformed`; + return i18n.str`The amount has an invalid format.`; case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: return i18n.str`The currency is not supported`; default: @@ -909,9 +917,7 @@ function TestConversionClass({ {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( <div class="flex items-center justify-between afu "> <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> + <span>{convertedLabel}</span> </dt> <dd class="text-sm text-onBackground"> <RenderAmount @@ -955,9 +961,7 @@ function TestConversionClass({ {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( <div class="flex items-center justify-between afu"> <dt class="flex items-center text-sm text-gray-600"> - <span> - <i18n.Translate>Converted</i18n.Translate> - </span> + <span>{convertedLabel}</span> </dt> <dd class="text-sm text-onBackground"> <RenderAmount @@ -1023,6 +1027,11 @@ function DeleteConversionClass({ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { const { i18n } = useTranslationContext(); + // Translators: Table column containing the assigned conversion-rate class. + const classLabel = i18n.str`Class`; + // Translators: Table column containing buttons for assigning or removing an + // account. + const actionLabel = i18n.str`Action`; const { lib: { bank }, @@ -1198,7 +1207,9 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" - >{i18n.str`Class`}</th> + > + {classLabel} + </th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" @@ -1210,7 +1221,9 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-onBackground" - >{i18n.str`Action`}</th> + > + {actionLabel} + </th> </tr> </thead> <tbody class="divide-y divide-gray-200"> diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx @@ -611,6 +611,7 @@ export function NeedConfirmationView({ </div> <div class="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-4 py-3"> <dt class="font-medium text-gray-600"> + {/* Translators: GNU Taler Exchange service, not a stock or currency exchange. */} <i18n.Translate>Exchange</i18n.Translate> </dt> <dd class="text-right font-medium break-words">{exchangeName}</dd> diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx @@ -342,6 +342,8 @@ function SolveMFAChallenges({ onCancel, }: Props): VNode { const { i18n } = useTranslationContext(); + // Translators: Button that finishes multi-factor authentication. + const completeLabel = i18n.str`Complete`; const mfa = useBankChallengeHandlerContext(); const [solved, setSolved] = useState<string[]>([]); @@ -623,7 +625,7 @@ function SolveMFAChallenges({ disabled={!hasSolvedEnough} onClick={() => onCompleted.run(solved)} > - <i18n.Translate>Complete</i18n.Translate> + {completeLabel} </AsyncButton> </div> </div> diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalProgress.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalProgress.tsx @@ -22,10 +22,13 @@ export function WithdrawalProgress({ const { i18n } = useTranslationContext(); const amount = i18n.str`Choose amount`; const wallet = i18n.str`Open wallet`; + // Translators: Final wallet-withdrawal progress step, after choosing the + // amount and opening the wallet. + const review = i18n.str`Review`; const steps = mode === "amount-first" - ? [amount, wallet, i18n.str`Review`] - : [wallet, amount, i18n.str`Review`]; + ? [amount, wallet, review] + : [wallet, amount, review]; return ( <nav aria-label={i18n.str`Withdrawal progress`}> diff --git a/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx b/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx @@ -82,7 +82,7 @@ export function UpdateAccountPassword({ repeat: !repeat ? i18n.str`Required` : password !== repeat - ? i18n.str`Repeated password doesn't match` + ? i18n.str`The passwords do not match.` : undefined, }); const passwordChange = !password @@ -125,7 +125,7 @@ export function UpdateAccountPassword({ case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`; case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: - return i18n.str`Your current password doesn't match, can't change to a new password.`; + return i18n.str`The current password is incorrect, so the password cannot be changed.`; case HttpStatusCode.Accepted: mfa.onNewChallenge( i18n.str`Password update`, diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx @@ -712,7 +712,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <TextField id="cashout-account" label={i18n.str`Cashout account`} - help={i18n.str`External account number where the money is going to be sent when doing cashouts`} + help={i18n.str`External account to which cashout transfers are sent.`} error={errors?.cashout_payto_uri} onChange={(e) => { form.cashout_payto_uri = e as PaytoString; diff --git a/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx b/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx @@ -266,7 +266,7 @@ function Metrics({ type="warning" title={i18n.str`Querying for the current stats failed`} > - <i18n.Translate>The request parameters are wrong</i18n.Translate> + <i18n.Translate>The request parameters are invalid.</i18n.Translate> </Attention> ); case HttpStatusCode.Unauthorized: @@ -291,7 +291,7 @@ function Metrics({ type="warning" title={i18n.str`Querying for the previous stats failed`} > - <i18n.Translate>The request parameters are wrong</i18n.Translate> + <i18n.Translate>The request parameters are invalid.</i18n.Translate> </Attention> ); case HttpStatusCode.Unauthorized: diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx @@ -88,7 +88,7 @@ export function ConversionClassList({ return ( <Attention type="warning" - title={i18n.str`Conversion list not found. Maybe conversion rate is not supported.`} + title={i18n.str`The conversion-rate list was not found. Conversion may not be supported.`} ></Attention> ); case HttpStatusCode.NotImplemented: diff --git a/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx @@ -78,7 +78,7 @@ export function CreateNewAccount({ case TalerErrorCode.BANK_UNALLOWED_DEBIT: return i18n.str`Bank ran out of bonus credit.`; case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`Account username can't be used because it is reserved`; + return i18n.str`This account username is reserved and cannot be used.`; case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return i18n.str`Only an administrator is allowed to set the debt limit.`; case TalerErrorCode.BANK_MISSING_TAN_INFO: @@ -106,7 +106,7 @@ export function CreateNewAccount({ <Fragment> <Attention type="warning" title={i18n.str`Can't create accounts`}> <i18n.Translate> - Only system admin can create accounts. + Only a system administrator can create accounts. </i18n.Translate> </Attention> <div class="mt-5 sm:mt-6"> diff --git a/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx b/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx @@ -418,7 +418,7 @@ export function DownloadStats({ routeCancel }: Props): VNode { > <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> <i18n.Translate> - downloading...{" "} + Downloading…{" "} {Math.round((lastStep.step / lastStep.total) * 100)} </i18n.Translate> </span> diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx @@ -217,9 +217,9 @@ function useComponentState({ case HttpStatusCode.NotImplemented: return i18n.str`Conversion is not implemented.`; case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; + return i18n.str`At least a debit or credit amount must be provided.`; case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malformed`; + return i18n.str`The amount has an invalid format.`; case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: return i18n.str`The currency is not supported`; default: @@ -461,8 +461,8 @@ function useComponentState({ <div class="p-4"> <Attention title={i18n.str`Bad ratios`} type="warning"> <i18n.Translate> - One of the ratios should be higher or equal than 1 and - the other should be lower or equal than 1. + One ratio must be greater than or equal to 1, and the + other must be less than or equal to 1. </i18n.Translate> </Attention> </div> @@ -829,6 +829,18 @@ export function ConversionForm({ id: string; }): VNode { const { i18n } = useTranslationContext(); + // Translators: Ratio used to convert between the regional and fiat + // currencies. + const ratioLabel = i18n.str`Ratio`; + // Translators: Rounding-mode name: round toward zero, to the largest allowed + // value below the input amount. + const roundTowardZeroLabel = i18n.str`Zero`; + // Translators: Rounding-mode name: round upward to the smallest allowed value + // above the input amount. + const roundUpLabel = i18n.str`Up`; + // Translators: Rounding-mode name: round to the allowed value closest to the + // input amount. + const roundNearestLabel = i18n.str`Nearest`; return ( <Fragment> <div class="px-6 pt-6"> @@ -865,7 +877,7 @@ export function ConversionForm({ class="block text-sm font-medium leading-6 text-onBackground" for={`${id}_ratio`} > - {i18n.str`Ratio`} + {ratioLabel} </label> <div class="mt-2"> <input @@ -937,7 +949,7 @@ export function ConversionForm({ <div class="mt-2 max-w-xl text-sm text-gray-500"> <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> <label - aria-label={i18n.str`Zero`} + aria-label={roundTowardZeroLabel} data-selected={rounding?.value === "zero"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > @@ -952,7 +964,7 @@ export function ConversionForm({ <span class="flex flex-1"> <span class="flex flex-col"> <span class="block text-sm font-medium text-onBackground "> - <i18n.Translate>Zero</i18n.Translate> + {roundTowardZeroLabel} </span> <i18n.Translate> Amount will be rounded below to the largest possible @@ -976,7 +988,7 @@ export function ConversionForm({ </label> <label - aria-label={i18n.str`Up`} + aria-label={roundUpLabel} data-selected={rounding?.value === "up"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > @@ -991,7 +1003,7 @@ export function ConversionForm({ <span class="flex flex-1"> <span class="flex flex-col"> <span class="block text-sm font-medium text-onBackground "> - <i18n.Translate>Up</i18n.Translate> + {roundUpLabel} </span> <i18n.Translate> Amount will be rounded up to the smallest possible value @@ -1014,7 +1026,7 @@ export function ConversionForm({ </svg> </label> <label - aria-label={i18n.str`Nearest`} + aria-label={roundNearestLabel} data-selected={rounding?.value === "nearest"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-primary" > @@ -1029,7 +1041,7 @@ export function ConversionForm({ <span class="flex flex-1"> <span class="flex flex-col"> <span class="block text-sm font-medium text-onBackground "> - <i18n.Translate>Nearest</i18n.Translate> + {roundNearestLabel} </span> <i18n.Translate> Amount will be rounded to the closest possible value. diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx @@ -335,9 +335,9 @@ function CreateCashoutInternal({ case HttpStatusCode.NotImplemented: return i18n.str`Conversion is not implemented.`; case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; + return i18n.str`At least a debit or credit amount must be provided.`; case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malformed`; + return i18n.str`The amount has an invalid format.`; case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: return i18n.str`The currency is not supported`; default: @@ -402,7 +402,7 @@ function CreateCashoutInternal({ ? undefined : balanceLimit.result.negative || Amounts.cmp(balanceLimit.result, calculationResult.debit) < 0 - ? i18n.str`Balance is not enough` + ? i18n.str`The balance is not sufficient` : Amounts.cmp(calculationResult.debit, rate.cashout_min_amount) < 0 ? i18n.str`It is not possible to cash out less than ${ Amounts.stringifyValueWithSpec( @@ -452,6 +452,7 @@ function CreateCashoutInternal({ switch (fail.case) { case HttpStatusCode.Accepted: mfa.onNewChallenge( + // Translators: Dialog title for authenticating a cashout operation. i18n.str`Cashout`, session.username, fail.body, diff --git a/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx b/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx @@ -55,10 +55,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { if (Number.isNaN(cid)) { return ( - <Attention - type="danger" - title={i18n.str`Cashout id should be a number`} - /> + <Attention type="danger" title={i18n.str`Cashout ID must be a number.`} /> ); } if (!result) { @@ -79,7 +76,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { return ( <Attention type="warning" - title={i18n.str`This cashout was not found. Maybe already aborted.`} + title={i18n.str`This cashout was not found. It may already have been aborted.`} ></Attention> ); case HttpStatusCode.NotImplemented: diff --git a/packages/libeufin-bank-webui/src/utils.ts b/packages/libeufin-bank-webui/src/utils.ts @@ -98,9 +98,9 @@ export function validateIBAN( case ParseIbanError.UNSUPPORTED_COUNTRY: return i18n.str`IBAN country code not found`; case ParseIbanError.TOO_LONG: - return i18n.str`IBAN numbers have less than 34 digits`; + return i18n.str`The IBAN must not exceed 34 characters.`; case ParseIbanError.TOO_SHORT: - return i18n.str`IBAN numbers have more than 4 digits`; + return i18n.str`The IBAN must contain at least 4 characters.`; case ParseIbanError.INVALID_LENGTH: return i18n.str`IBAN length is invalid for this country`; case ParseIbanError.INVALID_CHARSET: diff --git a/packages/pogen/README.md b/packages/pogen/README.md @@ -27,22 +27,26 @@ Configuration lives under `pogen` in the package's `package.json`: { "pogen": { "domain": "taler-merchant-webui", - "minimumCoverage": 85 + "minimumCoverage": 85, + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } ``` `domain` names the template: `src/i18n/<domain>.pot`. `minimumCoverage` is an optional integer percentage used by `pogen check`; it defaults to 85, matching -the browser language auto-selection threshold. Paths are otherwise fixed — -`src/i18n/*.po` in, `src/i18n/strings.ts` out. Two optional files are read if -present: `src/i18n/poheader` (replaces the default `.pot` header) and -`src/i18n/strings-prelude` (replaces the preamble of the emitted `strings.ts`). +the browser language auto-selection threshold. `requiredLanguages` is an +optional list of language tags whose catalogues must exist and be 100% +complete; tags are matched case-insensitively and treat `_` like `-`. Paths are +otherwise fixed — `src/i18n/*.po` in, `src/i18n/strings.ts` out. Two optional +files are read if present: `src/i18n/poheader` (replaces the default `.pot` +header) and `src/i18n/strings-prelude` (replaces the preamble of the emitted +`strings.ts`). > Earlier versions of this document described `pofile`, `plainI18nPackage` and > `reactI18nPackage`, and claimed extraction was scoped to strings imported from > a named package. None of that was ever implemented. Extraction is purely -> syntactic — see below — and `domain` is the only key read. +> syntactic — see below. ## What gets extracted @@ -146,9 +150,11 @@ translator comment does survive. Exits non-zero on any of: `msgfmt --check-format` failures; a translation whose `%N$s` placeholders do not match its msgid; or a non-`en` catalogue whose -completeness falls below the package's configured minimum. Run it in CI — the -other three subcommands are deliberately permissive and will not tell you a -catalogue is broken. +completeness falls below the package's configured minimum. Required-language +catalogues must be present and 100% complete. A `de-CH` catalogue is also +rejected if a translation contains `ß` or `ẞ`, which Swiss Standard German does +not use. Run `pogen check` in CI — the other three subcommands are deliberately +permissive and will not tell you a catalogue is broken. ## Notes and known limitations diff --git a/packages/pogen/src/check.test.ts b/packages/pogen/src/check.test.ts @@ -17,14 +17,49 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + checkSwissOrthography, MIN_LANG_COVERAGE_THRESHOLD, resolveMinimumCoverage, + resolveRequiredLanguages, } from "./check.js"; +const swissPo = (translation: string): string => `msgid "" +msgstr "" +"Language: de-CH\\n" + +msgid "Street" +msgstr "${translation}" +`; + test("coverage defaults to the browser language threshold", () => { assert.equal(resolveMinimumCoverage(undefined), MIN_LANG_COVERAGE_THRESHOLD); }); +test("required languages are normalized and deduplicated", () => { + assert.deepEqual(resolveRequiredLanguages(undefined), []); + assert.deepEqual(resolveRequiredLanguages(["de", "de_CH", "DE-ch", "fr"]), [ + "de", + "de-ch", + "fr", + ]); +}); + +test("required languages reject malformed configuration", () => { + for (const value of ["de", [""], [55], ["not a language tag"]]) { + assert.throws( + () => resolveRequiredLanguages(value), + /pogen\.requiredLanguages.*array of valid language tags/, + ); + } +}); + +test("Swiss German rejects sharp s in translated messages", () => { + assert.deepEqual(checkSwissOrthography(swissPo("Strasse"), "de-CH.po"), []); + assert.deepEqual(checkSwissOrthography(swissPo("Straße"), "de-CH.po"), [ + 'de-CH.po: Swiss German translation contains ß: "Straße"', + ]); +}); + test("coverage accepts package-specific integer percentages", () => { assert.equal(resolveMinimumCoverage(0), 0); assert.equal(resolveMinimumCoverage(55), 55); diff --git a/packages/pogen/src/check.ts b/packages/pogen/src/check.ts @@ -59,6 +59,32 @@ export function resolveMinimumCoverage(value: unknown): number { } /** + * Read the language catalogues that must be present and fully translated. + * Language tags are compared case-insensitively and treat `_` like `-`, just + * as the runtime does. + */ +export function resolveRequiredLanguages(value: unknown): string[] { + if (value === undefined) return []; + if ( + !Array.isArray(value) || + value.some( + (language) => + typeof language !== "string" || + !/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/.test(language), + ) + ) { + throw Error( + "'pogen.requiredLanguages' must be an array of valid language tags", + ); + } + return [...new Set(value.map(normalizeLanguage))]; +} + +function normalizeLanguage(language: string): string { + return language.replaceAll("_", "-").toLowerCase(); +} + +/** * The placeholders a message uses, sorted so two messages can be compared * regardless of the word order the target language needs. * @@ -118,6 +144,25 @@ export function checkPlaceholders(poText: string, name: string): string[] { return problems; } +/** Swiss Standard German uses `ss`, never the German `ß`/`ẞ` characters. */ +export function checkSwissOrthography(poText: string, name: string): string[] { + const parsed = gettextParser.po.parse(poText); + const problems: string[] = []; + for (const bucket of Object.values(parsed.translations)) { + for (const [msgid, entry] of Object.entries(bucket || {})) { + if (!msgid) continue; + for (const translation of entry.msgstr || []) { + if (/[ßẞ]/.test(translation)) { + problems.push( + `${name}: Swiss German translation contains ß: ${JSON.stringify(translation)}`, + ); + } + } + } + } + return problems; +} + function haveGettext(): boolean { try { child_process.execFileSync("msgfmt", ["--version"], { stdio: "ignore" }); @@ -137,10 +182,14 @@ export function check(): void { fs.readFileSync("./package.json", { encoding: "utf-8" }), ); let minimumCoverage: number; + let requiredLanguages: string[]; try { minimumCoverage = resolveMinimumCoverage( packageJson.pogen?.minimumCoverage, ); + requiredLanguages = resolveRequiredLanguages( + packageJson.pogen?.requiredLanguages, + ); } catch (e) { console.error(e instanceof Error ? e.message : e); process.exit(1); @@ -177,6 +226,7 @@ export function check(): void { } } + const foundLanguages = new Set<string>(); for (const f of files) { const poText = fs.readFileSync(f, "utf-8"); @@ -201,10 +251,19 @@ export function check(): void { failed = true; continue; } - if (lang !== "en" && completeness < minimumCoverage) { + const normalizedLanguage = normalizeLanguage(lang); + foundLanguages.add(normalizedLanguage); + if (normalizedLanguage === "de-ch") { + const problems = checkSwissOrthography(poText, f); + for (const problem of problems) console.error(problem); + if (problems.length > 0) failed = true; + } + const required = requiredLanguages.includes(normalizedLanguage); + const threshold = required ? 100 : minimumCoverage; + if (normalizedLanguage !== "en" && completeness < threshold) { console.error( `${f}: only ${completeness}% translated, below the ` + - `configured minimum of ${minimumCoverage}%`, + `${required ? "required" : "configured minimum"} of ${threshold}%`, ); failed = true; } else { @@ -212,6 +271,13 @@ export function check(): void { } } + for (const language of requiredLanguages) { + if (!foundLanguages.has(language)) { + console.error(`missing required language catalogue '${language}'`); + failed = true; + } + } + if (failed) { process.exit(1); } diff --git a/packages/pogen/src/potextract.test.ts b/packages/pogen/src/potextract.test.ts @@ -398,6 +398,60 @@ msgstr ""`, ); }); +test("should attach a JSX-expression translator comment to a tagged message", () => { + const out = process(` + <section> + {/* Translators: This is the wallet interface language. */} + <h2>{i18n.str\`Language\`}</h2> + </section>`); + assert.match( + out, + /#\. Translators: This is the wallet interface language\.\n/, + ); + assert.match(out, /msgid "Language"/); +}); + +test("should not reuse a JSX-expression translator comment", () => { + const out = process(` + <section> + {/* Translators: Only describes the first label. */} + <i18n.Translate>First label</i18n.Translate> + <i18n.Translate>Second label</i18n.Translate> + </section>`); + assert.match( + out, + /#\. Translators: Only describes the first label\.\n[^]*msgid "First label"/, + ); + assert.equal( + out.match(/Translators: Only describes the first label\./g)?.length, + 1, + ); +}); + +test("should attach a translator comment between JSX attributes", () => { + const out = process(` + <SettingsToggle + // Translators: api is a literal feature name. + description={i18n.str\`Enable the api feature.\`} + />`); + assert.match(out, /#\. Translators: api is a literal feature name\.\n/); + assert.match(out, /msgid "Enable the api feature\."/); +}); + +test("should attach a translator comment in a ternary arm", () => { + const out = process(` + return enabled + ? // Translators: A noun naming a payment receipt. + i18n.str\`Receipt\` + : i18n.str\`Disabled\`;`); + assert.match(out, /#\. Translators: A noun naming a payment receipt\.\n/); + assert.match(out, /msgid "Receipt"/); + assert.match( + out, + /msgstr ""\n\n#\. screenid: 5\n#: test\.tsx:8\nmsgid "Disabled"/, + ); +}); + // // Duplicates. // diff --git a/packages/pogen/src/potextract.ts b/packages/pogen/src/potextract.ts @@ -91,7 +91,7 @@ function getComment( } } if (!found.length) { - return ""; + return getNearbyTranslatorComment(sourceFile, node); } const startLineOf = (c: ts.CommentRange) => ts.getLineAndCharacterOfPosition(sourceFile, c.pos).line; @@ -100,7 +100,7 @@ function getComment( let first = found.length - 1; if (endLineOf(found[first]) != lc.line - 1) { - return ""; + return getNearbyTranslatorComment(sourceFile, node); } // A run of consecutive "//" lines is one comment, just like in xgettext. if (found[first].kind === ts.SyntaxKind.SingleLineCommentTrivia) { @@ -133,6 +133,90 @@ function getComment( return lines.join("\n"); } +const translatorCommentCache = new WeakMap< + ts.SourceFile, + Array<{ pos: number; end: number; text: string }> +>(); + +function translatorComments( + sourceFile: ts.SourceFile, +): Array<{ pos: number; end: number; text: string }> { + const cached = translatorCommentCache.get(sourceFile); + if (cached) return cached; + + const comments: Array<{ pos: number; end: number; text: string }> = []; + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + false, + ts.LanguageVariant.JSX, + sourceFile.text, + ); + for ( + let token = scanner.scan(); + token !== ts.SyntaxKind.EndOfFileToken; + token = scanner.scan() + ) { + if ( + token !== ts.SyntaxKind.SingleLineCommentTrivia && + token !== ts.SyntaxKind.MultiLineCommentTrivia + ) { + continue; + } + const pos = scanner.getTokenPos(); + const end = scanner.getTextPos(); + const raw = sourceFile.text.slice(pos, end); + if (!/\bTranslators?:/i.test(raw)) continue; + const text = raw + .replace(/^[/][/]\s*/, "") + .replace(/^[/][*]\s*/, "") + .replace(/\s*[*][/]$/, "") + .trim(); + comments.push({ pos, end, text }); + } + translatorCommentCache.set(sourceFile, comments); + return comments; +} + +/** + * Find an explicitly marked translator comment near a message even when JSX + * punctuation prevents TypeScript from exposing it as ordinary leading + * trivia. This covers comments written as `{/* Translators: ... *\/}` before + * JSX children and `// Translators: ...` between JSX attributes or after a + * ternary operator. + */ +function getNearbyTranslatorComment( + sourceFile: ts.SourceFile, + node: ts.Node, +): string { + const start = node.getStart(sourceFile); + const comments = translatorComments(sourceFile); + for (let index = comments.length - 1; index >= 0; index--) { + const comment = comments[index]; + if (comment.end > start) continue; + const commentLine = ts.getLineAndCharacterOfPosition( + sourceFile, + comment.end, + ).line; + const messageLine = ts.getLineAndCharacterOfPosition( + sourceFile, + start, + ).line; + if (messageLine - commentLine > 6) return ""; + + const between = sourceFile.text.slice(comment.end, start); + // Do not reuse one comment for a later message in the same JSX block. + if ( + /(?:<\s*i18n\.(?:Translate|TranslateSwitch)\b|(?:\bi18n\.[A-Za-z]+|\bt)\s*`)/.test( + between, + ) + ) { + return ""; + } + return comment.text; + } + return ""; +} + function getPath(node: ts.Node): { path: string[]; ctx: string } { switch (node.kind) { case ts.SyntaxKind.PropertyAccessExpression: { diff --git a/packages/taler-auditor-webui/package.json b/packages/taler-auditor-webui/package.json @@ -34,6 +34,7 @@ "typescript": "7.0.2" }, "pogen": { - "domain": "taler-auditor-backoffice" + "domain": "taler-auditor-backoffice", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/taler-auditor-webui/src/Application.tsx b/packages/taler-auditor-webui/src/Application.tsx @@ -77,12 +77,12 @@ function ApplicationStatusRoutes(): VNode { ) { return ( <Fragment> - <NotConnectedAppMenu title="Login" /> + <NotConnectedAppMenu title={i18n.str`Login`} /> <NotificationCard notification={{ message: i18n.str`Checking the /config endpoint returned an authorization error`, type: "ERROR", - description: `The /config endpoint of the backend server should be accessible`, + description: i18n.str`The /config endpoint of the backend server should be accessible`, }} /> </Fragment> @@ -94,12 +94,12 @@ function ApplicationStatusRoutes(): VNode { ) { return ( <Fragment> - <NotConnectedAppMenu title="Error" /> + <NotConnectedAppMenu title={i18n.str`Error`} /> <NotificationCard notification={{ message: i18n.str`Could not find /config endpoint on this URL`, type: "ERROR", - description: `Check the URL or contact the system administrator.`, + description: i18n.str`Check the URL or contact the system administrator.`, }} /> </Fragment> @@ -108,7 +108,7 @@ function ApplicationStatusRoutes(): VNode { if (result.type === ErrorType.SERVER) { return ( <Fragment> - <NotConnectedAppMenu title="Error" /> + <NotConnectedAppMenu title={i18n.str`Error`} /> <NotificationCard notification={{ message: i18n.str`Server responded with an error code`, @@ -122,7 +122,7 @@ function ApplicationStatusRoutes(): VNode { if (result.type === ErrorType.UNREADABLE) { return ( <Fragment> - <NotConnectedAppMenu title="Error" /> + <NotConnectedAppMenu title={i18n.str`Error`} /> <NotificationCard notification={{ message: i18n.str`Response from server is unreadable, http status: ${result.status}`, @@ -135,7 +135,7 @@ function ApplicationStatusRoutes(): VNode { } return ( <Fragment> - <NotConnectedAppMenu title="Error" /> + <NotConnectedAppMenu title={i18n.str`Error`} /> <NotificationCard notification={{ message: i18n.str`Unexpected Error`, @@ -154,7 +154,7 @@ function ApplicationStatusRoutes(): VNode { ) { return ( <Fragment> - <NotConnectedAppMenu title="Error" /> + <NotConnectedAppMenu title={i18n.str`Error`} /> <NotificationCard notification={{ message: i18n.str`Incompatible version`, diff --git a/packages/taler-auditor-webui/src/InstanceRoutes.tsx b/packages/taler-auditor-webui/src/InstanceRoutes.tsx @@ -8,6 +8,7 @@ */ import type { VNode } from "preact"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useEffect, useErrorBoundary, useMemo } from "preact/hooks"; import { Redirect, Route, Switch, useLocation } from "wouter-preact"; import { Menu, NotificationCard } from "./components/menu/index.js"; @@ -24,19 +25,19 @@ import { Paths, reportForMonitoringPath } from "./routing/monitoringRoutes.js"; import type { HttpError } from "./utils/http.js"; import type { AuditorBackend } from "./declaration.js"; -const dashboardTitles: Partial<Record<Paths, string>> = { - [Paths.overview]: "Overview", - [Paths.key_figures]: "Key figures", - [Paths.critical_errors]: "Critical errors", - [Paths.operating_status]: "Operating status", - [Paths.detail_view]: "All reports", - [Paths.settings]: "Settings", -}; - export function InstanceRoutes(): VNode { + const { i18n } = useTranslationContext(); const [path] = useLocation(); const [error] = useErrorBoundary(); const report = reportForMonitoringPath(path); + const dashboardTitles: Partial<Record<Paths, string>> = { + [Paths.overview]: i18n.str`Overview`, + [Paths.key_figures]: i18n.str`Key figures`, + [Paths.critical_errors]: i18n.str`Critical errors`, + [Paths.operating_status]: i18n.str`Operating status`, + [Paths.detail_view]: i18n.str`All reports`, + [Paths.settings]: i18n.str`Settings`, + }; useEffect(() => window.scrollTo(0, 0), [path]); @@ -57,7 +58,7 @@ export function InstanceRoutes(): VNode { <NotificationCard notification={{ type: "ERROR", - message: "The auditor API request failed", + message: i18n.str`The auditor API request failed`, description: problem.message, }} /> @@ -69,7 +70,7 @@ export function InstanceRoutes(): VNode { {error && ( <NotificationCard notification={{ - message: "Internal error, please report", + message: i18n.str`Internal error, please report`, type: "ERROR", description: error instanceof Error ? error.message : String(error), }} diff --git a/packages/taler-auditor-webui/src/components/DashboardTableRow.tsx b/packages/taler-auditor-webui/src/components/DashboardTableRow.tsx @@ -3,6 +3,7 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { ComponentChildren, JSX, VNode } from "preact"; import { useLocation } from "wouter-preact"; import { pathForMonitoringEndpoint } from "../routing/monitoringRoutes.js"; @@ -27,6 +28,7 @@ export function DashboardTableRow({ children, tone = "neutral", }: Props): VNode { + const { i18n } = useTranslationContext(); const [, setLocation] = useLocation(); const path = pathForMonitoringEndpoint(endpoint); @@ -44,7 +46,13 @@ export function DashboardTableRow({ return ( <tr - aria-label={`${tone === "neutral" ? "" : `${tone === "critical" ? "Emergency" : "Warning"}: `}View ${label}`} + aria-label={ + tone === "critical" + ? i18n.str`Emergency: View ${label}` + : tone === "warning" + ? i18n.str`Warning: View ${label}` + : i18n.str`View ${label}` + } class={`cursor-pointer transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-taler-brand ${toneClasses[tone]}`} onClick={() => setLocation(path)} onKeyDown={onKeyDown} diff --git a/packages/taler-auditor-webui/src/components/exception/loading.tsx b/packages/taler-auditor-webui/src/components/exception/loading.tsx @@ -19,16 +19,20 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; export function Loading(): VNode { + const { i18n } = useTranslationContext(); return ( <div class="flex min-h-[50vh] w-full items-center justify-center" role="status" > <Spinner /> - <span class="sr-only">Loading</span> + <span class="sr-only"> + <i18n.Translate>Loading</i18n.Translate> + </span> </div> ); } diff --git a/packages/taler-auditor-webui/src/components/menu/LangSelector.tsx b/packages/taler-auditor-webui/src/components/menu/LangSelector.tsx @@ -12,18 +12,19 @@ const names: Record<string, string> = { en: "English [en]", fr: "Français [fr]", de: "Deutsch [de]", + "de-CH": "Deutsch (Schweiz) [de-CH]", sv: "Svenska [sv]", it: "Italiano [it]", }; export function LangSelector(): VNode { - const { lang, changeLanguage } = useTranslationContext(); + const { i18n, lang, changeLanguage } = useTranslationContext(); return ( <select class="auditor-input max-w-xs" value={lang} onChange={(event) => changeLanguage(event.currentTarget.value)} - aria-label="Language" + aria-label={i18n.str`Language`} > {Object.keys(messages).map((code) => ( <option key={code} value={code}> diff --git a/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx b/packages/taler-auditor-webui/src/components/menu/NavigationBar.tsx @@ -3,6 +3,7 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import logo from "../../assets/logo-2021.svg"; @@ -17,6 +18,7 @@ export function NavigationBar({ title, withSidebar = true, }: Props): VNode { + const { i18n } = useTranslationContext(); return ( <header class={`fixed inset-x-0 top-0 z-30 flex h-16 items-center border-b border-gray-200 bg-white/95 px-4 shadow-sm backdrop-blur md:px-6 ${ @@ -27,7 +29,7 @@ export function NavigationBar({ <button type="button" class="mr-3 rounded-lg p-2 text-gray-600 hover:bg-gray-100 md:hidden" - aria-label="Open navigation" + aria-label={i18n.str`Open navigation`} onClick={onMobileMenu} > <span aria-hidden="true" class="text-xl"> @@ -42,9 +44,9 @@ export function NavigationBar({ <a class="ml-4 shrink-0 rounded hover:opacity-80" href="https://taler.net" - aria-label="GNU Taler website" + aria-label={i18n.str`GNU Taler website`} > - <img src={logo} class="h-9 w-auto" alt="GNU Taler" /> + <img src={logo} class="h-9 w-auto" alt={i18n.str`GNU Taler`} /> </a> )} </header> diff --git a/packages/taler-auditor-webui/src/components/menu/SideBar.tsx b/packages/taler-auditor-webui/src/components/menu/SideBar.tsx @@ -55,7 +55,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { Auditor </span> <span class="mt-0.5 block text-xs text-gray-500"> - API version {config.version} + <i18n.Translate>API version {config.version}</i18n.Translate> </span> </span> </Link> @@ -64,7 +64,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { type="button" class="rounded p-1 text-gray-400 hover:text-white" onClick={onClose} - aria-label="Close navigation" + aria-label={i18n.str`Close navigation`} > ✕ </button> @@ -93,7 +93,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { href="/dev" class="rounded-lg px-3 py-2 text-xs font-medium text-gray-500 hover:bg-gray-800 hover:text-gray-300" > - Development settings + <i18n.Translate>Development settings</i18n.Translate> </Link> </aside> ); @@ -106,7 +106,7 @@ export function Sidebar({ mobile = false, onClose }: Props): VNode { type="button" class="flex-1" onClick={onClose} - aria-label="Close navigation" + aria-label={i18n.str`Close navigation`} /> </div> ); diff --git a/packages/taler-auditor-webui/src/components/modal/index.tsx b/packages/taler-auditor-webui/src/components/modal/index.tsx @@ -25,7 +25,7 @@ export function ConfirmModal({ children, danger, disabled, - label = "Confirm", + label, }: Props): VNode | null { const { i18n } = useTranslationContext(); if (!active) return null; @@ -42,7 +42,7 @@ export function ConfirmModal({ type="button" class="rounded p-2 text-gray-500 hover:bg-gray-100" onClick={onCancel} - aria-label="Close" + aria-label={i18n.str`Close dialog`} > ✕ </button> @@ -62,7 +62,7 @@ export function ConfirmModal({ disabled={disabled} onClick={onConfirm} > - <i18n.Translate>{label}</i18n.Translate> + {label ?? i18n.str`Confirm`} </button> )} </footer> diff --git a/packages/taler-auditor-webui/src/hooks/finance.ts b/packages/taler-auditor-webui/src/hooks/finance.ts @@ -22,6 +22,7 @@ import { MonitoringSummary, RecordsResponse, } from "../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { reportsForDashboard } from "../routing/monitoringRoutes.js"; import { HttpResponse, HttpResponseOk, RequestError } from "../utils/http.js"; import { useBackendRequest } from "./backend.js"; @@ -35,12 +36,13 @@ export function useKeyFiguresData(): HttpResponse< any, AuditorBackend.ErrorDetail > { + const { i18n } = useTranslationContext(); const { multiFetcher } = useBackendRequest(); const endpoints = [ { endpoint: "balances", - label: "Auditor balances", - description: "Auditor-wide accounting totals.", + label: i18n.str`Auditor balances`, + description: i18n.str`Auditor-wide accounting totals.`, }, ...reportsForDashboard("finance").map((report) => ({ endpoint: report.endpoint, diff --git a/packages/taler-auditor-webui/src/hooks/overview.ts b/packages/taler-auditor-webui/src/hooks/overview.ts @@ -8,6 +8,7 @@ import type { MonitoringSummary, RecordsResponse, } from "../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { monitoringReports } from "../routing/monitoringRoutes.js"; import type { HttpResponse, @@ -33,6 +34,7 @@ export function useOverviewData(): HttpResponse< OverviewData, AuditorBackend.ErrorDetail > { + const { i18n } = useTranslationContext(); const { multiFetcher } = useBackendRequest(); const reports = monitoringReports.filter( (report) => report.dashboard !== undefined, @@ -45,8 +47,8 @@ export function useOverviewData(): HttpResponse< })), { endpoint: "balances", - label: "Auditor balances", - description: "Auditor-wide accounting totals.", + label: i18n.str`Auditor balances`, + description: i18n.str`Auditor-wide accounting totals.`, }, ]; const paths = endpoints.map(({ endpoint }) => `monitoring/${endpoint}`); diff --git a/packages/taler-auditor-webui/src/paths/default/Table.tsx b/packages/taler-auditor-webui/src/paths/default/Table.tsx @@ -81,7 +81,9 @@ function Table({ ))} {hasActions && ( <th> - <span class="sr-only">Actions</span> + <span class="sr-only"> + <i18n.Translate>Actions</i18n.Translate> + </span> </th> )} </tr> @@ -102,6 +104,8 @@ function Table({ type="button" onClick={() => onSuppress(String(row[rowIdField]))} > + {/* Translators: Removes an auditor finding from the + active report; it does not delete the database row. */} <i18n.Translate>Suppress</i18n.Translate> </button> )} diff --git a/packages/taler-auditor-webui/src/paths/default/index.tsx b/packages/taler-auditor-webui/src/paths/default/index.tsx @@ -51,6 +51,9 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { const [, setLocation] = useLocation(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); + // Translators: "Suppressing" hides an auditor finding from active reports; + // it does not delete the underlying database row. + const suppressionWarning = i18n.str`Suppressing a row cannot be undone in this GUI.`; let data = result.loading || !result.ok ? undefined : result.data; const value = useMemo(() => ({ data }), [data]); @@ -71,7 +74,7 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { class="auditor-button" onClick={() => setLocation(Paths.detail_view)} > - ← Back to all reports + <i18n.Translate>← Back to all reports</i18n.Translate> </button> <p class="max-w-4xl text-sm leading-6 text-gray-600">{description}</p> @@ -84,8 +87,8 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { {suppressing && ( <ConfirmModal - label={`Suppress row`} - description={`Suppress the row`} + label={i18n.str`Suppress row`} + description={i18n.str`Suppress the row`} danger active onCancel={() => setSuppressing(null)} @@ -106,9 +109,7 @@ export default function DefaultList({ onLoadError, onNotFound }: Props): VNode { setSuppressing(null); }} > - <p class="text-sm text-gray-600"> - Suppressing a row <b>cannot be undone</b> in this GUI. - </p> + <p class="text-sm text-gray-600">{suppressionWarning}</p> </ConfirmModal> )} </section> diff --git a/packages/taler-auditor-webui/src/paths/details/ListPage.tsx b/packages/taler-auditor-webui/src/paths/details/ListPage.tsx @@ -3,6 +3,7 @@ (C) 2021-2026 Taler Systems S.A. */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import { Link } from "wouter-preact"; import { @@ -11,14 +12,17 @@ import { } from "../../routing/monitoringRoutes.js"; export function ListPage(): VNode { + const { i18n } = useTranslationContext(); return ( <div class="space-y-10"> <div class="max-w-4xl"> <p class="text-base leading-7 text-gray-600"> - This is the complete set of data exposed by the auditor monitoring - API. Diagnostic reports show unsuppressed findings by default; state - and history reports show the auditor's current accounting data and - processing position. + <i18n.Translate> + This is the complete set of data exposed by the auditor monitoring + API. Diagnostic reports show unsuppressed findings by default; state + and history reports show the auditor's current accounting data and + processing position. + </i18n.Translate> </p> </div> {monitoringSections.map((section) => { @@ -47,7 +51,9 @@ export function ListPage(): VNode { </span> </span> <span class="text-sm font-semibold text-taler-brand"> - View report <span aria-hidden="true">→</span> + <i18n.Translate> + View report <span aria-hidden="true">→</span> + </i18n.Translate> </span> </Link> ))} diff --git a/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx b/packages/taler-auditor-webui/src/paths/finance/ListPage.tsx @@ -3,6 +3,7 @@ (C) 2021-2026 Taler Systems S.A. */ +import { i18n } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import type { VNode } from "preact"; import { DashboardTableRow } from "../../components/DashboardTableRow.js"; @@ -23,8 +24,12 @@ interface BalanceGroup { const balanceGroups: readonly BalanceGroup[] = [ { id: "coins", - title: "Coins", - description: "Coin holdings, fees, losses and emergency exposure.", + get title() { + return i18n.str`Coins`; + }, + get description() { + return i18n.str`Coin holdings, fees, losses and emergency exposure.`; + }, matches: (key) => key.startsWith("coin") || ["total_escrowed", "total_recoup_loss", "total_refresh_hanging"].includes( @@ -33,9 +38,12 @@ const balanceGroups: readonly BalanceGroup[] = [ }, { id: "reserves", - title: "Reserves", - description: - "Money held in reserves, reserve fees and reserve-related losses.", + get title() { + return i18n.str`Reserves`; + }, + get description() { + return i18n.str`Money held in reserves, reserve fees and reserve-related losses.`; + }, matches: (key) => key.startsWith("reserves_") || key.startsWith("total_balance_reserve_") || @@ -43,50 +51,73 @@ const balanceGroups: readonly BalanceGroup[] = [ }, { id: "purses", - title: "Purses", - description: "Money held in purses and purse-related discrepancies.", + get title() { + return i18n.str`Purses`; + }, + get description() { + return i18n.str`Money held in purses and purse-related discrepancies.`; + }, matches: (key) => key.startsWith("purse_"), }, { id: "aggregation", - title: "Deposit aggregation", - description: - "Merchant aggregation totals, fees and arithmetic differences.", + get title() { + return i18n.str`Deposit aggregation`; + }, + get description() { + return i18n.str`Merchant aggregation totals, fees and arithmetic differences.`; + }, matches: (key) => key.startsWith("aggregation_"), }, { id: "wire", - title: "Wire and settlement", - description: - "Incoming and outgoing bank transfers, transfer fees, delays and settlement differences.", + get title() { + return i18n.str`Wire and settlement`; + }, + get description() { + return i18n.str`Incoming and outgoing bank transfers, transfer fees, delays and settlement differences.`; + }, matches: (key) => key.startsWith("total_") || key.startsWith("wire_debit_"), }, ]; -const balanceLabels: Readonly<Record<string, string>> = { - purse_global_balance: "Total balance held in purses", - reserves_reserve_total_balance: "Total balance held in reserves", - total_aml_hold: "Total amount in held outgoing transfers", - total_amount_lag: "Deposits not settled by the wire deadline", - total_bad_amount_in_minus: "Incoming transfers below exchange records", - total_bad_amount_in_plus: "Incoming transfers above exchange records", - total_bad_amount_out_minus: "Outgoing transfers below exchange records", - total_bad_amount_out_plus: "Outgoing transfers above exchange records", - total_balance_reserve_not_closed: "Total balance in expired reserves", - total_closure_amount_lag: "Overdue reserve closure transfers", - total_drained: "Profits drained from the exchange account", - total_early_aggregation: "Deposits aggregated before eligibility", - total_kycauth_in: "KYC authentication credits reported by the bank", - total_kycauth_revenue: "KYC authentication revenue booked by the exchange", - total_misattribution_in: "Incoming transfers credited to the wrong reserve", - total_missed_deposit_confirmations: "Deposits missing merchant confirmation", - total_small_aggregate: "Total held below the wire-fee threshold", - total_transfer_lag: "Aggregated transfers withheld without a reason", - total_wire_credit_fees: "Incoming wire transfer fees", - total_wire_in: "Total incoming wire transfers", - total_wire_out: "Total outgoing wire transfers", - wire_debit_duplicate_transfer_subject_total: - "Outgoing transfers with duplicate transfer subjects", +const balanceLabels: Readonly<Record<string, () => string>> = { + purse_global_balance: () => i18n.str`Total balance held in purses`, + reserves_reserve_total_balance: () => + i18n.str`Total balance held in reserves`, + total_aml_hold: () => i18n.str`Total amount in held outgoing transfers`, + total_amount_lag: () => i18n.str`Deposits not settled by the wire deadline`, + total_bad_amount_in_minus: () => + i18n.str`Incoming transfers below exchange records`, + total_bad_amount_in_plus: () => + i18n.str`Incoming transfers above exchange records`, + total_bad_amount_out_minus: () => + i18n.str`Outgoing transfers below exchange records`, + total_bad_amount_out_plus: () => + i18n.str`Outgoing transfers above exchange records`, + total_balance_reserve_not_closed: () => + i18n.str`Total balance in expired reserves`, + total_closure_amount_lag: () => i18n.str`Overdue reserve closure transfers`, + total_drained: () => i18n.str`Profits drained from the exchange account`, + total_early_aggregation: () => + i18n.str`Deposits aggregated before eligibility`, + total_kycauth_in: () => + i18n.str`KYC authentication credits reported by the bank`, + total_kycauth_revenue: () => + i18n.str`KYC authentication revenue booked by the exchange`, + total_misattribution_in: () => + i18n.str`Incoming transfers credited to the wrong reserve`, + total_missed_deposit_confirmations: () => + i18n.str`Deposits missing merchant confirmation`, + total_small_aggregate: () => + i18n.str`Total held below the wire-fee threshold`, + total_transfer_lag: () => + i18n.str`Aggregated transfers withheld without a reason`, + total_wire_credit_fees: () => i18n.str`Incoming wire transfer fees`, + total_wire_in: () => i18n.str`Total incoming wire transfers`, + total_wire_out: () => i18n.str`Total outgoing wire transfers`, + wire_debit_duplicate_transfer_subject_total: () => + i18n.str`Outgoing transfers with duplicate transfer subjects`, }; export function balanceGroupId(key: string): string { @@ -95,7 +126,7 @@ export function balanceGroupId(key: string): string { export function balanceName(key: string): string { const known = balanceLabels[key]; - if (known) return known; + if (known) return known(); const words = key.replaceAll("_", " ").replaceAll("-", " "); return (words.charAt(0).toUpperCase() + words.slice(1)) .replaceAll(" aml ", " AML ") @@ -120,8 +151,12 @@ function BalanceTable({ <table class="auditor-table"> <thead> <tr> - <th>Balance</th> - <th class="text-right">Value</th> + <th> + <i18n.Translate>Balance</i18n.Translate> + </th> + <th class="text-right"> + <i18n.Translate>Value</i18n.Translate> + </th> </tr> </thead> <tbody> @@ -179,8 +214,12 @@ export function ListPage(data: any): VNode { groupedBalances.push({ group: { id: "other", - title: "Other balances", - description: "Additional accounting totals reported by the auditor.", + get title() { + return i18n.str`Other balances`; + }, + get description() { + return i18n.str`Additional accounting totals reported by the auditor.`; + }, matches: () => false, }, balances: otherBalances, @@ -191,7 +230,9 @@ export function ListPage(data: any): VNode { <div class="space-y-8"> <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> - <h2 class="text-lg font-bold">Financial integrity checks</h2> + <h2 class="text-lg font-bold"> + <i18n.Translate>Financial integrity checks</i18n.Translate> + </h2> <p class="text-sm text-gray-500"> <i18n.Translate> Only active, unsuppressed differences between exchange, auditor @@ -208,8 +249,12 @@ export function ListPage(data: any): VNode { <table class="auditor-table"> <thead> <tr> - <th>Finding</th> - <th class="text-right">Count</th> + <th> + <i18n.Translate>Finding</i18n.Translate> + </th> + <th class="text-right"> + <i18n.Translate>Count</i18n.Translate> + </th> </tr> </thead> <tbody> @@ -239,10 +284,15 @@ export function ListPage(data: any): VNode { </div> <section class="space-y-4"> <header> - <h2 class="text-xl font-bold text-gray-900">Auditor balances</h2> + <h2 class="text-xl font-bold text-gray-900"> + <i18n.Translate>Auditor balances</i18n.Translate> + </h2> <p class="mt-1 max-w-4xl text-sm leading-6 text-gray-600"> - All accounting totals reported by the auditor are shown below. New - balance keys appear automatically instead of being silently omitted. + <i18n.Translate> + All accounting totals reported by the auditor are shown below. New + balance keys appear automatically instead of being silently + omitted. + </i18n.Translate> </p> <p class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-gray-600"> <span class="inline-flex items-center gap-2"> diff --git a/packages/taler-auditor-webui/src/paths/notfound/index.tsx b/packages/taler-auditor-webui/src/paths/notfound/index.tsx @@ -19,18 +19,20 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { Link } from "wouter-preact"; export default function NotFoundPage(): VNode { + const { i18n } = useTranslationContext(); return ( <div class="px-6 pb-12 pt-28 text-center"> <div class="auditor-card mx-auto max-w-lg p-10"> <p class="text-xl font-bold text-gray-900"> - That page doesn't exist. + <i18n.Translate>That page doesn't exist.</i18n.Translate> </p> <Link href="/" class="auditor-button auditor-button-primary mt-6"> - Back to home + <i18n.Translate>Back to home</i18n.Translate> </Link> </div> </div> diff --git a/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx b/packages/taler-auditor-webui/src/paths/operations/ListPage.tsx @@ -19,9 +19,13 @@ export function ListPage(data: any): VNode { return ( <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> - <h2 class="text-lg font-bold">Operational findings</h2> + <h2 class="text-lg font-bold"> + <i18n.Translate>Operational findings</i18n.Translate> + </h2> <p class="text-sm text-gray-500"> - Processing and data-quality diagnostics + <i18n.Translate> + Processing and data-quality diagnostics + </i18n.Translate> </p> <p class="mt-3 inline-flex items-center gap-2 text-xs text-gray-600"> <span class="h-3 w-3 rounded-sm border border-amber-200 bg-amber-50" /> @@ -34,8 +38,12 @@ export function ListPage(data: any): VNode { <table class="auditor-table"> <thead> <tr> - <th>Finding</th> - <th class="text-right">Count</th> + <th> + <i18n.Translate>Finding</i18n.Translate> + </th> + <th class="text-right"> + <i18n.Translate>Count</i18n.Translate> + </th> </tr> </thead> <tbody> diff --git a/packages/taler-auditor-webui/src/paths/security/ListPage.tsx b/packages/taler-auditor-webui/src/paths/security/ListPage.tsx @@ -19,9 +19,13 @@ export function ListPage(data: any): VNode { return ( <div class="auditor-card overflow-hidden"> <div class="border-b border-gray-200 px-5 py-4"> - <h2 class="text-lg font-bold">Critical findings</h2> + <h2 class="text-lg font-bold"> + <i18n.Translate>Critical findings</i18n.Translate> + </h2> <p class="text-sm text-gray-500"> - Security-sensitive conditions requiring attention + <i18n.Translate> + Security-sensitive conditions requiring attention + </i18n.Translate> </p> <p class="mt-3 inline-flex items-center gap-2 text-xs text-gray-600"> <span class="h-3 w-3 rounded-sm border border-red-200 bg-red-50" /> @@ -34,8 +38,12 @@ export function ListPage(data: any): VNode { <table class="auditor-table"> <thead> <tr> - <th>Finding</th> - <th class="text-right">Count</th> + <th> + <i18n.Translate>Finding</i18n.Translate> + </th> + <th class="text-right"> + <i18n.Translate>Count</i18n.Translate> + </th> </tr> </thead> <tbody> diff --git a/packages/taler-auditor-webui/src/paths/settings/index.tsx b/packages/taler-auditor-webui/src/paths/settings/index.tsx @@ -15,7 +15,7 @@ function browserLanguage(): string | undefined { export function Settings(): VNode { const { i18n } = useTranslationContext(); const { update } = useLang(undefined, {}); - const detected = browserLanguage()?.slice(0, 2); + const detected = browserLanguage(); return ( <section class="px-4 pb-8 pt-24 md:px-8"> <div class="auditor-card max-w-2xl p-6"> @@ -23,7 +23,9 @@ export function Settings(): VNode { <i18n.Translate>Language</i18n.Translate> </h2> <p class="mt-1 text-sm text-gray-500"> - Choose the language used by this browser. + <i18n.Translate> + Choose the language used by this browser. + </i18n.Translate> </p> <div class="mt-5 flex flex-wrap items-center gap-3"> <LangSelector /> @@ -33,6 +35,8 @@ export function Settings(): VNode { class="auditor-button" onClick={() => update(detected)} > + {/* Translators: Restores the language detected from the browser, + rather than choosing a fixed application language. */} <i18n.Translate>Set default</i18n.Translate> </button> )} diff --git a/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts b/packages/taler-auditor-webui/src/routing/monitoringRoutes.ts @@ -3,6 +3,8 @@ (C) 2021-2026 Taler Systems S.A. */ +import { i18n } from "@gnu-taler/taler-util"; + export enum Paths { error = "/error", settings = "/settings", @@ -16,27 +18,39 @@ export enum Paths { export const monitoringSections = [ { id: "financial-integrity", - title: "Financial integrity", - description: - "Differences between the exchange, auditor and bank records that can affect balances or settlement amounts.", + get title() { + return i18n.str`Financial integrity`; + }, + get description() { + return i18n.str`Differences between the exchange, auditor and bank records that can affect balances or settlement amounts.`; + }, }, { id: "critical-risks", - title: "Critical risks", - description: - "Conditions that indicate possible losses, invalid authorizations or emergency over-issuance.", + get title() { + return i18n.str`Critical risks`; + }, + get description() { + return i18n.str`Conditions that indicate possible losses, invalid authorizations or emergency over-issuance.`; + }, }, { id: "operations", - title: "Operations and settlement", - description: - "Delayed, incomplete or structurally inconsistent processing that requires operational review.", + get title() { + return i18n.str`Operations and settlement`; + }, + get description() { + return i18n.str`Delayed, incomplete or structurally inconsistent processing that requires operational review.`; + }, }, { id: "state-and-history", - title: "State and history", - description: - "Current balances and objects, auditor progress, deposit evidence and finalized revenue history.", + get title() { + return i18n.str`State and history`; + }, + get description() { + return i18n.str`Current balances and objects, auditor progress, deposit evidence and finalized revenue history.`; + }, }, ] as const; @@ -58,9 +72,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "amount-arithmetic-inconsistencies", path: "/amount-arithmetic-inconsistencies", - title: "Amount arithmetic inconsistencies", - description: - "Operations for which the exchange and auditor calculated different amounts.", + get title() { + return i18n.str`Amount arithmetic inconsistencies`; + }, + get description() { + return i18n.str`Operations for which the exchange and auditor calculated different amounts.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -68,9 +85,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "coin-inconsistencies", path: "/coin-inconsistencies", - title: "Coin inconsistencies", - description: - "Coin operations for which the exchange and auditor calculated different totals.", + get title() { + return i18n.str`Coin inconsistencies`; + }, + get description() { + return i18n.str`Coin operations for which the exchange and auditor calculated different totals.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -78,9 +98,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "kycauth-in-inconsistencies", path: "/kycauth-in-inconsistencies", - title: "KYC authentication credit inconsistencies", - description: - "KYC authentication credits for which the bank and exchange disagree about the amount, account or existence of the transfer.", + get title() { + return i18n.str`KYC authentication credit inconsistencies`; + }, + get description() { + return i18n.str`KYC authentication credits for which the bank and exchange disagree about the amount, account or existence of the transfer.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -88,9 +111,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "misattribution-in-inconsistencies", path: "/misattribution-in-inconsistencies", - title: "Misattributed incoming transfers", - description: - "Incoming bank transfers that were credited to the wrong reserve account.", + get title() { + return i18n.str`Misattributed incoming transfers`; + }, + get description() { + return i18n.str`Incoming bank transfers that were credited to the wrong reserve account.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -98,9 +124,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "reserve-balance-summary-wrong-inconsistencies", path: "/reserve-balance-summary-wrong-inconsistencies", - title: "Wrong reserve balance summaries", - description: - "Reserve summaries for which the exchange and auditor calculated different balances.", + get title() { + return i18n.str`Wrong reserve balance summaries`; + }, + get description() { + return i18n.str`Reserve summaries for which the exchange and auditor calculated different balances.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -108,9 +137,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "reserve-in-inconsistencies", path: "/reserve-in-inconsistencies", - title: "Incoming reserve transfer inconsistencies", - description: - "Incoming reserve transfers for which the bank and exchange records disagree.", + get title() { + return i18n.str`Incoming reserve transfer inconsistencies`; + }, + get description() { + return i18n.str`Incoming reserve transfers for which the bank and exchange records disagree.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -118,9 +150,13 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "wire-format-inconsistencies", path: "/wire-format-inconsistencies", - title: "Wire format inconsistencies", - description: - "Malformed or duplicate bank transfer data reported through the bank revenue API.", + get title() { + // Translators: "Wire" means bank transfer data, not an electrical cable. + return i18n.str`Wire format inconsistencies`; + }, + get description() { + return i18n.str`Malformed or duplicate bank transfer data reported through the bank revenue API.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -128,9 +164,13 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "wire-out-inconsistencies", path: "/wire-out-inconsistencies", - title: "Outgoing wire inconsistencies", - description: - "Outgoing transfers whose destination or amount differs from what the auditor expected.", + get title() { + // Translators: "Wire" means bank transfer, not an electrical cable. + return i18n.str`Outgoing wire inconsistencies`; + }, + get description() { + return i18n.str`Outgoing transfers whose destination or amount differs from what the auditor expected.`; + }, section: "financial-integrity", dashboard: "finance", suppressible: true, @@ -138,9 +178,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "bad-sig-losses", path: "/bad-sig-losses", - title: "Invalid signature losses", - description: - "Losses from exchange operations that were performed despite an invalid signature.", + get title() { + return i18n.str`Invalid signature losses`; + }, + get description() { + return i18n.str`Losses from exchange operations that were performed despite an invalid signature.`; + }, section: "critical-risks", dashboard: "critical", suppressible: true, @@ -148,9 +191,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "emergencies", path: "/emergencies", - title: "Denomination emergencies by amount", - description: - "Denominations whose redeemed value exceeds issuance, including realized loss and maximum risk.", + get title() { + return i18n.str`Denomination emergencies by amount`; + }, + get description() { + return i18n.str`Denominations whose redeemed value exceeds issuance, including realized loss and maximum risk.`; + }, section: "critical-risks", dashboard: "critical", suppressible: true, @@ -158,9 +204,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "emergencies-by-count", path: "/emergencies-by-count", - title: "Denomination emergencies by coin count", - description: - "Denominations for which more coins were redeemed than the exchange officially issued.", + get title() { + return i18n.str`Denomination emergencies by coin count`; + }, + get description() { + return i18n.str`Denominations for which more coins were redeemed than the exchange officially issued.`; + }, section: "critical-risks", dashboard: "critical", suppressible: true, @@ -168,9 +217,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "reserve-balance-insufficient-inconsistencies", path: "/reserve-balance-insufficient-inconsistencies", - title: "Insufficient reserve balances", - description: - "Reserve operations that exceeded the balance calculated by the auditor, showing the possible loss or gain.", + get title() { + return i18n.str`Insufficient reserve balances`; + }, + get description() { + return i18n.str`Reserve operations that exceeded the balance calculated by the auditor, showing the possible loss or gain.`; + }, section: "critical-risks", dashboard: "critical", suppressible: true, @@ -178,9 +230,13 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "aml-holds", path: "/aml-holds", - title: "Held outgoing transfers", - description: - "Wire transfers the exchange aggregated but has not executed, including KYC and below-fee deferrals.", + get title() { + // Translators: "Held" means retained/not yet executed, not completed. + return i18n.str`Held outgoing transfers`; + }, + get description() { + return i18n.str`Wire transfers the exchange aggregated but has not executed, including KYC and below-fee deferrals.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -188,9 +244,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "closure-lags", path: "/closure-lags", - title: "Reserve closure delays", - description: - "Expired reserves whose remaining funds were not transferred by the closure deadline.", + get title() { + return i18n.str`Reserve closure delays`; + }, + get description() { + return i18n.str`Expired reserves whose remaining funds were not transferred by the closure deadline.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -198,9 +257,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "denomination-key-validity-withdraw-inconsistencies", path: "/denomination-key-validity-withdraw-inconsistencies", - title: "Withdrawals with invalid denomination keys", - description: - "Withdrawals that used denomination keys outside their valid withdrawal period.", + get title() { + return i18n.str`Withdrawals with invalid denomination keys`; + }, + get description() { + return i18n.str`Withdrawals that used denomination keys outside their valid withdrawal period.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -208,9 +270,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "denominations-without-sigs", path: "/denominations-without-sig", - title: "Denominations without auditor signatures", - description: - "Exchange denomination keys that are missing the auditor's signature.", + get title() { + return i18n.str`Denominations without auditor signatures`; + }, + get description() { + return i18n.str`Exchange denomination keys that are missing the auditor's signature.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -218,9 +283,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "early-aggregations", path: "/early-aggregations", - title: "Early deposit aggregations", - description: - "Deposit batches the exchange aggregated before they were eligible, without a justification.", + get title() { + return i18n.str`Early deposit aggregations`; + }, + get description() { + return i18n.str`Deposit batches the exchange aggregated before they were eligible, without a justification.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -228,9 +296,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "fee-time-inconsistencies", path: "/fee-time-inconsistencies", - title: "Wire fee time inconsistencies", - description: - "Wire fee schedules with inconsistent validity periods for a wire method.", + get title() { + return i18n.str`Wire fee time inconsistencies`; + }, + get description() { + return i18n.str`Wire fee schedules with inconsistent validity periods for a wire method.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -238,9 +309,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "pending-deposits", path: "/pending-deposits", - title: "Pending deposit transfers", - description: - "Deposits still awaiting settlement, including their amount, target account hash and wire deadline.", + get title() { + return i18n.str`Pending deposit transfers`; + }, + get description() { + return i18n.str`Deposits still awaiting settlement, including their amount, target account hash and wire deadline.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -248,9 +322,14 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "purse-not-closed-inconsistencies", path: "/purse-not-closed-inconsistencies", - title: "Expired purses not closed", - description: - "Expired purses whose remaining balance should have been refunded.", + get title() { + // Translators: A Taler "purse" is a protocol object holding digital + // coins temporarily, not a handbag. + return i18n.str`Expired purses not closed`; + }, + get description() { + return i18n.str`Expired purses whose remaining balance should have been refunded.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -258,8 +337,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "reserve-not-closed-inconsistencies", path: "/reserve-not-closed-inconsistencies", - title: "Expired reserves not closed", - description: "Expired reserves that still carry a balance.", + get title() { + return i18n.str`Expired reserves not closed`; + }, + get description() { + return i18n.str`Expired reserves that still carry a balance.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -267,9 +350,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "row-inconsistencies", path: "/row-inconsistencies", - title: "Exchange row inconsistencies", - description: - "Exchange database rows with a serious structural or semantic problem identified by the auditor.", + get title() { + return i18n.str`Exchange row inconsistencies`; + }, + get description() { + return i18n.str`Exchange database rows with a serious structural or semantic problem identified by the auditor.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -277,9 +363,12 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "row-minor-inconsistencies", path: "/row-minor-inconsistencies", - title: "Minor exchange row inconsistencies", - description: - "Exchange database rows with lower-severity discrepancies that still warrant review.", + get title() { + return i18n.str`Minor exchange row inconsistencies`; + }, + get description() { + return i18n.str`Exchange database rows with lower-severity discrepancies that still warrant review.`; + }, section: "operations", dashboard: "operations", suppressible: true, @@ -287,18 +376,24 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "balances", path: "/balance", - title: "Auditor balances", - description: - "Auditor-wide totals for holdings, revenue, fees, losses and arithmetic differences.", + get title() { + return i18n.str`Auditor balances`; + }, + get description() { + return i18n.str`Auditor-wide totals for holdings, revenue, fees, losses and arithmetic differences.`; + }, section: "state-and-history", suppressible: false, }, { endpoint: "deposit-confirmations", path: "/deposit-confirmations", - title: "Deposit confirmations", - description: - "Exchange-signed deposit confirmations submitted by merchants for independent verification.", + get title() { + return i18n.str`Deposit confirmations`; + }, + get description() { + return i18n.str`Exchange-signed deposit confirmations submitted by merchants for independent verification.`; + }, section: "state-and-history", suppressible: true, rowIdField: "deposit_confirmation_serial_id", @@ -306,44 +401,60 @@ export const monitoringReports: readonly MonitoringReport[] = [ { endpoint: "historic-denomination-revenues", path: "/historic-denomination-revenues", - title: "Historic denomination revenue", - description: - "Fee revenue and losses finalized when a denomination expired.", + get title() { + return i18n.str`Historic denomination revenue`; + }, + get description() { + return i18n.str`Fee revenue and losses finalized when a denomination expired.`; + }, section: "state-and-history", suppressible: false, }, { endpoint: "historic-reserve-summaries", path: "/historic-reserve-summaries", - title: "Historic reserve summaries", - description: "Reserve profits summarized over completed reporting periods.", + get title() { + return i18n.str`Historic reserve summaries`; + }, + get description() { + return i18n.str`Reserve profits summarized over completed reporting periods.`; + }, section: "state-and-history", suppressible: false, }, { endpoint: "progress", path: "/progress", - title: "Auditor processing progress", - description: - "Offsets showing how much exchange data each auditor helper has processed.", + get title() { + return i18n.str`Auditor processing progress`; + }, + get description() { + return i18n.str`Offsets showing how much exchange data each auditor helper has processed.`; + }, section: "state-and-history", suppressible: false, }, { endpoint: "purses", path: "/purses", - title: "Current purses", - description: - "Current purse balances, target amounts and expiration dates recorded by the auditor.", + get title() { + return i18n.str`Current purses`; + }, + get description() { + return i18n.str`Current purse balances, target amounts and expiration dates recorded by the auditor.`; + }, section: "state-and-history", suppressible: false, }, { endpoint: "reserves", path: "/reserves", - title: "Current reserves", - description: - "Current reserve balances, fee revenue, losses, expiration dates and originating accounts.", + get title() { + return i18n.str`Current reserves`; + }, + get description() { + return i18n.str`Current reserve balances, fee revenue, losses, expiration dates and originating accounts.`; + }, section: "state-and-history", suppressible: false, }, diff --git a/packages/taler-exchange-aml-webui/package.json b/packages/taler-exchange-aml-webui/package.json @@ -46,6 +46,7 @@ }, "pogen": { "domain": "aml-backoffice", - "minimumCoverage": 55 + "minimumCoverage": 55, + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/taler-exchange-aml-webui/src/ExchangeAmlFrame.tsx b/packages/taler-exchange-aml-webui/src/ExchangeAmlFrame.tsx @@ -58,6 +58,10 @@ export function ExchangeAmlFrame({ }; }, [hasModal]); + useEffect(() => { + document.title = i18n.str`Exchange AML`; + }, [i18n]); + const failed = useRenderErrorReport({ hash: __GIT_HASH__, version: __VERSION__, @@ -67,7 +71,7 @@ export function ExchangeAmlFrame({ <div class="aml-app min-h-screen flex flex-col m-0 bg-background text-onBackground dark:bg-darkBackground dark:text-darkOnBackground"> <div class="bg-primary dark:bg-darkPrimaryContainer"> <Header - title="Exchange AML" + title={i18n.str`Exchange AML`} logoSrc={talerLogoUrl} iconLinkURL="#/dashboard" onLogout={undefined} diff --git a/packages/taler-exchange-kyc-webui/package.json b/packages/taler-exchange-kyc-webui/package.json @@ -32,7 +32,8 @@ "typescript": "7.0.2" }, "pogen": { - "domain": "kyc-ui" + "domain": "kyc-ui", + "requiredLanguages": ["de", "de-CH", "fr", "it"] }, "dependencies": { "@gnu-taler/taler-util": "workspace:*", diff --git a/packages/taler-exchange-kyc-webui/src/Routing.tsx b/packages/taler-exchange-kyc-webui/src/Routing.tsx @@ -19,6 +19,7 @@ import { urlPattern, useCurrentLocation, useNavigationContext, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -63,6 +64,7 @@ const publicPages = { }; function PublicRounting(): VNode { + const { i18n } = useTranslationContext(); const location = useCurrentLocation(publicPages); const { state, start } = useSessionState(); const { navigateTo } = useNavigationContext(); @@ -74,7 +76,11 @@ function PublicRounting(): VNode { } case "start": { if (!currentToken) { - return <div>No access token</div>; + return ( + <div> + <i18n.Translate>No access token was provided.</i18n.Translate> + </div> + ); } return <Start token={currentToken} />; diff --git a/packages/taler-exchange-kyc-webui/src/pages/ChallengeCompleted.tsx b/packages/taler-exchange-kyc-webui/src/pages/ChallengeCompleted.tsx @@ -21,7 +21,7 @@ export function ChallengeCompleted(): VNode { return ( <div class="m-4"> - <Attention title={i18n.str`Kyc completed`} type="success"> + <Attention title={i18n.str`KYC completed`} type="success"> <i18n.Translate>You can close this window now.</i18n.Translate> </Attention> </div> diff --git a/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx b/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx @@ -150,7 +150,7 @@ function ShowForm({ case HttpStatusCode.NotFound: return i18n.str`The account was not found`; case HttpStatusCode.Conflict: - return i18n.str`Officer disabled or more recent decision was already submitted.`; + return i18n.str`The AML officer session is disabled, or a more recent decision has already been submitted.`; default: assertUnreachable(fail); } diff --git a/packages/taler-exchange-kyc-webui/src/pages/Frame.tsx b/packages/taler-exchange-kyc-webui/src/pages/Frame.tsx @@ -60,6 +60,10 @@ export function Frame({ } }); }, [notifier]); + + useEffect(() => { + document.title = title ?? i18n.str`Customer identification`; + }, [i18n, title]); return ( <div class="min-h-full flex flex-col m-0 bg-slate-200" @@ -73,8 +77,8 @@ export function Frame({ !preferences.showDebugInfo || !routeTestKyc || !routeTestForms ? [] : [ - ["Test kyc", routeTestKyc.url({})], - ["Test Forms", routeTestForms.url({})], + [i18n.str`Test KYC`, routeTestKyc.url({})], + [i18n.str`Test forms`, routeTestForms.url({})], ] } > diff --git a/packages/taler-exchange-kyc-webui/src/pages/Start.tsx b/packages/taler-exchange-kyc-webui/src/pages/Start.tsx @@ -64,13 +64,29 @@ function ShowReqList({ if (result.type === "fail") { switch (result.case) { case HttpStatusCode.NotModified: { - return <div> not modified </div>; + return ( + <div> + <i18n.Translate>No changes were made.</i18n.Translate> + </div> + ); } case HttpStatusCode.NoContent: { - return <div> not requirements </div>; + return ( + <div> + <i18n.Translate> + No identification requirements were returned. + </i18n.Translate> + </div> + ); } case HttpStatusCode.Accepted: { - return <div> accepted </div>; + return ( + <div> + <i18n.Translate> + The request was accepted and is still being processed. + </i18n.Translate> + </div> + ); } default: { assertUnreachable(result); @@ -200,11 +216,11 @@ function LinkGenerator({ req }: { req: KycRequirementInformation }): VNode { setLoading({ state: LinkGenerationState.ERROR }); switch (fail.case) { case HttpStatusCode.NotFound: - return i18n.str`not found`; + return i18n.str`The KYC request was not found.`; case HttpStatusCode.Conflict: - return i18n.str`conflict`; + return i18n.str`The KYC request conflicts with the current account state.`; case HttpStatusCode.PayloadTooLarge: - return i18n.str`payload is too large`; + return i18n.str`The KYC request is too large.`; default: assertUnreachable(fail.case); } diff --git a/packages/taler-exchange-kyc-webui/src/pages/TriggerForms.tsx b/packages/taler-exchange-kyc-webui/src/pages/TriggerForms.tsx @@ -39,7 +39,9 @@ export function TriggerForms({ formId }: Props): VNode { const theForm: FormMetadata = { id: "asd", version: 1, - label: i18n.str`Trigger KYC balance`, + // Translators: Developer-tool form for opening one of the configured KYC + // forms directly, without running the normal KYC workflow. + label: i18n.str`Open a KYC test form`, config: { type: "single-column", fields: [ @@ -47,7 +49,8 @@ export function TriggerForms({ formId }: Props): VNode { id: "form" as UIHandlerId, type: "selectOne", label: i18n.str`Form`, - help: i18n.str`You can also use the formId in the UR after "/test/show-forms/$FORM_ID"`, + // Translators: Keep formId, $FORM_ID, and the URL path unchanged. + help: i18n.str`You can also put the formId in the URL after "/test/show-forms/$FORM_ID".`, required: true, choices: pf.map((form) => { return { diff --git a/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx b/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx @@ -61,12 +61,16 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { const theForm: FormMetadata = { id: "asd", version: 1, - label: i18n.str`Trigger KYC balance`, + // Translators: Developer-tool form title. It simulates a wallet balance + // crossing a threshold so that the Exchange starts a KYC process. + label: i18n.str`Trigger balance-based KYC`, config: { type: "double-column", sections: [ { - title: i18n.str`Trigger KYC Balance`, + // Translators: Developer-tool section title. "Balance" is the wallet + // balance used to trigger a KYC threshold, not an account summary. + title: i18n.str`KYC trigger balance`, fields: [ { id: "amount" as UIHandlerId, @@ -99,7 +103,6 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { return createNewWalletKycAccount(extraEntropy); }, [lib.exchange]); - // i18n.str`trigger kyc process`, const triggerKyc = async (_ct: CancellationToken, balance: AmountString) => { const account = await accountPromise; const limit: WalletKycRequest = { @@ -141,7 +144,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { onFail: showError(i18n.str`Failed to trigger a KYC event.`, (fail) => { switch (fail.case) { case HttpStatusCode.NoContent: - return i18n.str`No kyc configured.`; + return i18n.str`No KYC process is configured.`; case HttpStatusCode.Forbidden: return i18n.str`Forbidden.`; case HttpStatusCode.NotFound: @@ -158,6 +161,11 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { ? undefined : ([Amounts.stringify(status.result.amount)] as const); + // Translators: This text describes developer-only test actions. The numeric + // values encoded by the buttons cross configured wallet balance thresholds + // and select particular KYC test flows. + const thresholdExplanation = i18n.str`These actions simulate a wallet balance above the 1,000,000 threshold. Configure the Exchange to start the intended KYC flow.`; + return ( <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> @@ -182,11 +190,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { <div class="grid grid-cols-1 gap-x-8 gap-y-4 "> <p> - <i18n.Translate> - This actions will trigger wallet balance kyc above 1000000 - threshold, the exchange should be properly configured to trigger the - desired kyc flow. - </i18n.Translate> + {thresholdExplanation} </p> <div> <AsyncButton @@ -194,7 +198,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > - <i18n.Translate>Trigger TOPS Terms of service</i18n.Translate> + <i18n.Translate>Trigger TOPS terms of service</i18n.Translate> </AsyncButton> </div> <div> diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json @@ -44,6 +44,7 @@ "typescript": "6.0.3" }, "pogen": { - "domain": "taler-merchant-webui" + "domain": "taler-merchant-webui", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/taler-merchant-webui/src/App.tsx b/packages/taler-merchant-webui/src/App.tsx @@ -199,7 +199,7 @@ import { } from "./api/hooks.js"; export function AppContent(): VNode { - const { t } = useTranslation(); + const { t, lang } = useTranslation(); const [location, setLocation] = useLocation(); const searchParams = useQueryParams(); const hasToken = !!session.value.token; @@ -212,6 +212,10 @@ export function AppContent(): VNode { const needsAdminData = location.startsWith("/admin"); const bootstrap = useBootstrapInstance(!hasToken); + useEffect(() => { + document.title = t`Taler Merchant Portal`; + }, [lang, t]); + // Live SWR data hooks for layout, setup, and MFA handlers const { accounts: realAccounts, diff --git a/packages/taler-merchant-webui/src/context/translation.tsx b/packages/taler-merchant-webui/src/context/translation.tsx @@ -25,7 +25,7 @@ import { import { setupI18n } from "@gnu-taler/taler-util"; import { strings } from "../i18n/strings.js"; -export type LanguageCode = "en" | "de" | "fr" | "it"; +export type LanguageCode = "en" | "de" | "de-CH" | "fr" | "it"; /** * The translation function, as `useTranslation` provides it: usable both as a @@ -50,6 +50,7 @@ export interface LanguageOption { export const SUPPORTED_LANGUAGES: LanguageOption[] = [ { code: "en", name: "English", flag: "🇬🇧" }, { code: "de", name: "Deutsch", flag: "🇩🇪" }, + { code: "de-CH", name: "Deutsch (Schweiz)", flag: "🇨🇭" }, { code: "fr", name: "Français", flag: "🇫🇷" }, { code: "it", name: "Italiano", flag: "🇮🇹" }, ]; @@ -62,7 +63,7 @@ function detectInitialLanguage(): LanguageCode { } catch { // Storage can be unavailable in embedded or privacy-restricted contexts. } - if (saved && ["en", "de", "fr", "it"].includes(saved)) { + if (saved && ["en", "de", "de-CH", "fr", "it"].includes(saved)) { return saved as LanguageCode; } let browserLanguage = ""; @@ -71,7 +72,9 @@ function detectInitialLanguage(): LanguageCode { } catch { // Fall through to English when browser metadata is inaccessible. } - const browserLang = browserLanguage.substring(0, 2).toLowerCase(); + const normalizedBrowserLang = browserLanguage.replaceAll("_", "-"); + if (normalizedBrowserLang.toLowerCase() === "de-ch") return "de-CH"; + const browserLang = normalizedBrowserLang.substring(0, 2).toLowerCase(); if (["de", "fr", "it"].includes(browserLang)) { return browserLang as LanguageCode; } diff --git a/packages/taler-merchant-webui/src/i18n/catalog.test.ts b/packages/taler-merchant-webui/src/i18n/catalog.test.ts @@ -33,7 +33,7 @@ import assert from "node:assert"; import { readFileSync } from "node:fs"; import { MENU_GROUPS } from "../ui/menuStructure.js"; -const LANGS = ["de", "fr", "it"] as const; +const LANGS = ["de", "de-CH", "fr", "it"] as const; interface Entry { msgid: string; diff --git a/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx b/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx @@ -285,7 +285,7 @@ export function MoneyInScreen({ tone="waiting" icon="○" label={t`Payment service offline`} - title={t`This payment service did not answer. It will be tried again.`} + title={t`This payment service did not respond. We will try again.`} /> ); case "exchange-gateway-timeout": @@ -294,7 +294,7 @@ export function MoneyInScreen({ tone="waiting" icon="○" label={t`Payment service offline`} - title={t`This payment service took too long to answer. It will be tried again.`} + title={t`This payment service took too long to respond. We will try again.`} /> ); case "kyc-wire-impossible": @@ -303,7 +303,7 @@ export function MoneyInScreen({ tone="problem" icon="×" label={t`Transfer impossible`} - title={t`This account and this payment service have no way of moving money between them.`} + title={t`Money cannot be transferred between this account and this payment service.`} /> ); case "unsupported-account": diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx @@ -128,6 +128,12 @@ export function ReportsScreen({ onDeletePot, }: ReportsScreenProps = {}): VNode { const { t } = useTranslation(); + // Translators: “beverages_group” is a literal machine-readable identifier; + // translate only the “e.g.” prefix. + const groupIdentifierExample = t`e.g. beverages_group`; + // Translators: “breakfast_pot” is a literal machine-readable identifier; + // translate only the “e.g.” prefix. + const potIdentifierExample = t`e.g. breakfast_pot`; const [, setLocation] = useLocation(); const [activeTab, setActiveTab] = useState<"scheduled" | "groupings">( initialTab, @@ -834,7 +840,7 @@ export function ReportsScreen({ onInput={(e) => setGroupName((e.target as HTMLInputElement).value) } - placeholder="e.g. beverages_group" + placeholder={groupIdentifierExample} class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> </div> @@ -900,7 +906,7 @@ export function ReportsScreen({ required value={potName} onInput={(e) => setPotName((e.target as HTMLInputElement).value)} - placeholder="e.g. breakfast_pot" + placeholder={potIdentifierExample} class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" /> </div> diff --git a/packages/wallet-webui/package.json b/packages/wallet-webui/package.json @@ -63,6 +63,7 @@ "web-ext": "10.5.0" }, "pogen": { - "domain": "taler-wallet-webui" + "domain": "taler-wallet-webui", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/wallet-webui/src/routes/management-model.ts b/packages/wallet-webui/src/routes/management-model.ts @@ -143,7 +143,8 @@ export function exchangeUpdateStatusLabel(status: string): string { case "unavailable-update": return i18n.str`Update unavailable`; case "ready": - return i18n.str`Ready`; + // Translators: Describes an exchange whose information is ready for use. + return i18n.ctx("exchange update status")`Ready`; case "ready-update": return i18n.str`Update in progress`; case "outdated-update": diff --git a/packages/wallet-webui/src/routes/order-model.ts b/packages/wallet-webui/src/routes/order-model.ts @@ -34,7 +34,9 @@ function quantityFor(product: ProductSold): string | undefined { if (!quantity) return undefined; if (product.unit) return `${quantity} ${product.unit}`; const count = Number(quantity); + // Translators: %1$s is the formatted quantity shown before the product-count noun. if (!Number.isFinite(count)) return i18n.str`${quantity} items`; + // Translators: %1$s is the formatted quantity shown before the product-count noun. return i18n.plural( count, i18n.lazy`${quantity} item`, diff --git a/packages/wallet-webui/src/routes/transaction-model.ts b/packages/wallet-webui/src/routes/transaction-model.ts @@ -234,7 +234,8 @@ function minorLabels(): Partial<Record<TransactionMinorState, string>> { [TransactionMinorState.CompletedByOtherWallet]: i18n.str`Completed by another wallet`, [TransactionMinorState.ContinuedWithOtherWallet]: i18n.str`Continued in another wallet`, [TransactionMinorState.PaidByOther]: i18n.str`Paid with another wallet`, - [TransactionMinorState.Ready]: i18n.str`Ready`, + // Translators: Feminine where grammatical gender applies; this describes a transaction. + [TransactionMinorState.Ready]: i18n.ctx("transaction minor state")`Ready`, [TransactionMinorState.RebindSession]: i18n.str`Restoring merchant session`, [TransactionMinorState.SubmitPayment]: i18n.str`Submitting payment`, [TransactionMinorState.Refresh]: i18n.str`Refreshing digital cash`, diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx @@ -53,12 +53,12 @@ function ChoiceInstrument(props: { const instrument = props.instrument; const kind = instrument.kind === "discount" - ? // Compact noun label for a merchant-issued discount token. + ? // Translators: Compact noun label for a merchant-issued discount token. i18n.str`Discount` : instrument.kind === "subscription" - ? // Compact noun label for a reusable merchant-issued subscription token. + ? // Translators: Compact noun label for a reusable merchant-issued subscription token. i18n.str`Pass` - : // Compact noun label for a Donau tax or donation receipt. + : // Translators: Compact noun label for a Donau tax or donation receipt. i18n.str`Receipt`; return ( <li class="rounded-xl bg-surfaceVariant p-3"> @@ -456,6 +456,9 @@ export function PaymentScreen(props: { onCopyPosConfirmation?: (code: string) => void; }) { const [confirmUnclaim, setConfirmUnclaim] = useState(false); + // Translators: Payment subtitle; the placeholder is the merchant's display + // name. “to” expresses that the payment is being made to this merchant. + const merchantSubtitle = i18n.str`to ${props.merchantName ?? ""}`; return ( <div class="mx-auto max-w-2xl space-y-5"> <div class="flex items-center gap-4"> @@ -479,12 +482,7 @@ export function PaymentScreen(props: { {props.merchantName && props.state !== "ready" && props.state !== "paying" && ( - <p class="text-secondary"> - { - // Payment subtitle; the placeholder is the merchant's display name. - i18n.str`to ${props.merchantName}` - } - </p> + <p class="text-secondary">{merchantSubtitle}</p> )} </div> </div> diff --git a/packages/wallet-webui/test/language.test.tsx b/packages/wallet-webui/test/language.test.tsx @@ -29,6 +29,7 @@ class MemoryStorage implements LanguagePreferenceStorage { const testCatalogs = { de: {}, + "de-CH": {}, en: {}, "pt-BR": {}, }; @@ -36,8 +37,9 @@ const testCatalogs = { test("language preferences match available catalogues and fall back to English", () => { assert.equal( resolveLanguage(AUTOMATIC_LANGUAGE, ["de-CH"], testCatalogs), - "de", + "de-CH", ); + assert.equal(resolveLanguage("de_CH", ["en"], testCatalogs), "de-CH"); assert.equal( resolveLanguage(AUTOMATIC_LANGUAGE, ["fr-FR"], testCatalogs), "en", @@ -47,7 +49,7 @@ test("language preferences match available catalogues and fall back to English", languageOptions(testCatalogs) .map((option) => option.value) .sort(), - ["de", "en", "pt-BR"], + ["de", "de-CH", "en", "pt-BR"], ); }); diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -67,6 +67,7 @@ "tailwindcss": "3.4.17" }, "pogen": { - "domain": "web-util" + "domain": "web-util", + "requiredLanguages": ["de", "de-CH", "fr", "it"] } } diff --git a/packages/web-util/src/components/Footer.tsx b/packages/web-util/src/components/Footer.tsx @@ -17,6 +17,7 @@ export function Footer({ variant?: "default" | "demo" | "compact"; }) { const { i18n } = useTranslationContext(); + const copyrightLabel = i18n.str`Copyright`; const testingUrl = testingUrlKey && @@ -31,7 +32,7 @@ export function Footer({ target="_blank" rel="noreferrer noopener" > - Version {VERSION} ({GIT_HASH.substring(0, 8)}) + {i18n.str`Version ${VERSION} (${GIT_HASH.substring(0, 8)})`} </a> ) : ( VERSION @@ -85,7 +86,8 @@ export function Footer({ </a> </i18n.Translate> <span aria-hidden="true"> · </span> - Copyright © 2014—2026 Taler Systems SA. {versionText} + {copyrightLabel} © 2014—2026 Taler Systems SA.{" "} + {versionText} </p> {actions ? <div class="shrink-0">{actions}</div> : undefined} {testingNotice ? ( @@ -117,7 +119,8 @@ export function Footer({ </p> <div class="flex flex-wrap items-center gap-x-4 gap-y-2"> <p class="m-0"> - Copyright © 2014—2026 Taler Systems SA. {versionText} + {copyrightLabel} © 2014—2026 Taler Systems SA.{" "} + {versionText} </p> {actions ? <div class="shrink-0">{actions}</div> : undefined} </div> @@ -149,7 +152,7 @@ export function Footer({ </i18n.Translate> </p> <p class="text-xs leading-5 text-slate-700 dark:text-slate-200"> - Copyright © 2014—2026 Taler Systems SA.{" "} + {copyrightLabel} © 2014—2026 Taler Systems SA.{" "} {versionText}{" "} </p> </div> diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx @@ -38,6 +38,7 @@ const names: LangsNames = { fr: "Français [fr]", es: "Español [es]", de: "Deutsch [de]", + "de-CH": "Deutsch (Schweiz) [de-CH]", en: "English [en]", }; diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts @@ -24,6 +24,7 @@ import esLocale from "date-fns/locale/es/index.js"; import enLocale from "date-fns/locale/en-GB/index.js"; import frLocale from "date-fns/locale/fr/index.js"; import deLocale from "date-fns/locale/de/index.js"; +import itLocale from "date-fns/locale/it/index.js"; export type InternationalizationAPI = typeof i18n; @@ -41,8 +42,9 @@ const SUPPORTED_LANGS = { en: "English [en]", fr: "Français [fr]", de: "Deutsch [de]", + "de-CH": "Deutsch (Schweiz) [de-CH]", // sv: "Svenska [sv]", - // it: "Italiane [it]", + it: "Italiano [it]", }; const initial: Type = { @@ -58,10 +60,18 @@ const initial: Type = { en: 0, es: 0, fr: 0, + "de-CH": 0, + it: 0, }, }; const Context = createContext<Type>(initial); +function canonicalLanguageCode(language: string): string { + const normalized = language.replaceAll("_", "-"); + if (normalized.toLowerCase() === "de-ch") return "de-CH"; + return normalized.toLowerCase(); +} + interface Props { initial?: string; children: ComponentChildren; @@ -73,13 +83,25 @@ interface Props { function mergeTranslationSources( application: Record<string, StringsType>, ): Record<string, StringsType> { + const sharedByLanguage = Object.fromEntries( + Object.entries(webUtilStrings).map(([language, catalog]) => [ + canonicalLanguageCode(language), + catalog, + ]), + ); + const applicationByLanguage = Object.fromEntries( + Object.entries(application).map(([language, catalog]) => [ + canonicalLanguageCode(language), + catalog, + ]), + ); const result: Record<string, StringsType> = {}; for (const lang of new Set([ - ...Object.keys(webUtilStrings), - ...Object.keys(application), + ...Object.keys(sharedByLanguage), + ...Object.keys(applicationByLanguage), ])) { - const shared = webUtilStrings[lang]; - const host = application[lang]; + const shared = sharedByLanguage[lang]; + const host = applicationByLanguage[lang]; if (!shared) { result[lang] = host; continue; @@ -121,7 +143,10 @@ export const TranslationProvider = ({ source, }: Props): VNode => { const mergedSource = useMemo(() => mergeTranslationSources(source), [source]); - const availableLanguages = new Set(["en", ...Object.keys(source)]); + const availableLanguages = new Set([ + "en", + ...Object.keys(source).map(canonicalLanguageCode), + ]); const supportedLang = Object.fromEntries( Object.entries(SUPPORTED_LANGS).filter(([lang]) => availableLanguages.has(lang), @@ -153,7 +178,7 @@ export const TranslationProvider = ({ changeLanguageRef.current(forceLang); } }, [forceLang]); - const effectiveLang = forceLang ?? lang; + const effectiveLang = canonicalLanguageCode(forceLang ?? lang); setupI18n(effectiveLang, mergedSource); const dateLocale = @@ -161,9 +186,11 @@ export const TranslationProvider = ({ ? esLocale : effectiveLang === "fr" ? frLocale - : effectiveLang === "de" + : effectiveLang === "de" || effectiveLang === "de-CH" ? deLocale - : enLocale; + : effectiveLang === "it" + ? itLocale + : enLocale; return h(Context.Provider, { value: { diff --git a/packages/web-util/src/forms/AcceptTosForm.tsx b/packages/web-util/src/forms/AcceptTosForm.tsx @@ -36,6 +36,10 @@ export function AcceptTosOfficerView({ const downloaded = data[TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE]; const providerName = design.type === "accept-tos" ? design.providerName?.trim() : undefined; + // Translators: Status shown when no terms-acceptance value was stored. + const notRecordedLabel = i18n.str`Not recorded`; + // Translators: Heading for the organization that supplied the terms. + const providerLabel = i18n.str`Provider`; return ( <dl class="grid gap-4 sm:grid-cols-2"> @@ -44,7 +48,7 @@ export function AcceptTosOfficerView({ <i18n.Translate>Acceptance status</i18n.Translate> </dt> <dd class="mt-1 text-sm text-gray-700 dark:text-gray-300"> - {acceptedVersion ? i18n.str`Accepted` : i18n.str`Not recorded`} + {acceptedVersion ? i18n.str`Accepted` : notRecordedLabel} </dd> </div> <div> @@ -52,7 +56,7 @@ export function AcceptTosOfficerView({ <i18n.Translate>Accepted terms version</i18n.Translate> </dt> <dd class="mt-1 break-words font-mono text-sm text-gray-700 dark:text-gray-300"> - {acceptedVersion ?? i18n.str`Not recorded`} + {acceptedVersion ?? notRecordedLabel} </dd> </div> <div> @@ -64,13 +68,13 @@ export function AcceptTosOfficerView({ ? i18n.str`Yes` : downloaded === false ? i18n.str`No` - : i18n.str`Not recorded`} + : notRecordedLabel} </dd> </div> {!providerName ? undefined : ( <div> <dt class="text-sm font-semibold text-gray-900 dark:text-gray-100"> - <i18n.Translate>Provider</i18n.Translate> + {providerLabel} </dt> <dd class="mt-1 text-sm text-gray-700 dark:text-gray-300"> {providerName} @@ -470,7 +474,9 @@ export function AcceptTosForm(props: { : "cursor-not-allowed" }`} > - <span class="sr-only">Accept the terms of service</span> + <span class="sr-only"> + {i18n.str`Accept the terms of service`} + </span> <input id={checkboxId} type="checkbox" diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx @@ -51,7 +51,7 @@ export function Calendar({ const start = startOfWeek(startOfMonth(showingDate), { weekStartsOn: 1 }); const end = endOfWeek(endOfMonth(showingDate), { weekStartsOn: 1 }); const daysInMonth = eachDayOfInterval({ start, end }); - const { i18n } = useTranslationContext(); + const { i18n, dateLocale } = useTranslationContext(); const monthNames = [ i18n.str`January`, i18n.str`February`, @@ -176,13 +176,11 @@ export function Calendar({ </button> </div> <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500"> - <div>M</div> - <div>T</div> - <div>W</div> - <div>T</div> - <div>F</div> - <div>S</div> - <div>S</div> + {daysInMonth.slice(0, 7).map((day) => ( + <div key={day.getTime()}> + {format(day, "EEEEE", { locale: dateLocale })} + </div> + ))} </div> <div class="isolate mt-2"> <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200"> diff --git a/packages/web-util/src/forms/Dialog.tsx b/packages/web-util/src/forms/Dialog.tsx @@ -1,4 +1,5 @@ import { ComponentChildren, VNode, h } from "preact"; +import { useTranslationContext } from "../context/translation.js"; export function Dialog({ children, @@ -7,6 +8,7 @@ export function Dialog({ onClose?: () => void; children: ComponentChildren; }): VNode { + const { i18n } = useTranslationContext(); return ( <div class="relative z-10" @@ -16,7 +18,7 @@ export function Dialog({ > <button type="button" - aria-label="Close dialog" + aria-label={i18n.str`Close dialog`} class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} /> diff --git a/packages/web-util/src/forms/forms-ui.test.tsx b/packages/web-util/src/forms/forms-ui.test.tsx @@ -63,9 +63,9 @@ test("unsupported form languages show a persistent legal warning", async () => { const notice = view.getByRole("status"); assert.equal(notice.classList.contains("attention-warning"), true); - assert.match(notice.textContent, /not officially supported/i); - assert.match(notice.textContent, /may be incomplete/i); - assert.match(notice.textContent, /only the German text is legally binding/i); + assert.match(notice.textContent, /pas officiellement disponible/i); + assert.match(notice.textContent, /peut être incomplète/i); + assert.match(notice.textContent, /seul le texte en German fait foi/i); cleanup(); await window.happyDOM.abort(); diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -120,6 +120,7 @@ export function DefaultForm<T>({ design: FormDesign; initial: object; }): VNode { + const { i18n } = useTranslationContext(); const { model: handler, status } = useForm<T>(design, initial as any); const [shorten, setShorten] = useState(true); @@ -134,10 +135,12 @@ export function DefaultForm<T>({ checked={shorten} onChange={(e) => setShorten(!shorten)} />{" "} - Shorten file contents. + <i18n.Translate>Shorten file contents.</i18n.Translate> </label> - <p>Result JSON:</p> + <p> + <i18n.Translate>Result JSON:</i18n.Translate> + </p> <pre class="break-all whitespace-pre-wrap"> {JSON.stringify( shorten diff --git a/packages/web-util/src/forms/gana/multi_upload.ts b/packages/web-util/src/forms/gana/multi_upload.ts @@ -46,6 +46,8 @@ export const form_multi_upload = ( { id: "REQUESTED_FILE_ID", type: "text", + // Translators: A stable machine-readable identifier assigned to a + // requested document, distinct from its human-readable title. label: i18n.str`Document identifier`, required: true, }, @@ -64,6 +66,8 @@ export const form_multi_upload = ( { id: "REQUESTED_FILE_REQUIRED", type: "toggle", + // Translators: Toggle indicating that the requested document must + // be uploaded rather than being optional. label: i18n.str`Required`, }, ], diff --git a/packages/web-util/src/forms/gana/simplest.ts b/packages/web-util/src/forms/gana/simplest.ts @@ -44,23 +44,28 @@ export function resolutionSection( i18n: InternationalizationAPI, ): DoubleColumnFormSection { return { + // Translators: “Resolution” is the decision taken to resolve this case. title: i18n.str`Resolution`, fields: [ { type: "choiceHorizontal", id: "state" as UIHandlerId, + // Translators: The new status to assign to the account. label: i18n.str`New state`, choices: [ { value: "frozen", + // Translators: Account status in which transactions are blocked. label: i18n.str`Frozen`, }, { value: "pending", + // Translators: Account status awaiting a decision or action. label: i18n.str`Pending`, }, { value: "normal", + // Translators: Regular, unrestricted account status. label: i18n.str`Normal`, }, ], diff --git a/packages/web-util/src/hooks/errors.ts b/packages/web-util/src/hooks/errors.ts @@ -10,7 +10,7 @@ export function useRenderErrorReport(appInfo: { const { displayError } = useNotificationContext(); const [failed] = useErrorBoundary((error) => { if (error) { - const description = i18n.str`The process runtime thrown an Error which was unexpected and not properly handled. To report click the copy button and create an issue in https://bugs.taler.net.`; + const description = i18n.str`An unexpected error occurred and was not handled properly. To report it, copy the details and create an issue at https://bugs.taler.net.`; displayError( i18n.str`Render error.`, { diff --git a/packages/web-util/src/hooks/useLang.test.tsx b/packages/web-util/src/hooks/useLang.test.tsx @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { act } from "preact/test-utils"; +import { + TranslationProvider, + useTranslationContext, +} from "../context/translation.js"; +import type { StringsType } from "../i18n/strings.js"; + +function installDom(language: string): Window { + const window = new Window({ url: "https://language.example/" }); + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language, + }); + Object.defineProperty(window.navigator, "languages", { + configurable: true, + value: [language], + }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + StorageEvent: window.StorageEvent, + HTMLElement: window.HTMLElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +function catalog(lang: string): StringsType { + return { + domain: "messages", + lang, + completeness: 100, + plural_forms: "nplurals=2; plural=n != 1;", + locale_data: { + messages: { + "": { domain: "messages", lang }, + }, + }, + }; +} + +function Harness() { + const context = useTranslationContext(); + return ( + <div> + <output data-testid="language">{context.lang}</output> + <output data-testid="supported"> + {Object.keys(context.supportedLang).join(",")} + </output> + <button onClick={() => context.changeLanguage("de-CH")}>Swiss</button> + </div> + ); +} + +test("browser de-CH selects the regional catalogue without truncation", async () => { + const window = installDom("de-CH"); + const { cleanup, render } = await import("@testing-library/preact"); + try { + const view = render( + <TranslationProvider source={{ de_CH: catalog("de-CH") }}> + <Harness /> + </TranslationProvider>, + ); + assert.equal(view.getByTestId("language").textContent, "de-CH"); + assert.match(view.getByTestId("supported").textContent ?? "", /de-CH/); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); + +test("an explicit de-CH selection is persisted intact", async () => { + const window = installDom("en"); + const { cleanup, render } = await import("@testing-library/preact"); + try { + const view = render( + <TranslationProvider source={{ "de-CH": catalog("de-CH") }}> + <Harness /> + </TranslationProvider>, + ); + await act(() => view.getByText("Swiss").click()); + assert.equal(view.getByTestId("language").textContent, "de-CH"); + assert.match(window.localStorage.getItem("lang-preference") ?? "", /de-CH/); + } finally { + cleanup(); + await window.happyDOM.abort(); + } +}); diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts @@ -35,19 +35,29 @@ function getBrowserLang( ): string | undefined { if (typeof window === "undefined") return undefined; + const available = Object.keys(completeness); + const resolveAvailable = (requested: string): string | undefined => { + const normalized = requested.replaceAll("_", "-").toLowerCase(); + return ( + available.find((candidate) => candidate.toLowerCase() === normalized) ?? + available.find( + (candidate) => candidate.toLowerCase() === normalized.split("-", 1)[0], + ) + ); + }; + if (window.navigator.language) { - if ( - completeness[window.navigator.language] >= MIN_LANG_COVERAGE_THRESHOLD - ) { - return window.navigator.language; + const exact = resolveAvailable(window.navigator.language); + if (exact && completeness[exact] >= MIN_LANG_COVERAGE_THRESHOLD) { + return exact; } } if (window.navigator.languages) { const match = Object.entries(completeness) .filter(([code, value]) => { if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below the threshold - return ( - window.navigator.languages.findIndex((l) => l.startsWith(code)) !== -1 + return window.navigator.languages.some( + (requested) => resolveAvailable(requested) === code, ); }) .map(([code, value]) => ({ code, value })); @@ -72,10 +82,6 @@ export function useLang( initial: string | undefined, completeness: Record<string, number>, ): Required<StorageState> { - const defaultValue = ( - getBrowserLang(completeness) || - initial || - "en" - ).substring(0, 2); + const defaultValue = getBrowserLang(completeness) || initial || "en"; return useLocalStorage(langPreferenceKey, defaultValue); } diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -149,7 +149,7 @@ export function translateTalerError( cause.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT) ) { return [ - i18n.str`The request reached a timeout, check your connection.`, + i18n.str`The request timed out. Check your connection.`, cause.errorDetail.requestUrl ? i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed after ${(cause.errorDetail.timeoutMs ?? 0) / 1000} seconds.` : undefined, diff --git a/packages/web-util/src/i18n/catalog.test.ts b/packages/web-util/src/i18n/catalog.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +interface PoEntry { + id: string; + translations: string[]; + fuzzy: boolean; +} + +function parsePo(text: string): PoEntry[] { + const unquote = (value: string): string => JSON.parse(value) as string; + const entries: PoEntry[] = []; + for (const block of text.split(/\n{2,}/)) { + if (block.startsWith("#~")) continue; + const values = new Map<string, string>(); + let field: string | undefined; + let fuzzy = false; + for (const line of block.split("\n")) { + if (line.startsWith("#,")) { + fuzzy ||= line + .slice(2) + .split(",") + .some((flag) => flag.trim() === "fuzzy"); + continue; + } + const start = /^(msgid|msgstr(?:\[(\d+)\])?) (".*")$/.exec(line); + if (start) { + field = start[1]; + values.set(field, unquote(start[3])); + } else if (field && line.startsWith('"')) { + values.set(field, (values.get(field) ?? "") + unquote(line)); + } + } + const id = values.get("msgid"); + if (!id) continue; + entries.push({ + id, + translations: [...values] + .filter(([name]) => name === "msgstr" || name.startsWith("msgstr[")) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value), + fuzzy, + }); + } + return entries; +} + +const placeholders = (value: string): string[] => + (value.match(/%\d+\$s|%s|%%/g) ?? []).sort(); + +test("German, Swiss German, French and Italian catalogues are complete", () => { + const directory = new URL("../../src/i18n/", import.meta.url); + const template = parsePo( + readFileSync(new URL("web-util.pot", directory), "utf8"), + ); + const failures: string[] = []; + + for (const language of ["de", "de-CH", "fr", "it"]) { + const catalogue = new Map( + parsePo(readFileSync(new URL(`${language}.po`, directory), "utf8")).map( + (entry) => [entry.id, entry], + ), + ); + for (const source of template) { + const translated = catalogue.get(source.id); + if (!translated) { + failures.push(`${language}: missing ${JSON.stringify(source.id)}`); + continue; + } + if (translated.fuzzy) { + failures.push(`${language}: fuzzy ${JSON.stringify(source.id)}`); + } + if ( + translated.translations.length === 0 || + translated.translations.some((value) => value === "") + ) { + failures.push(`${language}: untranslated ${JSON.stringify(source.id)}`); + } + for (const value of translated.translations) { + if ( + placeholders(value).join("\u0000") !== + placeholders(source.id).join("\u0000") + ) { + failures.push( + `${language}: placeholder mismatch for ${JSON.stringify(source.id)}`, + ); + } + } + } + } + + assert.deepEqual(failures, [], failures.join("\n")); +}); diff --git a/packages/web-util/src/stories-utils.tsx b/packages/web-util/src/stories-utils.tsx @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { setupI18n } from "@gnu-taler/taler-util"; +import { i18n, setupI18n } from "@gnu-taler/taler-util"; import { ComponentChild, ComponentChildren, @@ -474,7 +474,9 @@ function getContentForExample( ): FunctionalComponent { if (!item) return function SelectExampleMessage() { - return <div>select example from the list on the left</div>; + return ( + <div>{i18n.str`Select an example from the list on the left.`}</div> + ); }; const example = findByGroupComponentName( allExamples, @@ -484,7 +486,7 @@ function getContentForExample( ); if (!example) { return function ExampleNotFoundMessage() { - return <div>example not found</div>; + return <div>{i18n.str`Example not found.`}</div>; }; } return () => example.render.component(example.render.props); @@ -631,20 +633,20 @@ function ErrorReport({ if (error) { return ( <div> - <p>Error was thrown trying to render</p> + <p>{i18n.str`An error occurred while rendering the example.`}</p> {selected && ( <ul> <li> - <b>group</b>: {selected.group} + <b>{i18n.str`Group`}</b>: {selected.group} </li> <li> - <b>component</b>: {selected.component} + <b>{i18n.str`Component`}</b>: {selected.component} </li> <li> - <b>example</b>: {selected.name} + <b>{i18n.str`Example`}</b>: {selected.name} </li> <li> - <b>args</b>:{" "} + <b>{i18n.str`Arguments`}</b>:{" "} <pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre> </li> </ul> @@ -941,9 +943,14 @@ function Application({ selectedComponent?.examples.findIndex( (dataset) => dataset.name === selected?.name, ) ?? -1; + // Translators: A dataset is one named set of example input values in the + // component story browser. + const datasetLabel = i18n.str`Dataset`; + // Translators: %1$s is the current dataset number and %2$s is the total. + const datasetPositionLabel = i18n.str`Dataset ${datasetIndex + 1} of ${datasetCount}`; const selectedLabel = selected ? `${selected.component} / ${selected.name}` - : "Select a story"; + : i18n.str`Select a story`; const selectStory = (item: ExampleItem): void => { const exampleId = getExampleId(item); history.pushState({}, "", `#${exampleId}`); @@ -960,7 +967,7 @@ function Application({ ref={menuButtonRef} type="button" class="taler-stories-menu-button" - aria-label="Open stories navigation" + aria-label={i18n.str`Open stories navigation`} aria-controls="taler-stories-sidebar" aria-expanded={navigationOpen} onClick={() => setNavigationOpen(true)} @@ -986,23 +993,23 @@ function Application({ class={`taler-stories-sidebar${navigationOpen ? " is-open" : ""}`} role={isMobile ? "dialog" : undefined} aria-modal={isMobile ? "true" : undefined} - aria-label="Stories navigation" + aria-label={i18n.str`Stories navigation`} aria-hidden={isMobile && !navigationOpen ? "true" : undefined} tabIndex={isMobile ? -1 : undefined} > <div class="taler-stories-sidebar-header"> - <h1 class="taler-stories-sidebar-title">Stories</h1> + <h1 class="taler-stories-sidebar-title">{i18n.str`Stories`}</h1> <button type="button" class="taler-stories-close-button" - aria-label="Close stories navigation" + aria-label={i18n.str`Close stories navigation`} onClick={() => setNavigationOpen(false)} > × </button> </div> <label class="taler-stories-language"> - <span>Language</span> + <span>{i18n.str`Language`}</span> <select value={currentLang} onChange={(e) => { @@ -1016,7 +1023,7 @@ function Application({ ))} </select> </label> - <nav class="taler-stories-navigation" aria-label="Stories"> + <nav class="taler-stories-navigation" aria-label={i18n.str`Stories`}> {examplesInGroups.map((group) => ( <ExampleList key={group.title} @@ -1046,7 +1053,7 @@ function Application({ <span class="taler-stories-story-dataset">{selected.name}</span> </h1> <label class="taler-stories-dataset-selector"> - <span>Dataset</span> + <span>{datasetLabel}</span> <span class="taler-stories-dataset-control"> <select value={selected.name} @@ -1068,7 +1075,7 @@ function Application({ <span class="taler-stories-dataset-position" aria-live="polite" - aria-label={`Dataset ${datasetIndex + 1} of ${datasetCount}`} + aria-label={datasetPositionLabel} > {datasetIndex + 1}/{datasetCount} </span> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@creativebulma/bulma-tooltip': specifier: ^1.2.0 version: 1.2.0 + '@gnu-taler/pogen': + specifier: workspace:* + version: link:../pogen '@types/chai': specifier: ^4.3.0 version: 4.3.3