summaryrefslogtreecommitdiff
path: root/meshmc/launcher/translations/TranslationsModel.cpp
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
commit31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch)
tree8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/translations/TranslationsModel.cpp
parent934382c8a1ce738589dee9ee0f14e1cec812770e (diff)
parentfad6a1066616b69d7f5fef01178efdf014c59537 (diff)
downloadProject-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.tar.gz
Project-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.zip
Add 'meshmc/' from commit 'fad6a1066616b69d7f5fef01178efdf014c59537'
git-subtree-dir: meshmc git-subtree-mainline: 934382c8a1ce738589dee9ee0f14e1cec812770e git-subtree-split: fad6a1066616b69d7f5fef01178efdf014c59537
Diffstat (limited to 'meshmc/launcher/translations/TranslationsModel.cpp')
-rw-r--r--meshmc/launcher/translations/TranslationsModel.cpp610
1 files changed, 610 insertions, 0 deletions
diff --git a/meshmc/launcher/translations/TranslationsModel.cpp b/meshmc/launcher/translations/TranslationsModel.cpp
new file mode 100644
index 0000000000..3364be6fa4
--- /dev/null
+++ b/meshmc/launcher/translations/TranslationsModel.cpp
@@ -0,0 +1,610 @@
+/* SPDX-FileCopyrightText: 2026 Project Tick
+ * SPDX-FileContributor: Project Tick
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ * MeshMC - A Custom Launcher for Minecraft
+ * Copyright (C) 2026 Project Tick
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "TranslationsModel.h"
+
+#include <QCoreApplication>
+#include <QTranslator>
+#include <QLocale>
+#include <QDir>
+#include <QLibraryInfo>
+#include <QDebug>
+
+#include "FileSystem.h"
+#include "net/NetJob.h"
+#include "net/ChecksumValidator.h"
+#include "BuildConfig.h"
+#include "Json.h"
+
+#include "POTranslator.h"
+
+#include "Application.h"
+
+const static QLatin1String defaultLangCode("en_US");
+
+enum class FileType { NONE, QM, PO };
+
+struct Language {
+ Language()
+ {
+ updated = true;
+ }
+ Language(const QString& _key)
+ {
+ key = _key;
+ locale = QLocale(key);
+ updated = (key == defaultLangCode);
+ }
+
+ QString languageName() const
+ {
+ QString result;
+ if (key == "ja_KANJI") {
+ result = locale.nativeLanguageName() + u8" (漢字)";
+ } else if (key == "es_UY") {
+ result = u8"español de Latinoamérica";
+ } else {
+ result = locale.nativeLanguageName();
+ }
+ return result;
+ }
+
+ float percentTranslated() const
+ {
+ if (total == 0) {
+ return 100.0f;
+ }
+ return 100.0f * float(translated) / float(total);
+ }
+
+ void setTranslationStats(unsigned _translated, unsigned _untranslated,
+ unsigned _fuzzy)
+ {
+ translated = _translated;
+ untranslated = _untranslated;
+ fuzzy = _fuzzy;
+ total = translated + untranslated + fuzzy;
+ }
+
+ bool isOfSameNameAs(const Language& other) const
+ {
+ return key == other.key;
+ }
+
+ bool isIdenticalTo(const Language& other) const
+ {
+ return (key == other.key && file_name == other.file_name &&
+ file_size == other.file_size && file_sha1 == other.file_sha1 &&
+ translated == other.translated && fuzzy == other.fuzzy &&
+ total == other.fuzzy && localFileType == other.localFileType);
+ }
+
+ Language& apply(Language& other)
+ {
+ if (!isOfSameNameAs(other)) {
+ return *this;
+ }
+ file_name = other.file_name;
+ file_size = other.file_size;
+ file_sha1 = other.file_sha1;
+ translated = other.translated;
+ fuzzy = other.fuzzy;
+ total = other.total;
+ localFileType = other.localFileType;
+ return *this;
+ }
+
+ QString key;
+ QLocale locale;
+ bool updated;
+
+ QString file_name = QString();
+ std::size_t file_size = 0;
+ QString file_sha1 = QString();
+
+ unsigned translated = 0;
+ unsigned untranslated = 0;
+ unsigned fuzzy = 0;
+ unsigned total = 0;
+
+ FileType localFileType = FileType::NONE;
+};
+
+struct TranslationsModel::Private {
+ QDir m_dir;
+
+ // initial state is just english
+ QVector<Language> m_languages = {Language(defaultLangCode)};
+
+ QString m_selectedLanguage = defaultLangCode;
+ std::unique_ptr<QTranslator> m_qt_translator;
+ std::unique_ptr<QTranslator> m_app_translator;
+
+ Net::Download::Ptr m_index_task;
+ QString m_downloadingTranslation;
+ NetJob::Ptr m_dl_job;
+ NetJob::Ptr m_index_job;
+ QString m_nextDownload;
+
+ std::unique_ptr<POTranslator> m_po_translator;
+ QFileSystemWatcher* watcher;
+};
+
+TranslationsModel::TranslationsModel(QString path, QObject* parent)
+ : QAbstractListModel(parent)
+{
+ d.reset(new Private);
+ d->m_dir.setPath(path);
+ FS::ensureFolderPathExists(path);
+ reloadLocalFiles();
+
+ d->watcher = new QFileSystemWatcher(this);
+ connect(d->watcher, &QFileSystemWatcher::directoryChanged, this,
+ &TranslationsModel::translationDirChanged);
+ d->watcher->addPath(d->m_dir.canonicalPath());
+}
+
+TranslationsModel::~TranslationsModel() {}
+
+void TranslationsModel::translationDirChanged(const QString& path)
+{
+ qDebug() << "Dir changed:" << path;
+ reloadLocalFiles();
+ selectLanguage(selectedLanguage());
+}
+
+void TranslationsModel::indexReceived()
+{
+ qDebug() << "Got translations index!";
+ d->m_index_job.reset();
+ if (d->m_selectedLanguage != defaultLangCode) {
+ downloadTranslation(d->m_selectedLanguage);
+ }
+}
+
+namespace
+{
+ void readIndex(const QString& path, QMap<QString, Language>& languages)
+ {
+ QByteArray data;
+ try {
+ data = FS::read(path);
+ } catch (const Exception& e) {
+ qCritical()
+ << "Translations Download Failed: index file not readable";
+ return;
+ }
+
+ try {
+ auto toplevel_doc = Json::requireDocument(data);
+ auto doc = Json::requireObject(toplevel_doc);
+ auto file_type = Json::requireString(doc, "file_type");
+ if (file_type != "MMC-TRANSLATION-INDEX") {
+ qCritical() << "Translations Download Failed: index file is of "
+ "unknown file type"
+ << file_type;
+ return;
+ }
+ auto version = Json::requireInteger(doc, "version");
+ if (version > 2) {
+ qCritical() << "Translations Download Failed: index file is of "
+ "unknown format version"
+ << file_type;
+ return;
+ }
+ auto langObjs = Json::requireObject(doc, "languages");
+ for (auto iter = langObjs.begin(); iter != langObjs.end(); iter++) {
+ Language lang(iter.key());
+
+ auto langObj = Json::requireObject(iter.value());
+ lang.setTranslationStats(
+ Json::ensureInteger(langObj, "translated", 0),
+ Json::ensureInteger(langObj, "untranslated", 0),
+ Json::ensureInteger(langObj, "fuzzy", 0));
+ lang.file_name = Json::requireString(langObj, "file");
+ lang.file_sha1 = Json::requireString(langObj, "sha1");
+ lang.file_size = Json::requireInteger(langObj, "size");
+
+ languages.insert(lang.key, lang);
+ }
+ } catch (Json::JsonException& e) {
+ qCritical() << "Translations Download Failed: index file could not "
+ "be parsed as json";
+ }
+ }
+} // namespace
+
+void TranslationsModel::reloadLocalFiles()
+{
+ QMap<QString, Language> languages = {
+ {defaultLangCode, Language(defaultLangCode)}};
+
+ readIndex(d->m_dir.absoluteFilePath("index_v2.json"), languages);
+ auto entries = d->m_dir.entryInfoList({"mmc_*.qm", "*.po"},
+ QDir::Files | QDir::NoDotAndDotDot);
+ for (auto& entry : entries) {
+ auto completeSuffix = entry.completeSuffix();
+ QString langCode;
+ FileType fileType = FileType::NONE;
+ if (completeSuffix == "qm") {
+ langCode = entry.baseName().remove(0, 4);
+ fileType = FileType::QM;
+ } else if (completeSuffix == "po") {
+ langCode = entry.baseName();
+ fileType = FileType::PO;
+ } else {
+ continue;
+ }
+
+ auto langIter = languages.find(langCode);
+ if (langIter != languages.end()) {
+ auto& language = *langIter;
+ if (int(fileType) > int(language.localFileType)) {
+ language.localFileType = fileType;
+ }
+ } else {
+ if (fileType == FileType::PO) {
+ Language localFound(langCode);
+ localFound.localFileType = FileType::PO;
+ languages.insert(langCode, localFound);
+ }
+ }
+ }
+
+ // changed and removed languages
+ for (auto iter = d->m_languages.begin(); iter != d->m_languages.end();) {
+ auto& language = *iter;
+ auto row = iter - d->m_languages.begin();
+
+ auto updatedLanguageIter = languages.find(language.key);
+ if (updatedLanguageIter != languages.end()) {
+ if (language.isIdenticalTo(*updatedLanguageIter)) {
+ languages.remove(language.key);
+ } else {
+ language.apply(*updatedLanguageIter);
+ emit dataChanged(index(row), index(row));
+ languages.remove(language.key);
+ }
+ iter++;
+ } else {
+ beginRemoveRows(QModelIndex(), row, row);
+ iter = d->m_languages.erase(iter);
+ endRemoveRows();
+ }
+ }
+ // added languages
+ if (languages.isEmpty()) {
+ return;
+ }
+ beginResetModel();
+ for (auto& language : languages) {
+ d->m_languages.append(language);
+ }
+ std::sort(d->m_languages.begin(), d->m_languages.end(),
+ [](const Language& a, const Language& b) {
+ return a.key.compare(b.key) < 0;
+ });
+ endResetModel();
+}
+
+namespace
+{
+ enum class Column { Language, Completeness };
+}
+
+QVariant TranslationsModel::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid())
+ return QVariant();
+
+ int row = index.row();
+ auto column = static_cast<Column>(index.column());
+
+ if (row < 0 || row >= d->m_languages.size())
+ return QVariant();
+
+ auto& lang = d->m_languages[row];
+ switch (role) {
+ case Qt::DisplayRole: {
+ switch (column) {
+ case Column::Language: {
+ return lang.languageName();
+ }
+ case Column::Completeness: {
+ QString text;
+ text =
+ QString::asprintf("%3.1f %%", lang.percentTranslated());
+ return text;
+ }
+ }
+ }
+ case Qt::ToolTipRole: {
+ return tr("%1:\n%2 translated\n%3 fuzzy\n%4 total")
+ .arg(lang.key, QString::number(lang.translated),
+ QString::number(lang.fuzzy), QString::number(lang.total));
+ }
+ case Qt::UserRole:
+ return lang.key;
+ default:
+ return QVariant();
+ }
+}
+
+QVariant TranslationsModel::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ auto column = static_cast<Column>(section);
+ if (role == Qt::DisplayRole) {
+ switch (column) {
+ case Column::Language: {
+ return tr("Language");
+ }
+ case Column::Completeness: {
+ return tr("Completeness");
+ }
+ }
+ } else if (role == Qt::ToolTipRole) {
+ switch (column) {
+ case Column::Language: {
+ return tr("The native language name.");
+ }
+ case Column::Completeness: {
+ return tr("Completeness is the percentage of fully translated "
+ "strings, not counting automatically guessed ones.");
+ }
+ }
+ }
+ return QAbstractListModel::headerData(section, orientation, role);
+}
+
+int TranslationsModel::rowCount(const QModelIndex& parent) const
+{
+ return d->m_languages.size();
+}
+
+int TranslationsModel::columnCount(const QModelIndex& parent) const
+{
+ return 2;
+}
+
+Language* TranslationsModel::findLanguage(const QString& key)
+{
+ auto found = std::find_if(d->m_languages.begin(), d->m_languages.end(),
+ [&](Language& lang) { return lang.key == key; });
+ if (found == d->m_languages.end()) {
+ return nullptr;
+ } else {
+ return &(*found);
+ }
+}
+
+bool TranslationsModel::selectLanguage(QString key)
+{
+ QString& langCode = key;
+ auto langPtr = findLanguage(key);
+ if (!langPtr) {
+ qWarning() << "Selected invalid language" << key << ", defaulting to"
+ << defaultLangCode;
+ langCode = defaultLangCode;
+ } else {
+ langCode = langPtr->key;
+ }
+
+ // uninstall existing translators if there are any
+ if (d->m_app_translator) {
+ QCoreApplication::removeTranslator(d->m_app_translator.get());
+ d->m_app_translator.reset();
+ }
+ if (d->m_qt_translator) {
+ QCoreApplication::removeTranslator(d->m_qt_translator.get());
+ d->m_qt_translator.reset();
+ }
+
+ /*
+ * FIXME: potential source of crashes:
+ * In a multithreaded application, the default locale should be set at
+ * application startup, before any non-GUI threads are created. This
+ * function is not reentrant.
+ */
+ QLocale locale = QLocale(langCode);
+ QLocale::setDefault(locale);
+
+ // if it's the default UI language, finish
+ if (langCode == defaultLangCode) {
+ d->m_selectedLanguage = langCode;
+ return true;
+ }
+
+ // otherwise install new translations
+ bool successful = false;
+ // FIXME: this is likely never present. FIX IT.
+ d->m_qt_translator.reset(new QTranslator());
+ if (d->m_qt_translator->load(
+ "qt_" + langCode,
+ QLibraryInfo::location(QLibraryInfo::TranslationsPath))) {
+ qDebug() << "Loading Qt Language File for"
+ << langCode.toLocal8Bit().constData() << "...";
+ if (!QCoreApplication::installTranslator(d->m_qt_translator.get())) {
+ qCritical() << "Loading Qt Language File failed.";
+ d->m_qt_translator.reset();
+ } else {
+ successful = true;
+ }
+ } else {
+ d->m_qt_translator.reset();
+ }
+
+ if (langPtr->localFileType == FileType::PO) {
+ qDebug() << "Loading Application Language File for"
+ << langCode.toLocal8Bit().constData() << "...";
+ auto poTranslator = new POTranslator(
+ FS::PathCombine(d->m_dir.path(), langCode + ".po"));
+ if (!poTranslator->isEmpty()) {
+ if (!QCoreApplication::installTranslator(poTranslator)) {
+ delete poTranslator;
+ qCritical() << "Installing Application Language File failed.";
+ } else {
+ d->m_app_translator.reset(poTranslator);
+ successful = true;
+ }
+ } else {
+ qCritical() << "Loading Application Language File failed.";
+ d->m_app_translator.reset();
+ }
+ } else if (langPtr->localFileType == FileType::QM) {
+ d->m_app_translator.reset(new QTranslator());
+ if (d->m_app_translator->load("mmc_" + langCode, d->m_dir.path())) {
+ qDebug() << "Loading Application Language File for"
+ << langCode.toLocal8Bit().constData() << "...";
+ if (!QCoreApplication::installTranslator(
+ d->m_app_translator.get())) {
+ qCritical() << "Installing Application Language File failed.";
+ d->m_app_translator.reset();
+ } else {
+ successful = true;
+ }
+ } else {
+ d->m_app_translator.reset();
+ }
+ } else {
+ d->m_app_translator.reset();
+ }
+ d->m_selectedLanguage = langCode;
+ return successful;
+}
+
+QModelIndex TranslationsModel::selectedIndex()
+{
+ auto found = findLanguage(d->m_selectedLanguage);
+ if (found) {
+ // QVector iterator freely converts to pointer to contained type
+ return index(found - d->m_languages.data(), 0, QModelIndex());
+ }
+ return QModelIndex();
+}
+
+QString TranslationsModel::selectedLanguage()
+{
+ return d->m_selectedLanguage;
+}
+
+void TranslationsModel::downloadIndex()
+{
+ if (d->m_index_job || d->m_dl_job) {
+ return;
+ }
+ qDebug() << "Downloading Translations Index...";
+ d->m_index_job = new NetJob("Translations Index", APPLICATION->network());
+ MetaEntryPtr entry =
+ APPLICATION->metacache()->resolveEntry("translations", "index_v2.json");
+ entry->setStale(true);
+ d->m_index_task = Net::Download::makeCached(
+ QUrl("https://i18n.projecttick.org/index_v2.json"), entry);
+ d->m_index_job->addNetAction(d->m_index_task);
+ connect(d->m_index_job.get(), &NetJob::failed, this,
+ &TranslationsModel::indexFailed);
+ connect(d->m_index_job.get(), &NetJob::succeeded, this,
+ &TranslationsModel::indexReceived);
+ d->m_index_job->start();
+}
+
+void TranslationsModel::updateLanguage(QString key)
+{
+ if (key == defaultLangCode) {
+ qWarning() << "Cannot update builtin language" << key;
+ return;
+ }
+ auto found = findLanguage(key);
+ if (!found) {
+ qWarning() << "Cannot update invalid language" << key;
+ return;
+ }
+ if (!found->updated) {
+ downloadTranslation(key);
+ }
+}
+
+void TranslationsModel::downloadTranslation(QString key)
+{
+ if (d->m_dl_job) {
+ d->m_nextDownload = key;
+ return;
+ }
+ auto lang = findLanguage(key);
+ if (!lang) {
+ qWarning() << "Will not download an unknown translation" << key;
+ return;
+ }
+
+ d->m_downloadingTranslation = key;
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry(
+ "translations", "mmc_" + key + ".qm");
+ entry->setStale(true);
+
+ auto dl = Net::Download::makeCached(
+ QUrl(BuildConfig.TRANSLATIONS_BASE_URL + lang->file_name), entry);
+ auto rawHash = QByteArray::fromHex(lang->file_sha1.toLatin1());
+ dl->addValidator(
+ new Net::ChecksumValidator(QCryptographicHash::Sha1, rawHash));
+ dl->m_total_progress = lang->file_size;
+
+ d->m_dl_job = new NetJob("Translation for " + key, APPLICATION->network());
+ d->m_dl_job->addNetAction(dl);
+
+ connect(d->m_dl_job.get(), &NetJob::succeeded, this,
+ &TranslationsModel::dlGood);
+ connect(d->m_dl_job.get(), &NetJob::failed, this,
+ &TranslationsModel::dlFailed);
+
+ d->m_dl_job->start();
+}
+
+void TranslationsModel::downloadNext()
+{
+ if (!d->m_nextDownload.isEmpty()) {
+ downloadTranslation(d->m_nextDownload);
+ d->m_nextDownload.clear();
+ }
+}
+
+void TranslationsModel::dlFailed(QString reason)
+{
+ qCritical() << "Translations Download Failed:" << reason;
+ d->m_dl_job.reset();
+ downloadNext();
+}
+
+void TranslationsModel::dlGood()
+{
+ qDebug() << "Got translation:" << d->m_downloadingTranslation;
+
+ if (d->m_downloadingTranslation == d->m_selectedLanguage) {
+ selectLanguage(d->m_selectedLanguage);
+ }
+ d->m_dl_job.reset();
+ downloadNext();
+}
+
+void TranslationsModel::indexFailed(QString reason)
+{
+ qCritical() << "Translations Index Download Failed:" << reason;
+ d->m_index_job.reset();
+}