[Translation] Name languages without reading the translation files (#7662)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* [Translation] Name languages without reading the translation files

The language selector derived every label from the language's own .qm file:
it loaded the file and translated the literal "English" in the "i18n"
context. lupdate could never extract that literal from the callers, which
pass the DEFAULT_LANG_NAME macro, so a dummy QT_TRANSLATE_NOOP in
cache_settings.cpp existed to give it something to extract. #7050 deleted
that dummy, the next lupdate push dropped the string from Transifex, and
the following pull rewrote the .ts files without it. lrelease only emits
translated messages, so 22 of 25 .qm files returned an empty name.

The names are data, not translations: a selector has to be readable before
any translation is loaded, and a label must not depend on the translation
of the language it names. TranslationLoader::languageDisplayName now holds
them next to availableLanguages, falling back to Qt's native language name
and then to the code, so a language added later still gets a label.

Also select the configured language by item data instead of by matching the
translated "English" string, which pointed at the wrong row whenever the
lookup came back empty, and restore that English entry's own name: en_US is
"English (United States)" so it can no longer be confused with the
untranslated default.

Oracle shared the code path, so its wizard language page is fixed too.

* [Translation] Name the default language selection once

Review follow-up: log the unresolvable language through the page's own
category, and move the en_US entry both selectors fall back to for an unset
setting into TranslationLoader::defaultLanguageCode.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-26 02:39:26 +01:00 • committed by GitHub
parent 858b270fa9
commit 675e1f7447
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 157 additions and 51 deletions

View file

@ -19,7 +19,6 @@ inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache");
class ReleaseChannel;
#define DEFAULT_LANG_NAME "English"
#define CLIENT_INFO_NOT_SET "notset"
#define DEFAULT_FONT_SIZE 12

View file

@ -5,6 +5,7 @@
#include "../../settings_page/general_settings_page.h"
#include "../first_run_wizard_page.h"
#include "libcockatrice/settings/personal_settings.h"
#include "libcockatrice/utility/translation_loader.h"
#include <QApplication>
#include <QChar>
@ -30,7 +31,7 @@ WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent)
languageLabel = new QLabel(this);
langCombo = new QComboBox(this);
for (const QString &code : GeneralSettingsPage::findQmFiles()) {
langCombo->addItem(GeneralSettingsPage::languageName(code), code);
langCombo->addItem(TranslationLoader::languageDisplayName(code), code);
}
QString current = SettingsCache::instance().personal().getLang();

View file

