From abc380d7e118fd127bbbc406692b2cf0f5ad03f9 Mon Sep 17 00:00:00 2001 From: Egor Poderiagin Date: Mon, 14 Sep 2026 10:19:21 +0700 Subject: [PATCH] refactor: change style of localization --- README.md | 21 ++- src/frontend/src/__tests__/i18n.test.js | 5 +- src/frontend/src/i18n.js | 22 ++- .../src/locales/__tests__/keys.test.js | 50 ++++++- src/frontend/src/locales/de.json | 122 +++++++++++------ src/frontend/src/locales/en.json | 122 +++++++++++------ src/frontend/src/locales/fr.json | 124 ++++++++++------- src/frontend/src/locales/hu.json | 122 +++++++++++------ src/frontend/src/locales/ru.json | 126 +++++++++++------- src/frontend/src/locales/th.json | 120 +++++++++++------ 10 files changed, 547 insertions(+), 287 deletions(-) diff --git a/README.md b/README.md index 918c12d..95fb4b5 100644 --- a/README.md +++ b/README.md @@ -420,17 +420,22 @@ and `specs/roadmap.md` tracks milestone progress. ## Translating -The interface text lives in `src/frontend/src/locales/`, one flat JSON file per +The interface text lives in `src/frontend/src/locales/`, one JSON file per language. `en.json` is the source: every other language is translated from it, and any string a language has not translated yet shows in English. +Keys are nested objects grouped by component (`settings` → `button` → `label`) +and looked up by their dotted path, `t('settings.button.label')`. Don't write a +flat `"settings.button.label"` key: Weblate writes the keys it adds nested, so a +file would end up mixing both shapes. `keys.test.js` fails on dotted keys. + Translations are done on [Hosted Weblate](https://hosted.weblate.org/). You don't need to open a pull request to translate. Weblate commits the changes and opens the pull request for you. To change the English wording or add a string, edit `en.json` in a normal pull request. Weblate picks the change up once it merges. A string that depends on a number gets one key per plural form, named the i18next -v4 way: `"sentence.symbolCount_one"` and `"sentence.symbolCount_other"` in +v4 way: `symbolCount_one` and `symbolCount_other` inside `sentence` in `en.json`. Render it with `tPlural(key, count)` from `src/frontend/src/i18n.js`, not `t`. The helper picks the form the active language needs, so on Weblate each language gets its own plural forms, such as `_few` and `_many` for Russian. @@ -443,16 +448,24 @@ Only maintainers need these, when creating or repairing the component: |---|---| | Version control system | GitHub pull request | | Source code repository | `https://github.com/Akay7/TypeLearn.git` | -| Repository push URL | `git@github.com:Akay7/TypeLearn.git` | +| Repository push URL | `git@github.com:Akay7/TypeLearn.git` (not empty, see below) | | Repository branch | `main` | +| Push branch | `weblate` | | File mask | `src/frontend/src/locales/*.json` | | Monolingual base language file | `src/frontend/src/locales/en.json` | | Edit base file | off (English changes go through code review) | -| File format | i18next JSON file v4 (keeps the flat keys; shows `key_one`, `key_few`, `key_other`… as one plural string) | +| File format | i18next JSON file v4 (nested keys; shows `key_one`, `key_few`, `key_other`… as one plural string) | | File format parameters | `json_indent: 2`, `json_sort_keys` left unset (keeps `en.json`'s order) | | Translation flags | `vue-format` (checks `{placeholder}` names against the source) | | Adding new translation | Contact maintainers (see below) | +Weblate pushes its commits to a `weblate` branch in this repository, not to a +fork of it. CI pushes its images to ghcr.io before testing them, and a pull +request opened from a fork gets a read-only token that cannot push, so CI would +never test a translation. For the push to work, add Hosted Weblate's public SSH +key (shown on its "SSH keys" page) to the repository as a deploy key with write +access. + ### Adding a language A new JSON file alone does nothing. The app only loads the languages it lists. To diff --git a/src/frontend/src/__tests__/i18n.test.js b/src/frontend/src/__tests__/i18n.test.js index 4264f76..9edea34 100644 --- a/src/frontend/src/__tests__/i18n.test.js +++ b/src/frontend/src/__tests__/i18n.test.js @@ -5,9 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // the '_one' form of a plural whose '_other' form is translated. vi.mock('../locales/fr.json', () => ({ default: { - 'answer.check': 'Vérifier', - 'sentence.loading': '', - 'sentence.symbolCount_other': '{count} caractères', + answer: { check: 'Vérifier' }, + sentence: { loading: '', symbolCount_other: '{count} caractères' }, }, })) diff --git a/src/frontend/src/i18n.js b/src/frontend/src/i18n.js index 85dcfca..4c3dd84 100644 --- a/src/frontend/src/i18n.js +++ b/src/frontend/src/i18n.js @@ -9,10 +9,13 @@ import th from './locales/th.json' // The catalogs are plain JSON so Weblate (see "Translating" in the README) can // read and write them. en.json is the reference: it holds every key, and every -// other locale is translated from it and falls back to it. Keys are flat, -// dot-prefixed strings grouped by the component that owns the text, rather than -// nested objects: six files are easier to diff side by side this way, and -// vue-i18n resolves a flat 'a.b' key exactly like a nested one. +// other locale is translated from it and falls back to it. Keys are nested +// objects grouped by the component that owns the text, and components look +// them up by dotted path: t('settings.button.label'). Never a flat +// 'settings.button.label' key: Weblate's i18next v4 format reads a dot as +// nesting, so while it re-saves an existing flat key as it found it, any key +// it adds to a translation is written nested — leaving a partly translated +// file half one shape and half the other. // // A translation may be incomplete — a volunteer translates some strings of a // language and not the rest — and src/locales/__tests__/keys.test.js allows @@ -24,10 +27,15 @@ import th from './locales/th.json' export const SUPPORTED_LANGUAGES = ['en', 'fr', 'de', 'th', 'ru', 'hu'] // Weblate can write a string nobody has translated yet as "" instead of leaving -// the key out, and vue-i18n renders "" as it is: a blank button. Dropping blanks -// makes those keys fall back to English, the same as keys that are missing. +// the key out, and vue-i18n renders "" as it is: a blank button. Dropping blanks, +// at every level of nesting, makes those keys fall back to English, the same as +// keys that are missing. function withoutBlanks(catalog) { - return Object.fromEntries(Object.entries(catalog).filter(([, text]) => text !== '')) + return Object.fromEntries( + Object.entries(catalog) + .filter(([, value]) => value !== '') + .map(([key, value]) => [key, typeof value === 'object' ? withoutBlanks(value) : value]), + ) } export const i18n = createI18n({ diff --git a/src/frontend/src/locales/__tests__/keys.test.js b/src/frontend/src/locales/__tests__/keys.test.js index 08511e0..deaa0b0 100644 --- a/src/frontend/src/locales/__tests__/keys.test.js +++ b/src/frontend/src/locales/__tests__/keys.test.js @@ -1,18 +1,38 @@ import { describe, expect, it } from 'vitest' -import de from '../de.json' -import en from '../en.json' -import fr from '../fr.json' -import hu from '../hu.json' -import ru from '../ru.json' -import th from '../th.json' +import deCatalog from '../de.json' +import enCatalog from '../en.json' +import frCatalog from '../fr.json' +import huCatalog from '../hu.json' +import ruCatalog from '../ru.json' +import thCatalog from '../th.json' + +// The catalogs are nested objects (see the comment in i18n.js); every check +// below is about the dotted paths components look strings up by, so each +// catalog is flattened to those paths first. +function flatten(catalog, prefix = '') { + return Object.fromEntries( + Object.entries(catalog).flatMap(([key, value]) => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? Object.entries(flatten(value, `${prefix}${key}.`)) + : [[`${prefix}${key}`, value]], + ), + ) +} + +const CATALOGS = { en: enCatalog, fr: frCatalog, de: deCatalog, th: thCatalog, ru: ruCatalog, hu: huCatalog } // en.json is the reference catalog (see the comment in i18n.js). The other // locales are edited on Weblate, and a language is often only partly translated // there, so a key that is missing or blank passes: it falls back to English. // What does not pass is a translation that would break at runtime — a key en.json // no longer has, or a placeholder that does not match the English one. -const OTHERS = { fr, de, th, ru, hu } +const en = flatten(enCatalog) +const OTHERS = Object.fromEntries( + Object.entries(CATALOGS) + .filter(([locale]) => locale !== 'en') + .map(([locale, catalog]) => [locale, flatten(catalog)]), +) // Plural forms (see tPlural in i18n.js) vary by language: English has '_one' // and '_other', Russian adds '_few' and '_many'. Any CLDR form counts as @@ -30,6 +50,14 @@ function placeholders(text) { return [...text.matchAll(/\{(\w+)\}/g)].map(([, name]) => name).sort() } +/** Every key, at every level, that has a dot in it. */ +function dottedKeys(catalog, path = '') { + return Object.entries(catalog).flatMap(([key, value]) => [ + ...(key.includes('.') ? [`${path}${key}`] : []), + ...(value !== null && typeof value === 'object' ? dottedKeys(value, `${path}${key} → `) : []), + ]) +} + describe('locale catalogs', () => { it('en.json is a complete catalog of non-empty strings', () => { expect(Object.keys(en).length).toBeGreaterThan(0) @@ -39,6 +67,14 @@ describe('locale catalogs', () => { } }) + // A flat 'a.b' key would still resolve, but Weblate writes the keys it adds + // nested — so a catalog that mixed both shapes would not stay consistent. + for (const [locale, catalog] of Object.entries(CATALOGS)) { + it(`${locale}.json nests its keys instead of joining them with dots`, () => { + expect(dottedKeys(catalog)).toEqual([]) + }) + } + for (const [locale, catalog] of Object.entries(OTHERS)) { it(`${locale}.json has no key that en.json lacks`, () => { expect(Object.keys(catalog).filter((key) => sourceOf(key) === undefined)).toEqual([]) diff --git a/src/frontend/src/locales/de.json b/src/frontend/src/locales/de.json index 9325c06..df350bc 100644 --- a/src/frontend/src/locales/de.json +++ b/src/frontend/src/locales/de.json @@ -1,46 +1,80 @@ { - "settings.button.label": "Einstellungen", - "settings.keyboardOverride.title": "Virtuelle Tastatur", - "settings.keyboardOverride.auto.label": "Automatisch", - "settings.keyboardOverride.auto.description": "An dieses Gerät anpassen", - "settings.keyboardOverride.on.label": "Ein", - "settings.keyboardOverride.on.description": "Immer anzeigen", - "settings.keyboardOverride.off.label": "Aus", - "settings.keyboardOverride.off.description": "Nie anzeigen", - "settings.onScreenKeyboard.title": "Bildschirmtastatur", - "settings.onScreenKeyboard.show.label": "Anzeigen", - "settings.onScreenKeyboard.show.description": "Das Kedmanee-Layout, mit Fingerfarben", - "settings.onScreenKeyboard.hide.label": "Ausblenden", - "settings.onScreenKeyboard.hide.description": "Nur mit der eigenen Tastatur tippen", - "settings.completionStats.label": "Zusammenfassung am Ende", - "settings.completionStats.description": "Statistik für heute und die letzten 7 Tage", - "settings.language.title": "Sprache", - "sentence.loading": "Übung wird geladen…", - "sentence.error": "Übung konnte nicht geladen werden. Läuft das Backend?", - "sentence.empty": "Es sind noch keine Übungen verfügbar.", - "sentence.symbolCount_one": "{count} Zeichen", - "sentence.symbolCount_other": "{count} Zeichen", - "answer.placeholder": "Tippe, was du hörst", - "answer.check": "Prüfen", - "answer.correct": "✓ Richtig", - "answer.incorrect": "✗ Falsch — versuch es erneut", - "audio.play": "▶ Abspielen", - "audio.pause": "⏸ Pause", - "audio.autoplayBlocked": "Drücke Abspielen, um es zu hören", - "keyboard.finger.little": "kleiner Finger", - "keyboard.finger.ring": "Ringfinger", - "keyboard.finger.middle": "Mittelfinger", - "keyboard.finger.leftIndex": "linker Zeigefinger", - "keyboard.finger.rightIndex": "rechter Zeigefinger", - "keyboard.finger.thumb": "Daumen", - "keyboard.restPosition": "Ruheposition", - "keyboard.restPositionSuffix": ", Ruheposition", - "keyboard.layoutSwitch": "Tastaturlayout: {name} — wechseln", - "stats.caption": "Übungssummen, heute und in den letzten 7 Tagen", - "stats.symbolsCorrect": "Richtige Zeichen", - "stats.keysPressed": "Tastendrücke", - "stats.exercisesCompleted": "Erledigte Übungen", - "stats.today": "Heute", - "stats.last7Days": "Letzte 7 Tage", - "stats.next": "Nächste Übung →" + "settings": { + "button": { + "label": "Einstellungen" + }, + "keyboardOverride": { + "title": "Virtuelle Tastatur", + "auto": { + "label": "Automatisch", + "description": "An dieses Gerät anpassen" + }, + "on": { + "label": "Ein", + "description": "Immer anzeigen" + }, + "off": { + "label": "Aus", + "description": "Nie anzeigen" + } + }, + "onScreenKeyboard": { + "title": "Bildschirmtastatur", + "show": { + "label": "Anzeigen", + "description": "Das Kedmanee-Layout, mit Fingerfarben" + }, + "hide": { + "label": "Ausblenden", + "description": "Nur mit der eigenen Tastatur tippen" + } + }, + "completionStats": { + "label": "Zusammenfassung am Ende", + "description": "Statistik für heute und die letzten 7 Tage" + }, + "language": { + "title": "Sprache" + } + }, + "sentence": { + "loading": "Übung wird geladen…", + "error": "Übung konnte nicht geladen werden. Läuft das Backend?", + "empty": "Es sind noch keine Übungen verfügbar.", + "symbolCount_one": "{count} Zeichen", + "symbolCount_other": "{count} Zeichen" + }, + "answer": { + "placeholder": "Tippe, was du hörst", + "check": "Prüfen", + "correct": "✓ Richtig", + "incorrect": "✗ Falsch — versuch es erneut" + }, + "audio": { + "play": "▶ Abspielen", + "pause": "⏸ Pause", + "autoplayBlocked": "Drücke Abspielen, um es zu hören" + }, + "keyboard": { + "finger": { + "little": "kleiner Finger", + "ring": "Ringfinger", + "middle": "Mittelfinger", + "leftIndex": "linker Zeigefinger", + "rightIndex": "rechter Zeigefinger", + "thumb": "Daumen" + }, + "restPosition": "Ruheposition", + "restPositionSuffix": ", Ruheposition", + "layoutSwitch": "Tastaturlayout: {name} — wechseln" + }, + "stats": { + "caption": "Übungssummen, heute und in den letzten 7 Tagen", + "symbolsCorrect": "Richtige Zeichen", + "keysPressed": "Tastendrücke", + "exercisesCompleted": "Erledigte Übungen", + "today": "Heute", + "last7Days": "Letzte 7 Tage", + "next": "Nächste Übung →" + } } diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json index 248d912..c467767 100644 --- a/src/frontend/src/locales/en.json +++ b/src/frontend/src/locales/en.json @@ -1,46 +1,80 @@ { - "settings.button.label": "Settings", - "settings.keyboardOverride.title": "Virtual keyboard", - "settings.keyboardOverride.auto.label": "Auto", - "settings.keyboardOverride.auto.description": "Match this device", - "settings.keyboardOverride.on.label": "On", - "settings.keyboardOverride.on.description": "Always show it", - "settings.keyboardOverride.off.label": "Off", - "settings.keyboardOverride.off.description": "Never show it", - "settings.onScreenKeyboard.title": "On-screen keyboard", - "settings.onScreenKeyboard.show.label": "Show", - "settings.onScreenKeyboard.show.description": "The Kedmanee board, with finger colours", - "settings.onScreenKeyboard.hide.label": "Hide", - "settings.onScreenKeyboard.hide.description": "Type on your own keyboard alone", - "settings.completionStats.label": "Summary on complete", - "settings.completionStats.description": "Statistics for today and the last 7 days", - "settings.language.title": "Language", - "sentence.loading": "Loading an exercise…", - "sentence.error": "Could not load an exercise. Is the backend running?", - "sentence.empty": "No exercises are available yet.", - "sentence.symbolCount_one": "{count} symbol", - "sentence.symbolCount_other": "{count} symbols", - "answer.placeholder": "Type what you hear", - "answer.check": "Check", - "answer.correct": "✓ Correct", - "answer.incorrect": "✗ Incorrect — try again", - "audio.play": "▶ Play", - "audio.pause": "⏸ Pause", - "audio.autoplayBlocked": "Press play to hear it", - "keyboard.finger.little": "little", - "keyboard.finger.ring": "ring", - "keyboard.finger.middle": "middle", - "keyboard.finger.leftIndex": "left index", - "keyboard.finger.rightIndex": "right index", - "keyboard.finger.thumb": "thumb", - "keyboard.restPosition": "rest position", - "keyboard.restPositionSuffix": ", rest position", - "keyboard.layoutSwitch": "Keyboard layout: {name} — switch", - "stats.caption": "Practice totals, today and over the last 7 days", - "stats.symbolsCorrect": "Symbols correct", - "stats.keysPressed": "Key presses", - "stats.exercisesCompleted": "Exercises completed", - "stats.today": "Today", - "stats.last7Days": "Last 7 days", - "stats.next": "Next exercise →" + "settings": { + "button": { + "label": "Settings" + }, + "keyboardOverride": { + "title": "Virtual keyboard", + "auto": { + "label": "Auto", + "description": "Match this device" + }, + "on": { + "label": "On", + "description": "Always show it" + }, + "off": { + "label": "Off", + "description": "Never show it" + } + }, + "onScreenKeyboard": { + "title": "On-screen keyboard", + "show": { + "label": "Show", + "description": "The Kedmanee board, with finger colours" + }, + "hide": { + "label": "Hide", + "description": "Type on your own keyboard alone" + } + }, + "completionStats": { + "label": "Summary on complete", + "description": "Statistics for today and the last 7 days" + }, + "language": { + "title": "Language" + } + }, + "sentence": { + "loading": "Loading an exercise…", + "error": "Could not load an exercise. Is the backend running?", + "empty": "No exercises are available yet.", + "symbolCount_one": "{count} symbol", + "symbolCount_other": "{count} symbols" + }, + "answer": { + "placeholder": "Type what you hear", + "check": "Check", + "correct": "✓ Correct", + "incorrect": "✗ Incorrect — try again" + }, + "audio": { + "play": "▶ Play", + "pause": "⏸ Pause", + "autoplayBlocked": "Press play to hear it" + }, + "keyboard": { + "finger": { + "little": "little", + "ring": "ring", + "middle": "middle", + "leftIndex": "left index", + "rightIndex": "right index", + "thumb": "thumb" + }, + "restPosition": "rest position", + "restPositionSuffix": ", rest position", + "layoutSwitch": "Keyboard layout: {name} — switch" + }, + "stats": { + "caption": "Practice totals, today and over the last 7 days", + "symbolsCorrect": "Symbols correct", + "keysPressed": "Key presses", + "exercisesCompleted": "Exercises completed", + "today": "Today", + "last7Days": "Last 7 days", + "next": "Next exercise →" + } } diff --git a/src/frontend/src/locales/fr.json b/src/frontend/src/locales/fr.json index 1d0d87c..5a64798 100644 --- a/src/frontend/src/locales/fr.json +++ b/src/frontend/src/locales/fr.json @@ -1,47 +1,81 @@ { - "settings.button.label": "Paramètres", - "settings.keyboardOverride.title": "Clavier virtuel", - "settings.keyboardOverride.auto.label": "Auto", - "settings.keyboardOverride.auto.description": "Suivre cet appareil", - "settings.keyboardOverride.on.label": "Activé", - "settings.keyboardOverride.on.description": "Toujours l'afficher", - "settings.keyboardOverride.off.label": "Désactivé", - "settings.keyboardOverride.off.description": "Ne jamais l'afficher", - "settings.onScreenKeyboard.title": "Clavier à l'écran", - "settings.onScreenKeyboard.show.label": "Afficher", - "settings.onScreenKeyboard.show.description": "Le clavier Kedmanee, avec les couleurs des doigts", - "settings.onScreenKeyboard.hide.label": "Masquer", - "settings.onScreenKeyboard.hide.description": "Taper uniquement sur votre propre clavier", - "settings.completionStats.label": "Résumé à la fin", - "settings.completionStats.description": "Statistiques du jour et des 7 derniers jours", - "settings.language.title": "Langue", - "sentence.loading": "Chargement d'un exercice…", - "sentence.error": "Impossible de charger un exercice. Le serveur est-il en cours d'exécution ?", - "sentence.empty": "Aucun exercice n'est disponible pour le moment.", - "sentence.symbolCount_one": "{count} caractère", - "sentence.symbolCount_many": "{count} de caractères", - "sentence.symbolCount_other": "{count} caractères", - "answer.placeholder": "Tapez ce que vous entendez", - "answer.check": "Vérifier", - "answer.correct": "✓ Correct", - "answer.incorrect": "✗ Incorrect — réessayez", - "audio.play": "▶ Lecture", - "audio.pause": "⏸ Pause", - "audio.autoplayBlocked": "Appuyez sur lecture pour l'entendre", - "keyboard.finger.little": "auriculaire", - "keyboard.finger.ring": "annulaire", - "keyboard.finger.middle": "majeur", - "keyboard.finger.leftIndex": "index gauche", - "keyboard.finger.rightIndex": "index droit", - "keyboard.finger.thumb": "pouce", - "keyboard.restPosition": "position de repos", - "keyboard.restPositionSuffix": ", position de repos", - "keyboard.layoutSwitch": "Disposition du clavier : {name} — changer", - "stats.caption": "Totaux d'entraînement, aujourd'hui et sur les 7 derniers jours", - "stats.symbolsCorrect": "Symboles corrects", - "stats.keysPressed": "Touches pressées", - "stats.exercisesCompleted": "Exercices terminés", - "stats.today": "Aujourd'hui", - "stats.last7Days": "7 derniers jours", - "stats.next": "Exercice suivant →" + "settings": { + "button": { + "label": "Paramètres" + }, + "keyboardOverride": { + "title": "Clavier virtuel", + "auto": { + "label": "Auto", + "description": "Suivre cet appareil" + }, + "on": { + "label": "Activé", + "description": "Toujours l'afficher" + }, + "off": { + "label": "Désactivé", + "description": "Ne jamais l'afficher" + } + }, + "onScreenKeyboard": { + "title": "Clavier à l'écran", + "show": { + "label": "Afficher", + "description": "Le clavier Kedmanee, avec les couleurs des doigts" + }, + "hide": { + "label": "Masquer", + "description": "Taper uniquement sur votre propre clavier" + } + }, + "completionStats": { + "label": "Résumé à la fin", + "description": "Statistiques du jour et des 7 derniers jours" + }, + "language": { + "title": "Langue" + } + }, + "sentence": { + "loading": "Chargement d'un exercice…", + "error": "Impossible de charger un exercice. Le serveur est-il en cours d'exécution ?", + "empty": "Aucun exercice n'est disponible pour le moment.", + "symbolCount_one": "{count} caractère", + "symbolCount_many": "{count} de caractères", + "symbolCount_other": "{count} caractères" + }, + "answer": { + "placeholder": "Tapez ce que vous entendez", + "check": "Vérifier", + "correct": "✓ Correct", + "incorrect": "✗ Incorrect — réessayez" + }, + "audio": { + "play": "▶ Lecture", + "pause": "⏸ Pause", + "autoplayBlocked": "Appuyez sur lecture pour l'entendre" + }, + "keyboard": { + "finger": { + "little": "auriculaire", + "ring": "annulaire", + "middle": "majeur", + "leftIndex": "index gauche", + "rightIndex": "index droit", + "thumb": "pouce" + }, + "restPosition": "position de repos", + "restPositionSuffix": ", position de repos", + "layoutSwitch": "Disposition du clavier : {name} — changer" + }, + "stats": { + "caption": "Totaux d'entraînement, aujourd'hui et sur les 7 derniers jours", + "symbolsCorrect": "Symboles corrects", + "keysPressed": "Touches pressées", + "exercisesCompleted": "Exercices terminés", + "today": "Aujourd'hui", + "last7Days": "7 derniers jours", + "next": "Exercice suivant →" + } } diff --git a/src/frontend/src/locales/hu.json b/src/frontend/src/locales/hu.json index d33591e..49745df 100644 --- a/src/frontend/src/locales/hu.json +++ b/src/frontend/src/locales/hu.json @@ -1,46 +1,80 @@ { - "settings.button.label": "Beállítások", - "settings.keyboardOverride.title": "Virtuális billentyűzet", - "settings.keyboardOverride.auto.label": "Automatikus", - "settings.keyboardOverride.auto.description": "Az eszköz alapján", - "settings.keyboardOverride.on.label": "Be", - "settings.keyboardOverride.on.description": "Mindig jelenjen meg", - "settings.keyboardOverride.off.label": "Ki", - "settings.keyboardOverride.off.description": "Soha ne jelenjen meg", - "settings.onScreenKeyboard.title": "Képernyő-billentyűzet", - "settings.onScreenKeyboard.show.label": "Megjelenítés", - "settings.onScreenKeyboard.show.description": "A Kedmanee kiosztás, ujjszínekkel", - "settings.onScreenKeyboard.hide.label": "Elrejtés", - "settings.onScreenKeyboard.hide.description": "Csak a saját billentyűzeteden gépelj", - "settings.completionStats.label": "Összegzés a végén", - "settings.completionStats.description": "Statisztika a mai napról és az elmúlt 7 napról", - "settings.language.title": "Nyelv", - "sentence.loading": "Feladat betöltése…", - "sentence.error": "A feladatot nem sikerült betölteni. Fut a háttérszolgáltatás?", - "sentence.empty": "Még nincs elérhető feladat.", - "sentence.symbolCount_one": "{count} karakter", - "sentence.symbolCount_other": "{count} karakter", - "answer.placeholder": "Írd le, amit hallasz", - "answer.check": "Ellenőrzés", - "answer.correct": "✓ Helyes", - "answer.incorrect": "✗ Helytelen — próbáld újra", - "audio.play": "▶ Lejátszás", - "audio.pause": "⏸ Szünet", - "audio.autoplayBlocked": "Nyomd meg a lejátszást a meghallgatáshoz", - "keyboard.finger.little": "kisujj", - "keyboard.finger.ring": "gyűrűsujj", - "keyboard.finger.middle": "középső ujj", - "keyboard.finger.leftIndex": "bal mutatóujj", - "keyboard.finger.rightIndex": "jobb mutatóujj", - "keyboard.finger.thumb": "hüvelykujj", - "keyboard.restPosition": "nyugalmi helyzet", - "keyboard.restPositionSuffix": ", nyugalmi helyzet", - "keyboard.layoutSwitch": "Billentyűzetkiosztás: {name} — váltás", - "stats.caption": "Gyakorlási összesítés, ma és az elmúlt 7 napban", - "stats.symbolsCorrect": "Helyes karakterek", - "stats.keysPressed": "Billentyűleütések", - "stats.exercisesCompleted": "Befejezett gyakorlatok", - "stats.today": "Ma", - "stats.last7Days": "Elmúlt 7 nap", - "stats.next": "Következő gyakorlat →" + "settings": { + "button": { + "label": "Beállítások" + }, + "keyboardOverride": { + "title": "Virtuális billentyűzet", + "auto": { + "label": "Automatikus", + "description": "Az eszköz alapján" + }, + "on": { + "label": "Be", + "description": "Mindig jelenjen meg" + }, + "off": { + "label": "Ki", + "description": "Soha ne jelenjen meg" + } + }, + "onScreenKeyboard": { + "title": "Képernyő-billentyűzet", + "show": { + "label": "Megjelenítés", + "description": "A Kedmanee kiosztás, ujjszínekkel" + }, + "hide": { + "label": "Elrejtés", + "description": "Csak a saját billentyűzeteden gépelj" + } + }, + "completionStats": { + "label": "Összegzés a végén", + "description": "Statisztika a mai napról és az elmúlt 7 napról" + }, + "language": { + "title": "Nyelv" + } + }, + "sentence": { + "loading": "Feladat betöltése…", + "error": "A feladatot nem sikerült betölteni. Fut a háttérszolgáltatás?", + "empty": "Még nincs elérhető feladat.", + "symbolCount_one": "{count} karakter", + "symbolCount_other": "{count} karakter" + }, + "answer": { + "placeholder": "Írd le, amit hallasz", + "check": "Ellenőrzés", + "correct": "✓ Helyes", + "incorrect": "✗ Helytelen — próbáld újra" + }, + "audio": { + "play": "▶ Lejátszás", + "pause": "⏸ Szünet", + "autoplayBlocked": "Nyomd meg a lejátszást a meghallgatáshoz" + }, + "keyboard": { + "finger": { + "little": "kisujj", + "ring": "gyűrűsujj", + "middle": "középső ujj", + "leftIndex": "bal mutatóujj", + "rightIndex": "jobb mutatóujj", + "thumb": "hüvelykujj" + }, + "restPosition": "nyugalmi helyzet", + "restPositionSuffix": ", nyugalmi helyzet", + "layoutSwitch": "Billentyűzetkiosztás: {name} — váltás" + }, + "stats": { + "caption": "Gyakorlási összesítés, ma és az elmúlt 7 napban", + "symbolsCorrect": "Helyes karakterek", + "keysPressed": "Billentyűleütések", + "exercisesCompleted": "Befejezett gyakorlatok", + "today": "Ma", + "last7Days": "Elmúlt 7 nap", + "next": "Következő gyakorlat →" + } } diff --git a/src/frontend/src/locales/ru.json b/src/frontend/src/locales/ru.json index 4ad977a..e8df924 100644 --- a/src/frontend/src/locales/ru.json +++ b/src/frontend/src/locales/ru.json @@ -1,48 +1,82 @@ { - "settings.button.label": "Настройки", - "settings.keyboardOverride.title": "Виртуальная клавиатура", - "settings.keyboardOverride.auto.label": "Авто", - "settings.keyboardOverride.auto.description": "Определять по устройству", - "settings.keyboardOverride.on.label": "Включено", - "settings.keyboardOverride.on.description": "Всегда показывать", - "settings.keyboardOverride.off.label": "Выключено", - "settings.keyboardOverride.off.description": "Никогда не показывать", - "settings.onScreenKeyboard.title": "Экранная клавиатура", - "settings.onScreenKeyboard.show.label": "Показать", - "settings.onScreenKeyboard.show.description": "Клавиатура Кедмани, с цветовой разметкой пальцев", - "settings.onScreenKeyboard.hide.label": "Скрыть", - "settings.onScreenKeyboard.hide.description": "Печатать только на своей клавиатуре", - "settings.completionStats.label": "Итоги после упражнения", - "settings.completionStats.description": "Статистика за сегодня и последние 7 дней", - "settings.language.title": "Язык", - "sentence.loading": "Загрузка упражнения…", - "sentence.error": "Не удалось загрузить упражнение. Сервер запущен?", - "sentence.empty": "Упражнения пока недоступны.", - "sentence.symbolCount_one": "{count} символ", - "sentence.symbolCount_few": "{count} символа", - "sentence.symbolCount_many": "{count} символов", - "sentence.symbolCount_other": "{count} символа", - "answer.placeholder": "Напечатайте то, что слышите", - "answer.check": "Проверить", - "answer.correct": "✓ Верно", - "answer.incorrect": "✗ Неверно — попробуйте снова", - "audio.play": "▶ Играть", - "audio.pause": "⏸ Пауза", - "audio.autoplayBlocked": "Нажмите «Играть», чтобы услышать", - "keyboard.finger.little": "мизинец", - "keyboard.finger.ring": "безымянный", - "keyboard.finger.middle": "средний", - "keyboard.finger.leftIndex": "левый указательный", - "keyboard.finger.rightIndex": "правый указательный", - "keyboard.finger.thumb": "большой", - "keyboard.restPosition": "исходное положение", - "keyboard.restPositionSuffix": ", исходное положение", - "keyboard.layoutSwitch": "Раскладка клавиатуры: {name} — переключить", - "stats.caption": "Итоги практики за сегодня и последние 7 дней", - "stats.symbolsCorrect": "Верных символов", - "stats.keysPressed": "Нажатий клавиш", - "stats.exercisesCompleted": "Упражнений выполнено", - "stats.today": "Сегодня", - "stats.last7Days": "Последние 7 дней", - "stats.next": "Следующее упражнение →" + "settings": { + "button": { + "label": "Настройки" + }, + "keyboardOverride": { + "title": "Виртуальная клавиатура", + "auto": { + "label": "Авто", + "description": "Определять по устройству" + }, + "on": { + "label": "Включено", + "description": "Всегда показывать" + }, + "off": { + "label": "Выключено", + "description": "Никогда не показывать" + } + }, + "onScreenKeyboard": { + "title": "Экранная клавиатура", + "show": { + "label": "Показать", + "description": "Клавиатура Кедмани, с цветовой разметкой пальцев" + }, + "hide": { + "label": "Скрыть", + "description": "Печатать только на своей клавиатуре" + } + }, + "completionStats": { + "label": "Итоги после упражнения", + "description": "Статистика за сегодня и последние 7 дней" + }, + "language": { + "title": "Язык" + } + }, + "sentence": { + "loading": "Загрузка упражнения…", + "error": "Не удалось загрузить упражнение. Сервер запущен?", + "empty": "Упражнения пока недоступны.", + "symbolCount_one": "{count} символ", + "symbolCount_few": "{count} символа", + "symbolCount_many": "{count} символов", + "symbolCount_other": "{count} символа" + }, + "answer": { + "placeholder": "Напечатайте то, что слышите", + "check": "Проверить", + "correct": "✓ Верно", + "incorrect": "✗ Неверно — попробуйте снова" + }, + "audio": { + "play": "▶ Играть", + "pause": "⏸ Пауза", + "autoplayBlocked": "Нажмите «Играть», чтобы услышать" + }, + "keyboard": { + "finger": { + "little": "мизинец", + "ring": "безымянный", + "middle": "средний", + "leftIndex": "левый указательный", + "rightIndex": "правый указательный", + "thumb": "большой" + }, + "restPosition": "исходное положение", + "restPositionSuffix": ", исходное положение", + "layoutSwitch": "Раскладка клавиатуры: {name} — переключить" + }, + "stats": { + "caption": "Итоги практики за сегодня и последние 7 дней", + "symbolsCorrect": "Верных символов", + "keysPressed": "Нажатий клавиш", + "exercisesCompleted": "Упражнений выполнено", + "today": "Сегодня", + "last7Days": "Последние 7 дней", + "next": "Следующее упражнение →" + } } diff --git a/src/frontend/src/locales/th.json b/src/frontend/src/locales/th.json index fddba79..6d34070 100644 --- a/src/frontend/src/locales/th.json +++ b/src/frontend/src/locales/th.json @@ -1,45 +1,79 @@ { - "settings.button.label": "การตั้งค่า", - "settings.keyboardOverride.title": "แป้นพิมพ์เสมือน", - "settings.keyboardOverride.auto.label": "อัตโนมัติ", - "settings.keyboardOverride.auto.description": "ให้ตรงกับอุปกรณ์นี้", - "settings.keyboardOverride.on.label": "เปิด", - "settings.keyboardOverride.on.description": "แสดงเสมอ", - "settings.keyboardOverride.off.label": "ปิด", - "settings.keyboardOverride.off.description": "ไม่แสดงเลย", - "settings.onScreenKeyboard.title": "แป้นพิมพ์บนหน้าจอ", - "settings.onScreenKeyboard.show.label": "แสดง", - "settings.onScreenKeyboard.show.description": "แป้นพิมพ์เกษมณี พร้อมสีประจำนิ้ว", - "settings.onScreenKeyboard.hide.label": "ซ่อน", - "settings.onScreenKeyboard.hide.description": "พิมพ์ด้วยแป้นพิมพ์ของคุณเองเท่านั้น", - "settings.completionStats.label": "สรุปเมื่อทำเสร็จ", - "settings.completionStats.description": "สถิติของวันนี้และ 7 วันที่ผ่านมา", - "settings.language.title": "ภาษา", - "sentence.loading": "กำลังโหลดแบบฝึกหัด…", - "sentence.error": "ไม่สามารถโหลดแบบฝึกหัดได้ เซิร์ฟเวอร์แบ็กเอนด์ทำงานอยู่หรือไม่", - "sentence.empty": "ยังไม่มีแบบฝึกหัดให้ใช้งาน", - "sentence.symbolCount_other": "{count} ตัวอักษร", - "answer.placeholder": "พิมพ์ตามที่คุณได้ยิน", - "answer.check": "ตรวจสอบ", - "answer.correct": "✓ ถูกต้อง", - "answer.incorrect": "✗ ไม่ถูกต้อง — ลองอีกครั้ง", - "audio.play": "▶ เล่น", - "audio.pause": "⏸ หยุดชั่วคราว", - "audio.autoplayBlocked": "กดเล่นเพื่อฟัง", - "keyboard.finger.little": "นิ้วก้อย", - "keyboard.finger.ring": "นิ้วนาง", - "keyboard.finger.middle": "นิ้วกลาง", - "keyboard.finger.leftIndex": "นิ้วชี้ซ้าย", - "keyboard.finger.rightIndex": "นิ้วชี้ขวา", - "keyboard.finger.thumb": "นิ้วโป้ง", - "keyboard.restPosition": "ตำแหน่งพัก", - "keyboard.restPositionSuffix": ", ตำแหน่งพัก", - "keyboard.layoutSwitch": "รูปแบบแป้นพิมพ์: {name} — สลับ", - "stats.caption": "ยอดรวมการฝึก วันนี้และใน 7 วันที่ผ่านมา", - "stats.symbolsCorrect": "ตัวอักษรที่ถูกต้อง", - "stats.keysPressed": "จำนวนการกดแป้น", - "stats.exercisesCompleted": "แบบฝึกหัดที่ทำเสร็จ", - "stats.today": "วันนี้", - "stats.last7Days": "7 วันที่ผ่านมา", - "stats.next": "แบบฝึกหัดถัดไป →" + "settings": { + "button": { + "label": "การตั้งค่า" + }, + "keyboardOverride": { + "title": "แป้นพิมพ์เสมือน", + "auto": { + "label": "อัตโนมัติ", + "description": "ให้ตรงกับอุปกรณ์นี้" + }, + "on": { + "label": "เปิด", + "description": "แสดงเสมอ" + }, + "off": { + "label": "ปิด", + "description": "ไม่แสดงเลย" + } + }, + "onScreenKeyboard": { + "title": "แป้นพิมพ์บนหน้าจอ", + "show": { + "label": "แสดง", + "description": "แป้นพิมพ์เกษมณี พร้อมสีประจำนิ้ว" + }, + "hide": { + "label": "ซ่อน", + "description": "พิมพ์ด้วยแป้นพิมพ์ของคุณเองเท่านั้น" + } + }, + "completionStats": { + "label": "สรุปเมื่อทำเสร็จ", + "description": "สถิติของวันนี้และ 7 วันที่ผ่านมา" + }, + "language": { + "title": "ภาษา" + } + }, + "sentence": { + "loading": "กำลังโหลดแบบฝึกหัด…", + "error": "ไม่สามารถโหลดแบบฝึกหัดได้ เซิร์ฟเวอร์แบ็กเอนด์ทำงานอยู่หรือไม่", + "empty": "ยังไม่มีแบบฝึกหัดให้ใช้งาน", + "symbolCount_other": "{count} ตัวอักษร" + }, + "answer": { + "placeholder": "พิมพ์ตามที่คุณได้ยิน", + "check": "ตรวจสอบ", + "correct": "✓ ถูกต้อง", + "incorrect": "✗ ไม่ถูกต้อง — ลองอีกครั้ง" + }, + "audio": { + "play": "▶ เล่น", + "pause": "⏸ หยุดชั่วคราว", + "autoplayBlocked": "กดเล่นเพื่อฟัง" + }, + "keyboard": { + "finger": { + "little": "นิ้วก้อย", + "ring": "นิ้วนาง", + "middle": "นิ้วกลาง", + "leftIndex": "นิ้วชี้ซ้าย", + "rightIndex": "นิ้วชี้ขวา", + "thumb": "นิ้วโป้ง" + }, + "restPosition": "ตำแหน่งพัก", + "restPositionSuffix": ", ตำแหน่งพัก", + "layoutSwitch": "รูปแบบแป้นพิมพ์: {name} — สลับ" + }, + "stats": { + "caption": "ยอดรวมการฝึก วันนี้และใน 7 วันที่ผ่านมา", + "symbolsCorrect": "ตัวอักษรที่ถูกต้อง", + "keysPressed": "จำนวนการกดแป้น", + "exercisesCompleted": "แบบฝึกหัดที่ทำเสร็จ", + "today": "วันนี้", + "last7Days": "7 วันที่ผ่านมา", + "next": "แบบฝึกหัดถัดไป →" + } }