diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
| commit | 31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch) | |
| tree | 8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/translations/TranslationsModel.cpp | |
| parent | 934382c8a1ce738589dee9ee0f14e1cec812770e (diff) | |
| parent | fad6a1066616b69d7f5fef01178efdf014c59537 (diff) | |
| download | Project-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.cpp | 610 |
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(); +} |
