feat: support translation generation
This commit is contained in:
committed by
Chris Rizzitello
parent
71c1bb87ca
commit
c3f0b18df6
@ -47,12 +47,13 @@ runs:
|
||||
zypper refresh
|
||||
zypper install -y --force-resolution \
|
||||
cmake make ninja gcc-c++ rpm-build libopenssl-devel \
|
||||
glib2-devel libXtst-devel libxkbfile-devel qt6-base-devel qt6-tools-devel gtk3-devel \
|
||||
glib2-devel libXtst-devel libxkbfile-devel qt6-base-devel qt6-tools-devel \
|
||||
qt6-linguist-devel gtk3-devel \
|
||||
googletest-devel googlemock-devel libei-devel libportal-devel help2man
|
||||
elif [ ${{ inputs.like }} == "arch" ]; then
|
||||
pacman -Syu --noconfirm base-devel cmake ninja \
|
||||
gcc openssl glib2 libxtst libxkbfile gtest libei libportal \
|
||||
qt6-base qt6-tools qt6-svg gtk3 help2man doxygen graphviz rsync
|
||||
qt6-base qt6-tools qt6-svg qt6-translations qt6-declarative gtk3 help2man doxygen graphviz rsync
|
||||
else
|
||||
echo "Unknown like"
|
||||
fi
|
||||
|
||||
@ -166,6 +166,7 @@ configure_libs()
|
||||
|
||||
add_subdirectory(doc)
|
||||
add_subdirectory(src)
|
||||
add_subdirectory(translations)
|
||||
|
||||
# Install License, License is in the App Bundle on mac os (src/gui)
|
||||
if(WIN32)
|
||||
|
||||
@ -26,6 +26,7 @@ path = [
|
||||
, "src/apps/deskflow-server/deskflow-server.exe.manifest"
|
||||
, "src/apps/res/manpage.txt"
|
||||
, "src/apps/res/deskflow.plist.in"
|
||||
, "translations/*.ts"
|
||||
]
|
||||
SPDX-FileCopyrightText = "Deskflow Developers"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
@ -34,6 +34,7 @@ depends=(
|
||||
openssl
|
||||
qt6-base
|
||||
qt6-svg
|
||||
qt6-translations
|
||||
)
|
||||
|
||||
options=('!debug')
|
||||
|
||||
@ -30,6 +30,7 @@ CMake options:
|
||||
| ENABLE_COVERAGE | Enable test coverage | OFF | `gcov` |
|
||||
| SKIP_BUILD_TESTS | Skip running of tests at build time | OFF | |
|
||||
| VCPKG_QT | Build Qt w/ vcpkg (windows only) | OFF | |
|
||||
| CLEAN_TRS | Remove obsolete strings from tr files | OFF | |
|
||||
|
||||
Example cmake configuration.
|
||||
`cmake -S. -Bbuild -DCMAKE_INSTALL_PREFIX=<INSTALLPREFIX>`
|
||||
|
||||
@ -2,8 +2,8 @@ sonar.organization=deskflow
|
||||
sonar.projectKey=deskflow_deskflow
|
||||
sonar.sources=src/apps,src/lib
|
||||
sonar.tests=src/unittests
|
||||
sonar.exclusions=subprojects/**,build/**
|
||||
sonar.coverage.exclusions=subprojects/**,src/unittests/**,src/apps/deskflow-gui/**,src/apps/res/**
|
||||
sonar.exclusions=subprojects/**,build/**,translations/**
|
||||
sonar.coverage.exclusions=subprojects/**,src/unittests/**,src/apps/deskflow-gui/**,src/apps/res/**,translations/**
|
||||
sonar.cpd.exclusions=**/*Test*.cpp
|
||||
sonar.host.url=https://sonarcloud.io
|
||||
sonar.cfamily.compile-commands=build/compile_commands.json
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
#include "VersionInfo.h"
|
||||
#include "common/Constants.h"
|
||||
#include "common/ExitCodes.h"
|
||||
#include "common/I18N.h"
|
||||
#include "common/UrlConstants.h"
|
||||
#include "gui/Diagnostic.h"
|
||||
#include "gui/DotEnv.h"
|
||||
|
||||
@ -14,6 +14,8 @@ unset(SERVER_BINARY)
|
||||
add_library(common STATIC
|
||||
Common.h
|
||||
ExitCodes.h
|
||||
I18N.h
|
||||
I18N.cpp
|
||||
Settings.h
|
||||
Settings.cpp
|
||||
QSettingsProxy.cpp
|
||||
|
||||
195
src/lib/common/I18N.cpp
Normal file
195
src/lib/common/I18N.cpp
Normal file
@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Deskflow -- mouse and keyboard sharing utility
|
||||
* SPDX-FileCopyrightText: (C) 2025 Chris Rizzitello <sithlord48@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
|
||||
*/
|
||||
|
||||
#include "I18N.h"
|
||||
|
||||
#include "common/Constants.h"
|
||||
#include "common/Settings.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QTranslator>
|
||||
|
||||
I18N *I18N::instance()
|
||||
{
|
||||
static I18N m;
|
||||
return &m;
|
||||
}
|
||||
|
||||
I18N::I18N(QObject *parent) : QObject{parent}
|
||||
{
|
||||
const QList<QDir> appTrDirs{
|
||||
QDir(QStringLiteral("%1/%2").arg(QCoreApplication::applicationDirPath(), QStringLiteral("translations"))),
|
||||
QDir(QStringLiteral("%1/../translations").arg(QCoreApplication::applicationDirPath())),
|
||||
QDir(QStringLiteral("%1/../share/%2/translations").arg(QCoreApplication::applicationDirPath(), kAppId)),
|
||||
QDir(QStringLiteral("%1/.local/share/%2/translations").arg(QDir::homePath(), kAppId)),
|
||||
QDir(QStringLiteral("/usr/local/share/%1/translations").arg(kAppId)),
|
||||
QDir(QStringLiteral("/usr/share/%1/translations").arg(kAppId))
|
||||
};
|
||||
const QStringList appTrFilter{QStringLiteral("%1*.qm").arg(kAppId)};
|
||||
|
||||
for (const auto &dir : appTrDirs) {
|
||||
if (!dir.entryList(appTrFilter, QDir::Files, QDir::Name).isEmpty()) {
|
||||
m_appTrPath = dir.absolutePath();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_appTrPath.isEmpty()) {
|
||||
qInfo() << "no app translations found";
|
||||
}
|
||||
|
||||
const QList<QDir> qtTrDirs{
|
||||
QDir(QStringLiteral("%1/%2").arg(QCoreApplication::applicationDirPath(), QStringLiteral("translations"))),
|
||||
QDir(QStringLiteral("%1/../qt-depends/translations").arg(QCoreApplication::applicationDirPath())),
|
||||
QDir(QStringLiteral("%1/../share/qt/translations").arg(QCoreApplication::applicationDirPath())),
|
||||
QDir(QStringLiteral("%1/.local/share/%2/translations").arg(QDir::homePath(), QStringLiteral("qt"))),
|
||||
QDir(QStringLiteral("/usr/local/share/qt/translations")),
|
||||
QDir(QStringLiteral("/usr/share/qt/translations"))
|
||||
};
|
||||
const QStringList qtTrFilter{QStringLiteral("qt_*.qm")};
|
||||
|
||||
for (const auto &dir : qtTrDirs) {
|
||||
if (!dir.entryList(qtTrFilter, QDir::Files, QDir::Name).isEmpty()) {
|
||||
m_qtTrPath = dir.absolutePath();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (m_qtTrPath.isEmpty()) {
|
||||
qInfo() << "no qt translations found";
|
||||
}
|
||||
|
||||
detectLanguages();
|
||||
|
||||
if (Settings::value(Settings::Core::Language).isNull()) {
|
||||
auto appTranslator = new QTranslator(this);
|
||||
if (appTranslator->load(QLocale(), kAppId, "_", m_appTrPath)) {
|
||||
m_currentTranslations.append(appTranslator);
|
||||
QCoreApplication::installTranslator(appTranslator);
|
||||
}
|
||||
|
||||
m_currentLang = appTranslator->translate("i18n", "LocalizedName");
|
||||
if (m_currentLang.isEmpty())
|
||||
m_currentLang = QStringLiteral("English");
|
||||
|
||||
auto qtTranslator = new QTranslator(this);
|
||||
if (qtTranslator->load(QLocale(), QStringLiteral("qt"), "_", m_qtTrPath)) {
|
||||
m_currentTranslations.append(qtTranslator);
|
||||
QCoreApplication::installTranslator(qtTranslator);
|
||||
}
|
||||
} else {
|
||||
m_currentLang = Settings::value(Settings::Core::Language).toString();
|
||||
const auto translations = m_translations.value(m_currentLang);
|
||||
for (const auto &translation : translations) {
|
||||
auto translator = new QTranslator(this);
|
||||
if (translator->load(translation)) {
|
||||
m_currentTranslations.append(translator);
|
||||
QCoreApplication::installTranslator(translator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList I18N::detectedLanguages()
|
||||
{
|
||||
return instance()->m_translations.keys();
|
||||
}
|
||||
|
||||
QString I18N::currentLanguage()
|
||||
{
|
||||
return instance()->m_currentLang;
|
||||
}
|
||||
|
||||
void I18N::setLanguage(const QString &langName)
|
||||
{
|
||||
if (langName == instance()->m_currentLang) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instance()->m_translations.contains(langName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance()->m_currentLang = langName;
|
||||
Settings::setValue(Settings::Core::Language, langName);
|
||||
|
||||
for (const auto &translation : std::as_const(instance()->m_currentTranslations))
|
||||
QCoreApplication::removeTranslator(translation);
|
||||
|
||||
qDeleteAll(instance()->m_currentTranslations);
|
||||
instance()->m_currentTranslations.clear();
|
||||
|
||||
const auto translations = instance()->m_translations.value(langName);
|
||||
for (const auto &translation : translations) {
|
||||
auto translator = new QTranslator(instance());
|
||||
if (translator->load(translation)) {
|
||||
instance()->m_currentTranslations.append(translator);
|
||||
QCoreApplication::installTranslator(translator);
|
||||
}
|
||||
}
|
||||
|
||||
Q_EMIT instance()->languageChanged(langName);
|
||||
}
|
||||
|
||||
void I18N::reDetectLanguages()
|
||||
{
|
||||
instance()->detectLanguages();
|
||||
}
|
||||
|
||||
void I18N::detectLanguages()
|
||||
{
|
||||
const auto oldList = m_translations;
|
||||
m_translations.clear();
|
||||
|
||||
QStringList nameFilter = {QStringLiteral("%1_*.qm").arg(kAppId)};
|
||||
QMap<QString, QString> appTranslations;
|
||||
QMap<QString, QString> shortToNative;
|
||||
QStringList detectedLangCodes;
|
||||
QDir dir(m_appTrPath);
|
||||
QStringList langList = dir.entryList(nameFilter, QDir::Files, QDir::Name);
|
||||
|
||||
for (const QString &translation : std::as_const(langList)) {
|
||||
QTranslator translator;
|
||||
std::ignore = translator.load(translation, dir.absolutePath());
|
||||
const auto longCode = translator.language();
|
||||
//: Replace with your Language name
|
||||
//: This is a required string
|
||||
QString nativeLang = translator.translate("i18n", "LocalizedName");
|
||||
if (nativeLang.isEmpty())
|
||||
nativeLang = QStringLiteral("English");
|
||||
|
||||
QString shortCode;
|
||||
if (longCode.startsWith(QStringLiteral("zh")) || longCode.startsWith(QStringLiteral("pt")))
|
||||
shortCode = longCode;
|
||||
else
|
||||
shortCode = longCode.mid(0, 2);
|
||||
|
||||
appTranslations.insert(shortCode, translator.filePath());
|
||||
shortToNative.insert(shortCode, nativeLang);
|
||||
detectedLangCodes.append(QStringLiteral("qt_%1.qm").arg(shortCode));
|
||||
}
|
||||
|
||||
dir.setPath(m_qtTrPath);
|
||||
const static auto qtTrNameLen = 3; // length of qt_
|
||||
langList = dir.entryList(detectedLangCodes, QDir::Files, QDir::Name);
|
||||
|
||||
QMap<QString, QString> qtTranslations;
|
||||
for (const QString &translation : std::as_const(langList)) {
|
||||
QString lang = translation.mid(qtTrNameLen, translation.lastIndexOf('.') - qtTrNameLen);
|
||||
qtTranslations.insert(lang, QStringLiteral("%1/%2").arg(m_qtTrPath, translation));
|
||||
}
|
||||
|
||||
const QStringList keys = appTranslations.keys();
|
||||
for (const QString &lang : keys)
|
||||
m_translations.insert(shortToNative.value(lang), {appTranslations.value(lang), qtTranslations.value(lang)});
|
||||
|
||||
if (oldList != m_translations)
|
||||
Q_EMIT langaugesChanged(m_translations.keys());
|
||||
}
|
||||
70
src/lib/common/I18N.h
Normal file
70
src/lib/common/I18N.h
Normal file
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Deskflow -- mouse and keyboard sharing utility
|
||||
* SPDX-FileCopyrightText: (C) 2025 Chris Rizzitello <sithlord48@gmail.com>
|
||||
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
|
||||
class QTranslator;
|
||||
/**
|
||||
* @brief The I18N singleton class handles detection and loading of translation files
|
||||
*/
|
||||
class I18N : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static I18N *instance();
|
||||
/**
|
||||
* @brief detectedLanguages
|
||||
* @return List of detected languages (native names: English, Español etc..)
|
||||
*/
|
||||
static QStringList detectedLanguages();
|
||||
|
||||
/**
|
||||
* @brief currentLanguage
|
||||
* @return The current language string (native name: English, Español etc..)
|
||||
*/
|
||||
static QString currentLanguage();
|
||||
|
||||
/**
|
||||
* @brief setLanguage Sets the current language
|
||||
* @param langName The language name must be an is 639-1 name
|
||||
*/
|
||||
static void setLanguage(const QString &langName);
|
||||
|
||||
/**
|
||||
* @brief detectLanguages Detect new language files
|
||||
*/
|
||||
static void reDetectLanguages();
|
||||
|
||||
Q_SIGNALS:
|
||||
/**
|
||||
* @brief languageChanged Emitted when the current language changes
|
||||
* @param language The current language (native name, i.e English, Español)
|
||||
*/
|
||||
void languageChanged(const QString language);
|
||||
/**
|
||||
* @brief langaugesChanged Emitted when the detected languages changes
|
||||
* @param languages The current list of languages (native names i.e English, Español..)
|
||||
*/
|
||||
void langaugesChanged(const QStringList languages);
|
||||
|
||||
private:
|
||||
explicit I18N(QObject *parent = nullptr);
|
||||
|
||||
I18N *operator=(I18N &other) = delete;
|
||||
I18N(const I18N &other) = delete;
|
||||
~I18N() override = default;
|
||||
void detectLanguages();
|
||||
|
||||
QMap<QString, QStringList> m_translations;
|
||||
QList<QTranslator *> m_currentTranslations;
|
||||
QString m_currentLang = QStringLiteral("English");
|
||||
QString m_appTrPath;
|
||||
QString m_qtTrPath;
|
||||
};
|
||||
@ -54,6 +54,7 @@ public:
|
||||
inline static const auto Display = QStringLiteral("core/display");
|
||||
inline static const auto RestartOnFailure = QStringLiteral("core/restartOnFailure");
|
||||
inline static const auto UseHooks = QStringLiteral("core/useHooks");
|
||||
inline static const auto Language = QStringLiteral("core/language");
|
||||
};
|
||||
struct Daemon
|
||||
{
|
||||
@ -179,6 +180,7 @@ private:
|
||||
, Settings::Core::Display
|
||||
, Settings::Core::RestartOnFailure
|
||||
, Settings::Core::UseHooks
|
||||
, Settings::Core::Language
|
||||
, Settings::Daemon::Command
|
||||
, Settings::Daemon::Elevate
|
||||
, Settings::Daemon::LogFile
|
||||
|
||||
50
translations/CMakeLists.txt
Normal file
50
translations/CMakeLists.txt
Normal file
@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: Chris Rizzitello <sithlord48@gmail.com>
|
||||
# SPDX-License-Identifier: MIT
|
||||
set_directory_properties(PROPERTIES CLEAN_NO_CUSTOM 1)
|
||||
option(CLEAN_TRS "Clean obsolete translations from tr files" OFF)
|
||||
find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED NO_MODULE COMPONENTS LinguistTools)
|
||||
|
||||
# To add a new language Add 639 shortname
|
||||
set (${CMAKE_PROJECT_NAME}_TRS
|
||||
${CMAKE_PROJECT_NAME}_es.ts
|
||||
)
|
||||
|
||||
set(TR_OPTIONS -no-ui-lines -locations none -silent)
|
||||
if(CLEAN_TRS)
|
||||
list(APPEND TR_OPTIONS -no-obsolete)
|
||||
endif()
|
||||
|
||||
# English will have only plurals
|
||||
qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${CMAKE_PROJECT_NAME}_en.ts OPTIONS -pluralonly ${TR_OPTIONS})
|
||||
|
||||
# Other languages contain the full set of strings.
|
||||
qt_create_translation(TRS ${CMAKE_SOURCE_DIR}/src ${${CMAKE_PROJECT_NAME}_TRS} OPTIONS ${TR_OPTIONS})
|
||||
|
||||
#ensure that the targets are built always
|
||||
add_custom_target(app_translations ALL DEPENDS ${TRS})
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
install(FILES ${TRS} DESTINATION share/${CMAKE_PROJECT_NAME}/translations)
|
||||
elseif(WIN32)
|
||||
install(FILES ${TRS} DESTINATION translations)
|
||||
elseif(APPLE)
|
||||
#install our translations
|
||||
set(MAC_LANG_PATH ${CMAKE_PROJECT_PROPER_NAME}.app/Contents/MacOS/translations)
|
||||
install(FILES ${TRS} DESTINATION ${MAC_LANG_PATH})
|
||||
|
||||
# find the qt translation files not deployed by macdeployqt
|
||||
get_target_property(lupdate_executable Qt6::lupdate IMPORTED_LOCATION)
|
||||
get_filename_component(_qt_bin_dir "${lupdate_executable}" DIRECTORY)
|
||||
file(REAL_PATH "${_qt_bin_dir}/../" QT_ROOT_DIR)
|
||||
find_file(_QT_QM_FILE NAMES qtbase_en.qm PATHS ${QT_ROOT_DIR} PATH_SUFFIXES "translations" "share/qt/translations" REQUIRED)
|
||||
get_filename_component(MAC_QT_LANG_PATH "${_QT_QM_FILE}" DIRECTORY)
|
||||
|
||||
# install each lang file and rename to the same name
|
||||
# rename does not work with a list so foreach is used instead
|
||||
install(FILES ${MAC_QT_LANG_PATH}/qtbase_en.qm DESTINATION ${MAC_LANG_PATH} RENAME qt_en.qm)
|
||||
foreach(LANG ${${CMAKE_PROJECT_NAME}_TRS})
|
||||
string(REPLACE "${CMAKE_PROJECT_NAME}_" "" LANG ${LANG})
|
||||
string(REPLACE ".ts" "" LANG ${LANG})
|
||||
install(FILES ${MAC_QT_LANG_PATH}/qtbase_${LANG}.qm DESTINATION ${MAC_LANG_PATH} RENAME qt_${LANG}.qm)
|
||||
endforeach()
|
||||
endif()
|
||||
15
translations/deskflow_en.ts
Normal file
15
translations/deskflow_en.ts
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="en_US">
|
||||
<context>
|
||||
<name>MainWindow</name>
|
||||
<message numerus="yes">
|
||||
<source>%1 is connected, with %n client(s): %2</source>
|
||||
<extracomment>Shown when in server mode and at least 1 client is connected %1 is replaced by the app name %2 will be a list of at least one client %n will be replaced by the number of clients (n is >=1), it is not requried to be in the translation</extracomment>
|
||||
<translation>
|
||||
<numerusform>%1 is connected, with a client: %2</numerusform>
|
||||
<numerusform>%1 is connected, with %n clients: %2</numerusform>
|
||||
</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Reference in New Issue
Block a user