From 5a3f827a01d1902a0dadc5debb4200138d4af885 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 15 Aug 2026 12:37:41 +0200 Subject: feat(i18n): wire translations and ship an Italian one (item 22) Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 --- src/CMakeLists.txt | 25 ++++++++++++++++++ src/config.cpp | 72 ++++++++++++++++++++++++++++++++++---------------- src/keymap.cpp | 6 ++--- src/keymap.h | 6 +++++ src/main.cpp | 31 ++++++++++++++++++++++ src/tagrulesdialog.cpp | 25 ++++++++++++------ 6 files changed, 131 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ee1f621..197366d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -46,6 +46,31 @@ target_link_libraries(qtmaildir PRIVATE qtmaildir_lib) install(TARGETS qtmaildir RUNTIME DESTINATION bin) +# The .ts is tracked; the .qm is generated and is not. Built beside the binary +# so a build tree runs translated without installing, and installed to the +# AppDataLocation main.cpp searches. Building the .qm without installing it +# reproduces exactly the state item 22 exists to fix, so the install rule is +# part of this and not a follow-up. +if(Qt6LinguistTools_FOUND) + set(QTMAILDIR_TS ${CMAKE_SOURCE_DIR}/translations/qtmaildir_it_IT.ts) + set(QTMAILDIR_QM ${CMAKE_CURRENT_BINARY_DIR}/translations/qtmaildir_it_IT.qm) + add_custom_command( + OUTPUT ${QTMAILDIR_QM} + COMMAND ${CMAKE_COMMAND} -E make_directory + ${CMAKE_CURRENT_BINARY_DIR}/translations + COMMAND Qt6::lrelease ${QTMAILDIR_TS} -qm ${QTMAILDIR_QM} + DEPENDS ${QTMAILDIR_TS} + COMMENT "Compiling Italian translation" + VERBATIM) + add_custom_target(qtmaildir_translations ALL DEPENDS ${QTMAILDIR_QM}) + add_dependencies(qtmaildir qtmaildir_translations) + install(FILES ${QTMAILDIR_QM} + DESTINATION share/qtmaildir/translations) +else() + message(STATUS + "Qt6 LinguistTools not found; building without translations") +endif() + # The icon goes into the hicolor theme under its scalable directory, which is # where a desktop environment looks for the Icon= name in the .desktop entry. install(FILES ${CMAKE_SOURCE_DIR}/assets/icons/qtmaildir.svg diff --git a/src/config.cpp b/src/config.cpp index 9bed74a..266ed39 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -208,14 +208,14 @@ void Config::load(const QString &path) // first. What was missing is the report. The value parses, so // nothing ever said the 500 in the file is not what is on screen. if (value < MessageView::kMinZoom || value > MessageView::kMaxZoom) { - addProblem(QStringLiteral("Message zoom %1 is outside %2 to %3; " + addProblem(tr("Message zoom %1 is outside %2 to %3; " "using the nearest allowed value.") .arg(value) .arg(MessageView::kMinZoom) .arg(MessageView::kMaxZoom)); } } else { - addProblem(QStringLiteral("Message zoom '%1' is not a number; " + addProblem(tr("Message zoom '%1' is not a number; " "using the default.") .arg(zoom.toString())); } @@ -235,14 +235,14 @@ void Config::load(const QString &path) bool ok = false; const int value = iconSize.toString().trimmed().toInt(&ok); if (!ok) { - addProblem(QStringLiteral("Toolbar icon size '%1' is not a number; " + addProblem(tr("Toolbar icon size '%1' is not a number; " "using %2.") .arg(iconSize.toString()) .arg(m_toolbarIconSize)); } else if (value < kMinToolbarIconSize || value > kMaxToolbarIconSize) { m_toolbarIconSize = qBound(kMinToolbarIconSize, value, kMaxToolbarIconSize); - addProblem(QStringLiteral("Toolbar icon size %1 is outside %2 to " + addProblem(tr("Toolbar icon size %1 is outside %2 to " "%3; using %4.") .arg(value) .arg(kMinToolbarIconSize) @@ -268,7 +268,7 @@ void Config::load(const QString &path) const QDateTime b(QDate(2019, 1, 3), QTime(4, 5)); const QLocale locale = QLocale::system(); if (locale.toString(a, dateFormat) == locale.toString(b, dateFormat)) { - addProblem(QStringLiteral("Date format '%1' contains no date or " + addProblem(tr("Date format '%1' contains no date or " "time field; using the system format.") .arg(dateFormat)); } else { @@ -297,7 +297,7 @@ void Config::load(const QString &path) } else { // Naming the accepted values, since a typo here silently changes what // happens to unsynced work at exit. - addProblem(QStringLiteral("Unknown sync_on_exit '%1'; expected ask, " + addProblem(tr("Unknown sync_on_exit '%1'; expected ask, " "always or never. Using ask.") .arg(syncExit)); } @@ -309,7 +309,7 @@ void Config::load(const QString &path) if (ok) { m_markReadDelayMs = value; } else { - addProblem(QStringLiteral("Mark-read delay '%1' is not a number; " + addProblem(tr("Mark-read delay '%1' is not a number; " "using the default.") .arg(markRead.toString())); } @@ -326,7 +326,7 @@ void Config::load(const QString &path) if (ok) { m_autoSyncDelayMs = value; } else { - addProblem(QStringLiteral("Auto-sync delay '%1' is not a number; " + addProblem(tr("Auto-sync delay '%1' is not a number; " "using the default.") .arg(autoSync.toString())); } @@ -352,7 +352,7 @@ void Config::load(const QString &path) // Skip only the bad entry: one typo must not cost the user the rest // of the list, and the built-ins are appended to regardless. if (value.isEmpty()) { - addProblem(QStringLiteral("[completion] extra_mimetypes: entry '%1' " + addProblem(tr("[completion] extra_mimetypes: entry '%1' " "has no mimetype; ignoring it.") .arg(entry)); continue; @@ -453,18 +453,29 @@ void Config::load(const QString &path) // entry for an account that does not exist and would sit on "All accounts" // without saying why. if (!m_startupAccount.isEmpty() && !account(m_startupAccount).isValid()) { - addProblem(QStringLiteral("Startup account '%1' is not a configured " + addProblem(tr("Startup account '%1' is not a configured " "account; starting on all accounts.") .arg(m_startupAccount)); m_startupAccount.clear(); } - if (m_startupQueryWasSet && !m_savedQueries.isEmpty() - && startupSavedQuery().name.compare(m_startupQuery, - Qt::CaseInsensitive) != 0) { - addProblem(QStringLiteral("Startup query '%1' is not a saved query; " - "opening '%2' instead.") - .arg(m_startupQuery, startupSavedQuery().name)); + // Asks whether the resolved query matched on EITHER a name or a generator, + // rather than comparing the name alone. Comparing names warned about a + // config that was working: `startup_query = Inbox` resolves through the + // generator under a translated UI, where the filter's name is "In arrivo", + // so the app opened the right view and reported it as wrong. + if (m_startupQueryWasSet && !m_savedQueries.isEmpty()) { + const SavedQuery resolved = startupSavedQuery(); + const bool matched = + resolved.name.compare(m_startupQuery, Qt::CaseInsensitive) == 0 + || (!resolved.generated.isEmpty() + && resolved.generated.compare(m_startupQuery, + Qt::CaseInsensitive) == 0); + if (!matched) { + addProblem(tr("Startup query '%1' is not a saved query; " + "opening '%2' instead.") + .arg(m_startupQuery, resolved.name)); + } } } @@ -513,14 +524,14 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings) // Order is alphabetical here because childKeys() is genuinely all the // INI knows. The user reorders once and it sticks from then on. if (!m_savedQueries.isEmpty() && !saveSavedQueries()) { - addProblem(QStringLiteral("Could not write saved queries to %1.") + addProblem(tr("Could not write saved queries to %1.") .arg(m_queriesPath)); } return; } if (!file.open(QIODevice::ReadOnly)) { - addProblem(QStringLiteral("Could not read %1: %2.") + addProblem(tr("Could not read %1: %2.") .arg(m_queriesPath, file.errorString())); m_queriesRefused = true; return; @@ -532,7 +543,7 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings) file.close(); if (error.error != QJsonParseError::NoError || !document.isObject()) { - addProblem(QStringLiteral("%1 is not valid JSON: %2.") + addProblem(tr("%1 is not valid JSON: %2.") .arg(m_queriesPath, error.errorString())); m_queriesRefused = true; return; @@ -545,7 +556,7 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings) // Refused rather than guessed at, and the refusal blocks the save: // rewriting a newer document with this build's reading of it would // destroy whatever the newer build stored. - addProblem(QStringLiteral("%1 has format version %2; this build " + addProblem(tr("%1 has format version %2; this build " "understands %3. Saved queries were not " "loaded.") .arg(m_queriesPath) @@ -585,13 +596,13 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings) // dropping the row here would delete it from the file on the next // save, which is the same data loss the unknown-field handling // exists to prevent. - addProblem(QStringLiteral("Saved query '%1' uses an unknown " + addProblem(tr("Saved query '%1' uses an unknown " "generator '%2' and will find nothing.") .arg(query.name, query.generated)); } if (query.name.isEmpty()) { - addProblem(QStringLiteral("A saved query in %1 has no name and was " + addProblem(tr("A saved query in %1 has no name and was " "skipped.").arg(m_queriesPath)); continue; } @@ -813,10 +824,25 @@ SavedQuery Config::startupSavedQuery() const // matched nothing once item 93 shipped Inbox as a filter and the duplicated // saved query was removed, and the app started on whatever query happened // to be first in the file. - for (const SavedQuery &filter : builtinFilters()) { + // + // Matched on the GENERATOR as well as the name, and the generator is what + // makes this survive translation. A filter's name is a translated label, so + // under LANG=it_IT the Inbox filter is called "In arrivo" and a config + // reading `startup_query = Inbox` matched nothing, warned, and fell back to + // another filter: the user's startup view changed because the UI language + // did. The generator is stored in queries.json and matched against a closed + // set, so it is wire format and identical in every locale. Names are still + // tried first, so a translated name a user copied out of their own UI keeps + // working. + const QList filters = builtinFilters(); + for (const SavedQuery &filter : filters) { if (filter.name.compare(m_startupQuery, Qt::CaseInsensitive) == 0) return filter; } + for (const SavedQuery &filter : filters) { + if (filter.generated.compare(m_startupQuery, Qt::CaseInsensitive) == 0) + return filter; + } // Named nothing that exists. Falling back to m_savedQueries.first() is what // this used to do and it is worse than it looks: after the duplicated diff --git a/src/keymap.cpp b/src/keymap.cpp index 52b015f..f6ef6a4 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -266,13 +266,13 @@ void KeyMap::loadOverrides(QSettings &settings) const QKeySequence sequence = normalizeSequence(key); if (sequence.isEmpty()) { m_warnings.append( - QStringLiteral("Unparseable key sequence '%1' in [keys]").arg(key)); + tr("Unparseable key sequence '%1' in [keys]").arg(key)); continue; } if (!known.contains(action)) { m_warnings.append( - QStringLiteral("Unknown action '%1' bound to '%2' in [keys]") + tr("Unknown action '%1' bound to '%2' in [keys]") .arg(action, key)); continue; } @@ -280,7 +280,7 @@ void KeyMap::loadOverrides(QSettings &settings) const auto previous = seenThisPass.constFind(sequence); if (previous != seenThisPass.constEnd()) { m_warnings.append( - QStringLiteral("Key sequence '%1' bound to both '%2' and '%3' " + tr("Key sequence '%1' bound to both '%2' and '%3' " "in [keys]; keeping '%2'") .arg(key, previous.value(), action)); continue; diff --git a/src/keymap.h b/src/keymap.h index f0addf5..81e1813 100644 --- a/src/keymap.h +++ b/src/keymap.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -30,6 +31,11 @@ class QSettings; /// class has no dependency on the widgets that implement the actions. class KeyMap { + // Not a QObject, so tr() comes from here. Its warnings are user-facing: + // MainWindow joins them with Config's into the status label and the + // "Configuration problems" modal. + Q_DECLARE_TR_FUNCTIONS(KeyMap) + public: /// Every action name the application understands. loadOverrides() rejects /// anything not in this set, so a typo in the config cannot bind silently. diff --git a/src/main.cpp b/src/main.cpp index 2804196..59f39be 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,8 +17,13 @@ */ #include +#include +#include #include +#include #include +#include +#include #include #include @@ -84,6 +89,32 @@ int main(int argc, char *argv[]) app.setWindowIcon(QIcon(QStringLiteral(":/icons/qtmaildir.svg"))); app.setDesktopFileName(QStringLiteral("qtmaildir")); + // On main's stack deliberately: a QTranslator must outlive exec(), and one + // scoped to a helper function unloads on return, silently reverting every + // string to English. Installed before Config is loaded, because config + // warnings are generated at load time and are themselves translated. + // + // QLocale() reads the system locale, so LANG=it_IT.UTF-8 selects the file + // with no config key of our own. A missing .qm returns false and the app + // runs in English, which is the correct failure rather than a fatal one. + QTranslator translator; + QStringList translationDirs; + // Beside the binary first, so a build tree works without installing. + translationDirs << QCoreApplication::applicationDirPath() + + QStringLiteral("/translations"); + const QStringList dataDirs = + QStandardPaths::standardLocations(QStandardPaths::AppDataLocation); + for (const QString &dir : dataDirs) + translationDirs << dir + QStringLiteral("/translations"); + + for (const QString &dir : std::as_const(translationDirs)) { + if (translator.load(QLocale(), QStringLiteral("qtmaildir"), + QStringLiteral("_"), dir)) { + app.installTranslator(&translator); + break; + } + } + // Fail loudly on an ABI mismatch rather than crashing later. if (LIBNOTMUCH_MAJOR_VERSION < 5) { QMessageBox::critical(nullptr, QObject::tr("qtmaildir"), diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp index ffcdf29..f208527 100644 --- a/src/tagrulesdialog.cpp +++ b/src/tagrulesdialog.cpp @@ -62,15 +62,24 @@ QStringList splitTags(const QString &text) struct FieldEntry { RuleTerm::Field field; const char *label; }; +// QT_TRANSLATE_NOOP, naming the context explicitly, rather than QT_TR_NOOP. +// These literals sit in an anonymous namespace, where lupdate reports "tr() +// cannot be called without context" and extracts NOTHING, while the use site +// below calls TagRulesDialog::tr() on them. The result compiles, reads +// correctly, and leaves all eight labels untranslatable: no .ts file ever +// contained them. Q_DECLARE_TR_FUNCTIONS on a neighbouring class does not fix +// it either (measured: still 0 extracted) because lupdate needs the context on +// the literal itself. The context named here must stay TagRulesDialog to match +// the tr() that reads it. const FieldEntry kFields[] = { - {RuleTerm::From, QT_TR_NOOP("From")}, - {RuleTerm::To, QT_TR_NOOP("To")}, - {RuleTerm::Cc, QT_TR_NOOP("Cc")}, - {RuleTerm::Subject, QT_TR_NOOP("Subject")}, - {RuleTerm::Tag, QT_TR_NOOP("Tag")}, - {RuleTerm::Folder, QT_TR_NOOP("Folder")}, - {RuleTerm::Attachment, QT_TR_NOOP("Attachment")}, - {RuleTerm::Date, QT_TR_NOOP("Date")}, + {RuleTerm::From, QT_TRANSLATE_NOOP("TagRulesDialog", "From")}, + {RuleTerm::To, QT_TRANSLATE_NOOP("TagRulesDialog", "To")}, + {RuleTerm::Cc, QT_TRANSLATE_NOOP("TagRulesDialog", "Cc")}, + {RuleTerm::Subject, QT_TRANSLATE_NOOP("TagRulesDialog", "Subject")}, + {RuleTerm::Tag, QT_TRANSLATE_NOOP("TagRulesDialog", "Tag")}, + {RuleTerm::Folder, QT_TRANSLATE_NOOP("TagRulesDialog", "Folder")}, + {RuleTerm::Attachment, QT_TRANSLATE_NOOP("TagRulesDialog", "Attachment")}, + {RuleTerm::Date, QT_TRANSLATE_NOOP("TagRulesDialog", "Date")}, }; } // namespace -- cgit v1.2.3