aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt25
-rw-r--r--src/config.cpp72
-rw-r--r--src/keymap.cpp6
-rw-r--r--src/keymap.h6
-rw-r--r--src/main.cpp31
-rw-r--r--src/tagrulesdialog.cpp25
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