@ -8,7 +8,6 @@
#include "update/client/release_channel.h"
#include <QByteArray>
#include <QCoreApplication>
#include <QDate>
#include <QDebug>
#include <QFile>
@ -22,7 +21,6 @@
#include <QPushButton>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QTranslator>
#include <QVBoxLayout>
#include <QVariant>
#include <QVariantList>
@ -51,18 +49,24 @@ enum startupCardUpdateCheckBehaviorIndex
GeneralSettingsPage::GeneralSettingsPage()
{
// language settings
QStringList languageCodes = findQmFiles();
for (const QString &code : languageCodes) {
QString langName = languageName(code);
languageBox.addItem(langName, code);
for (const QString &code : findQmFiles()) {
languageBox.addItem(TranslationLoader::languageDisplayName(code), code);
}
QString setLanguage = QCoreApplication::translate("i18n", DEFAULT_LANG_NAME);
int index = languageBox.findText(setLanguage, Qt::MatchExactly);
if (index == -1) {
qWarning() << "could not find language" << setLanguage;
} else {
// Select the language the client runs with: its own entry, the base language for a variant without a
// file of its own (de_CH -> de), and the default language for an unset setting, which names no file.
const QString configuredLanguage = SettingsCache::instance().personal().getLang();
int index = languageBox.findData(configuredLanguage);
if (index < 0) {
index = languageBox.findData(configuredLanguage.section('_', 0, 0));
}
if (index < 0) {
index = languageBox.findData(TranslationLoader::defaultLanguageCode());
}
if (index >= 0) {
languageBox.setCurrentIndex(index);
} else {
qCWarning(GeneralSettingsPageLog) << "could not find language" << configuredLanguage;
}
advertiseTranslationPageLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse);
@ -384,18 +388,6 @@ QStringList GeneralSettingsPage::findQmFiles()
TranslationLoader::applicationTranslationPaths(translationPath));
}
QString GeneralSettingsPage::languageName(const QString &lang)
{
QTranslator qTranslator;
// Only the language name is needed here, so the file is loaded without logging a line per
// language: findQmFiles() asked for languages whose files exist in the first place.
TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang,
TranslationLoader::applicationTranslationPaths(translationPath));
return qTranslator.translate("i18n", DEFAULT_LANG_NAME);
}
void GeneralSettingsPage::deckPathButtonClicked()
{
QString path = QFileDialog::getExistingDirectory(this, tr("Choose path"), deckPathEdit->text());

View file

@ -26,7 +26,6 @@ public:
void retranslateUi() override;
static QStringList findQmFiles();
static QString languageName(const QString &lang);
signals:
/// Request to re-import the card database with the newly selected card language

View file

@ -5,8 +5,10 @@
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QHash>
#include <QLatin1Char>
#include <QLibraryInfo>
#include <QLocale>
#include <QMessageLogger>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
@ -100,6 +102,60 @@ QList<QString> TranslationLoader::availableLanguages(const QString &_prefix, con
return sortedLanguages;
}
QString TranslationLoader::languageDisplayName(const QString &_lang)
{
if (_lang.isEmpty()) {
return QString();
}
// The native name first, so a speaker finds their own language, then the English one, so everyone
// else does. A language shipped with a .qm file belongs in here: the names used to come from the
// translation files, where a missing translation left the selector blank.
static const QHash<QString, QString> displayNames = {
{QStringLiteral("ca"), QStringLiteral("Català (Catalan)")},
{QStringLiteral("cs"), QStringLiteral("Česky (Czech)")},
{QStringLiteral("de"), QStringLiteral("Deutsch (German)")},
{QStringLiteral("el"), QStringLiteral("Ελληνικά (Greek)")},
{QStringLiteral("en@pirate"), QStringLiteral("English, arr! (Pirate English)")},
{QStringLiteral("en_US"), QStringLiteral("English (United States)")},
{QStringLiteral("es"), QStringLiteral("Español (Spanish)")},
{QStringLiteral("et"), QStringLiteral("Eesti Keel (Estonian)")},
{QStringLiteral("fi"), QStringLiteral("Suomi (Finnish)")},
{QStringLiteral("fr"), QStringLiteral("Français (French)")},
{QStringLiteral("hu"), QStringLiteral("Magyar (Hungarian)")},
{QStringLiteral("it"), QStringLiteral("Italiano (Italian)")},
{QStringLiteral("ja"), QStringLiteral("日本語 (Japanese)")},
{QStringLiteral("ko"), QStringLiteral("한국어 (Korean)")},
{QStringLiteral("nb"), QStringLiteral("Norsk Bokmål (Norwegian Bokmål)")},
{QStringLiteral("nl"), QStringLiteral("Nederlands (Dutch)")},
{QStringLiteral("pl"), QStringLiteral("Polski (Polish)")},
{QStringLiteral("pt"), QStringLiteral("Português (Portuguese)")},
{QStringLiteral("pt_BR"), QStringLiteral("Português do Brasil (Brazilian Portuguese)")},
{QStringLiteral("ru"), QStringLiteral("Русский (Russian)")},
{QStringLiteral("sr"), QStringLiteral("Srpski (Serbian)")},
{QStringLiteral("sv"), QStringLiteral("Svenska (Swedish)")},
{QStringLiteral("tr"), QStringLiteral("Türkçe (Turkish)")},
{QStringLiteral("yue"), QStringLiteral("粵語 (Cantonese)")},
{QStringLiteral("zh-Hans"), QStringLiteral("简体中文 (Chinese Simplified)")},
{QStringLiteral("zh-Hant"), QStringLiteral("繁體中文 (Chinese Traditional)")},
};
const QString displayName = displayNames.value(_lang);
if (!displayName.isEmpty()) {
return displayName;
}
// A language added after this table still gets a label instead of the blank one this function exists
// to prevent. Qt knows no name for an unknown code and answers with an empty string.
const QString nativeName = QLocale(_lang).nativeLanguageName();
return nativeName.isEmpty() ? _lang : nativeName;
}
QString TranslationLoader::defaultLanguageCode()
{
return QStringLiteral("en_US");
}
TranslationLoadResult
TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList<QString> &_directories)
{

View file

@ -74,6 +74,26 @@ TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang);
*/
QList<QString> availableLanguages(const QString &_prefix, const QList<QString> &_directories);
/**
* @brief A human-readable name for a language, e.g. "Deutsch (German)".
*
* The names are data, not translations: a language selector has to be readable before any translation
* is loaded, and its labels must not depend on the translation of the language they name.
*
* @param _lang The language code, e.g. "de" or "pt_BR".
* @return The curated name; for a code it does not know, the native language name; the code itself when
* Qt has no name for it either; an empty string when _lang is empty.
*/
QString languageDisplayName(const QString &_lang);
/**
* @brief The language a client runs with when none is configured, for preselecting a selector entry.
*
* English is the untranslated default and ships no translation file of its own, so a selector has nothing
* of its own to point at and uses the entry that carries an English translation instead.
*/
QString defaultLanguageCode();
/**
* @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator.
*

View file

@ -32,9 +32,6 @@ class QWidget;
OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent)
{
// define a dummy context that will be used where needed
QString dummy = QT_TRANSLATE_NOOP("i18n", "English");
#ifdef Q_OS_WIN
setWizardStyle(QWizard::ModernStyle);
#endif

View file

@ -10,7 +10,6 @@
#include <QBuffer>
#include <QCheckBox>
#include <QComboBox>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
@ -39,7 +38,6 @@
#include <QTextEdit>
#include <QTextStream>
#include <QTimer>
#include <QTranslator>
#include <QUrl>
#include <QVariant>
#include <QWizard>
@ -203,16 +201,23 @@ IntroPage::IntroPage(QWidget *parent) : OracleWizardPage(parent)
QStringList languageCodes = findQmFiles();
for (const QString &code : languageCodes) {
QString langName = languageName(code);
languageBox->addItem(langName, code);
languageBox->addItem(TranslationLoader::languageDisplayName(code), code);
}
QString setLanguage = QCoreApplication::translate("i18n", DEFAULT_LANG_NAME);
int index = languageBox->findText(setLanguage, Qt::MatchExactly);
if (index == -1) {
qWarning() << "could not find language" << setLanguage;
} else {
// Select the language the wizard runs with: its own entry, the base language for a variant without a
// file of its own (de_CH -> de), and the default language for an unset setting, which names no file.
const QString configuredLanguage = SettingsCache::instance().personal().getLang();
int index = languageBox->findData(configuredLanguage);
if (index < 0) {
index = languageBox->findData(configuredLanguage.section('_', 0, 0));
}
if (index < 0) {
index = languageBox->findData(TranslationLoader::defaultLanguageCode());
}
if (index >= 0) {
languageBox->setCurrentIndex(index);
} else {
qWarning() << "could not find language" << configuredLanguage;
}
connect(languageBox, qOverload<int>(&QComboBox::currentIndexChanged), this, &IntroPage::languageBoxChanged);
@ -239,18 +244,6 @@ QStringList IntroPage::findQmFiles()
TranslationLoader::applicationTranslationPaths(translationPath));
}
QString IntroPage::languageName(const QString &lang)
{
QTranslator qTranslator;
// Only the language name is needed here, so the file is loaded without logging a line per
// language: findQmFiles() asked for languages whose files exist in the first place.
TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang,
TranslationLoader::applicationTranslationPaths(translationPath));
return qTranslator.translate("i18n", DEFAULT_LANG_NAME);
}
void IntroPage::languageBoxChanged(int index)
{
SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString());

View file

@ -31,7 +31,6 @@ public:
private:
QStringList findQmFiles();
QString languageName(const QString &lang);
private:
QLabel *label, *languageLabel, *versionLabel;

View file

@ -406,6 +406,7 @@ if(TARGET Qt6::lrelease)
libcockatrice_utility
DEFINITIONS
TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}"
COCKATRICE_TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/cockatrice/translations"
)
add_dependencies(translation_loader_test translation_loader_test_qm)

View file

@ -2,6 +2,7 @@
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QLibraryInfo>
#include <QList>
#include <QSet>
@ -19,6 +20,11 @@
#define TRANSLATION_LOADER_TEST_DIR ""
#endif
// The client's translation files, whose language codes are the languages the language selector offers.
#ifndef COCKATRICE_TRANSLATIONS_DIR
#define COCKATRICE_TRANSLATIONS_DIR ""
#endif
namespace
{
const QString testContext = QStringLiteral("TranslationLoaderTest");
@ -233,6 +239,49 @@ TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoade
EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {}).isEmpty());
}
TEST(TranslationLoaderTest, LanguageDisplayNamesDoNotNeedATranslationFile)
{
// The regression this guards: the selector took every label from the language's own .qm file, so it
// went blank as soon as translators had not translated the "English" placeholder in the i18n context.
EXPECT_EQ(QStringLiteral("Deutsch (German)"), TranslationLoader::languageDisplayName(QStringLiteral("de")));
EXPECT_EQ(QStringLiteral("简体中文 (Chinese Simplified)"),
TranslationLoader::languageDisplayName(QStringLiteral("zh-Hans")));
EXPECT_EQ(QStringLiteral("English, arr! (Pirate English)"),
TranslationLoader::languageDisplayName(QStringLiteral("en@pirate")));
}
TEST(TranslationLoaderTest, EveryLanguageTheClientShipsHasItsOwnDisplayName)
{
// The shipped translation files are what the selector offers, so each of them has to end up with a
// label: an empty one is what made the dropdown look broken, and two equal ones are a coin flip.
const QDir translations(QStringLiteral(COCKATRICE_TRANSLATIONS_DIR));
const QStringList files = translations.entryList({QStringLiteral("cockatrice_*.ts")}, QDir::Files);
ASSERT_FALSE(files.isEmpty());
QStringList languages;
QSet<QString> displayNames;
for (const QString &file : files) {
const QString language = QFileInfo(file).completeBaseName().section(QLatin1Char('_'), 1);
const QString displayName = TranslationLoader::languageDisplayName(language);
EXPECT_FALSE(displayName.isEmpty()) << language.toStdString();
languages << language;
displayNames.insert(displayName);
}
EXPECT_EQ(files.size(), displayNames.size());
// The selector preselects this one for an unset setting, so it has to be offered at all.
EXPECT_TRUE(languages.contains(TranslationLoader::defaultLanguageCode()));
}
TEST(TranslationLoaderTest, UnknownLanguagesFallBackToTheNativeNameThenTheCode)
{
// A language added after the table, and a code Qt does not know either.
EXPECT_FALSE(TranslationLoader::languageDisplayName(QStringLiteral("sv_FI")).isEmpty());
EXPECT_EQ(QStringLiteral("zz_QQ"), TranslationLoader::languageDisplayName(QStringLiteral("zz_QQ")));
EXPECT_TRUE(TranslationLoader::languageDisplayName(QString()).isEmpty());
}
TEST(TranslationLoaderTest, LoadApplicationReportsAMissingTranslationDirectory)
{
QTemporaryDir emptyDir;