diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 25 | ||||
| -rw-r--r-- | src/config.cpp | 72 | ||||
| -rw-r--r-- | src/keymap.cpp | 6 | ||||
| -rw-r--r-- | src/keymap.h | 6 | ||||
| -rw-r--r-- | src/main.cpp | 31 | ||||
| -rw-r--r-- | src/tagrulesdialog.cpp | 25 |
6 files changed, 131 insertions, 34 deletions
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<SavedQuery> 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 <QCoreApplication> #include <QHash> #include <QKeySequence> #include <QList> @@ -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 <QApplication> +#include <QCoreApplication> +#include <QDir> #include <QIcon> +#include <QLocale> #include <QMessageBox> +#include <QStandardPaths> +#include <QTranslator> #include <QWebEngineUrlScheme> #include <notmuch.h> @@ -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 |
