feat: support translation generation

This commit is contained in:
sithlord48
2025-10-14 22:39:45 -04:00
committed by Chris Rizzitello
parent 71c1bb87ca
commit c3f0b18df6
13 changed files with 344 additions and 4 deletions

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -34,6 +34,7 @@ depends=(
openssl
qt6-base
qt6-svg
qt6-translations
)
options=('!debug')

View File

@ -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>`

View File

@ -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

View File

@ -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"

View File

@ -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
View 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
View 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;
};

View File

@ -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

View 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()

View 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 &gt;=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>