summaryrefslogtreecommitdiff
path: root/archived/projt-launcher/launcher/translations
diff options
context:
space:
mode:
Diffstat (limited to 'archived/projt-launcher/launcher/translations')
-rw-r--r--archived/projt-launcher/launcher/translations/POTranslator.cpp364
-rw-r--r--archived/projt-launcher/launcher/translations/POTranslator.h38
-rw-r--r--archived/projt-launcher/launcher/translations/TranslationsModel.cpp846
-rw-r--r--archived/projt-launcher/launcher/translations/TranslationsModel.h88
4 files changed, 1336 insertions, 0 deletions
diff --git a/archived/projt-launcher/launcher/translations/POTranslator.cpp b/archived/projt-launcher/launcher/translations/POTranslator.cpp
new file mode 100644
index 0000000000..6e893097d2
--- /dev/null
+++ b/archived/projt-launcher/launcher/translations/POTranslator.cpp
@@ -0,0 +1,364 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#include "POTranslator.h"
+
+#include <QDebug>
+#include "FileSystem.h"
+
+struct POEntry
+{
+ QString text;
+ bool fuzzy;
+};
+
+struct POTranslatorPrivate
+{
+ QString filename;
+ QHash<QByteArray, POEntry> mapping;
+ QHash<QByteArray, POEntry> mapping_disambiguatrion;
+ bool loaded = false;
+
+ void reload();
+};
+
+class ParserArray : public QByteArray
+{
+ public:
+ ParserArray(const QByteArray& in) : QByteArray(in)
+ {}
+ bool chomp(const char* data, int length)
+ {
+ if (startsWith(data))
+ {
+ remove(0, length);
+ return true;
+ }
+ return false;
+ }
+ bool chompString(QByteArray& appendHere)
+ {
+ QByteArray msg;
+ bool escape = false;
+ if (size() < 2)
+ {
+ qDebug() << "String fragment is too short";
+ return false;
+ }
+ if (!startsWith('"'))
+ {
+ qDebug() << "String fragment does not start with \"";
+ return false;
+ }
+ if (!endsWith('"'))
+ {
+ qDebug() << "String fragment does not end with \", instead, there is" << at(size() - 1);
+ return false;
+ }
+ for (int i = 1; i < size() - 1; i++)
+ {
+ char c = operator[](i);
+ if (escape)
+ {
+ switch (c)
+ {
+ case 'r': msg += '\r'; break;
+ case 'n': msg += '\n'; break;
+ case 't': msg += '\t'; break;
+ case 'v': msg += '\v'; break;
+ case 'a': msg += '\a'; break;
+ case 'b': msg += '\b'; break;
+ case 'f': msg += '\f'; break;
+ case '"': msg += '"'; break;
+ case '\\': msg.append('\\'); break;
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ {
+ int octal_start = i;
+ while ((c = operator[](i)) >= '0' && c <= '7')
+ {
+ i++;
+ if (i == length() - 1)
+ {
+ qDebug() << "Something went bad while parsing an octal escape string...";
+ return false;
+ }
+ }
+ msg += mid(octal_start, i - octal_start).toUInt(0, 8);
+ break;
+ }
+ case 'x':
+ {
+ // chomp the 'x'
+ i++;
+ int hex_start = i;
+ while (isxdigit(operator[](i)))
+ {
+ i++;
+ if (i == length() - 1)
+ {
+ qDebug() << "Something went bad while parsing a hex escape string...";
+ return false;
+ }
+ }
+ msg += mid(hex_start, i - hex_start).toUInt(0, 16);
+ break;
+ }
+ default:
+ {
+ qDebug() << "Invalid escape sequence character:" << c;
+ return false;
+ }
+ }
+ escape = false;
+ }
+ else if (c == '\\')
+ {
+ escape = true;
+ }
+ else
+ {
+ msg += c;
+ }
+ }
+ if (escape)
+ {
+ qDebug() << "Unterminated escape sequence...";
+ return false;
+ }
+ appendHere += msg;
+ return true;
+ }
+};
+
+void POTranslatorPrivate::reload()
+{
+ QFile file(filename);
+ if (!file.open(QFile::OpenMode::enum_type::ReadOnly | QFile::OpenMode::enum_type::Text))
+ {
+ qDebug() << "Failed to open PO file:" << filename;
+ return;
+ }
+
+ QByteArray context;
+ QByteArray disambiguation;
+ QByteArray id;
+ QByteArray str;
+ bool fuzzy = false;
+ bool nextFuzzy = false;
+
+ enum class Mode
+ {
+ First,
+ MessageContext,
+ MessageId,
+ MessageString
+ } mode = Mode::First;
+
+ int lineNumber = 0;
+ QHash<QByteArray, POEntry> newMapping;
+ QHash<QByteArray, POEntry> newMapping_disambiguation;
+ auto endEntry = [&]()
+ {
+ auto strStr = QString::fromUtf8(str);
+ // NOTE: PO header has empty id. We skip it.
+ if (!id.isEmpty())
+ {
+ auto normalKey = context + "|" + id;
+ newMapping.insert(normalKey, { strStr, fuzzy });
+ if (!disambiguation.isEmpty())
+ {
+ auto disambiguationKey = context + "|" + id + "@" + disambiguation;
+ newMapping_disambiguation.insert(disambiguationKey, { strStr, fuzzy });
+ }
+ }
+ context.clear();
+ disambiguation.clear();
+ id.clear();
+ str.clear();
+ fuzzy = nextFuzzy;
+ nextFuzzy = false;
+ };
+ while (!file.atEnd())
+ {
+ ParserArray line = file.readLine();
+ if (line.endsWith('\n'))
+ {
+ line.resize(line.size() - 1);
+ }
+ if (line.endsWith('\r'))
+ {
+ line.resize(line.size() - 1);
+ }
+
+ if (!line.size())
+ {
+ // NIL
+ }
+ else if (line[0] == '#')
+ {
+ if (line.contains(", fuzzy"))
+ {
+ nextFuzzy = true;
+ }
+ }
+ else if (line.startsWith('"'))
+ {
+ QByteArray temp;
+ QByteArray* out = &temp;
+
+ switch (mode)
+ {
+ case Mode::First:
+ qDebug() << "Unexpected escaped string during initial state... line:" << lineNumber;
+ return;
+ case Mode::MessageString: out = &str; break;
+ case Mode::MessageContext: out = &context; break;
+ case Mode::MessageId: out = &id; break;
+ }
+ if (!line.chompString(*out))
+ {
+ qDebug() << "Badly formatted string on line:" << lineNumber;
+ return;
+ }
+ }
+ else if (line.chomp("msgctxt ", 8))
+ {
+ switch (mode)
+ {
+ case Mode::First: break;
+ case Mode::MessageString: endEntry(); break;
+ case Mode::MessageContext:
+ case Mode::MessageId: qDebug() << "Unexpected msgctxt line:" << lineNumber; return;
+ }
+ if (line.chompString(context))
+ {
+ auto parts = context.split('|');
+ context = parts[0];
+ if (parts.size() > 1 && !parts[1].isEmpty())
+ {
+ disambiguation = parts[1];
+ }
+ mode = Mode::MessageContext;
+ }
+ }
+ else if (line.chomp("msgid ", 6))
+ {
+ switch (mode)
+ {
+ case Mode::MessageContext:
+ case Mode::First: break;
+ case Mode::MessageString: endEntry(); break;
+ case Mode::MessageId: qDebug() << "Unexpected msgid line:" << lineNumber; return;
+ }
+ if (line.chompString(id))
+ {
+ mode = Mode::MessageId;
+ }
+ }
+ else if (line.chomp("msgstr ", 7))
+ {
+ switch (mode)
+ {
+ case Mode::First:
+ case Mode::MessageString:
+ case Mode::MessageContext: qDebug() << "Unexpected msgstr line:" << lineNumber; return;
+ case Mode::MessageId: break;
+ }
+ if (line.chompString(str))
+ {
+ mode = Mode::MessageString;
+ }
+ }
+ else
+ {
+ qDebug() << "I did not understand line: " << lineNumber << ":" << QString::fromUtf8(line);
+ }
+ lineNumber++;
+ }
+ endEntry();
+ mapping = std::move(newMapping);
+ mapping_disambiguatrion = std::move(newMapping_disambiguation);
+ loaded = true;
+}
+
+POTranslator::POTranslator(const QString& filename, QObject* parent) : QTranslator(parent)
+{
+ d = new POTranslatorPrivate;
+ d->filename = filename;
+ d->reload();
+}
+
+POTranslator::~POTranslator()
+{
+ delete d;
+}
+
+QString POTranslator::translate(const char* context,
+ const char* sourceText,
+ const char* disambiguation,
+ [[maybe_unused]] int n) const
+{
+ if (disambiguation)
+ {
+ auto disambiguationKey = QByteArray(context) + "|" + QByteArray(sourceText) + "@" + QByteArray(disambiguation);
+ auto iter = d->mapping_disambiguatrion.find(disambiguationKey);
+ if (iter != d->mapping_disambiguatrion.end())
+ {
+ auto& entry = *iter;
+ if (entry.text.isEmpty())
+ {
+ qDebug() << "Translation entry has no content:" << disambiguationKey;
+ }
+ if (entry.fuzzy)
+ {
+ qDebug() << "Translation entry is fuzzy:" << disambiguationKey << "->" << entry.text;
+ }
+ return entry.text;
+ }
+ }
+ auto key = QByteArray(context) + "|" + QByteArray(sourceText);
+ auto iter = d->mapping.find(key);
+ if (iter != d->mapping.end())
+ {
+ auto& entry = *iter;
+ if (entry.text.isEmpty())
+ {
+ qDebug() << "Translation entry has no content:" << key;
+ }
+ if (entry.fuzzy)
+ {
+ qDebug() << "Translation entry is fuzzy:" << key << "->" << entry.text;
+ }
+ return entry.text;
+ }
+ return QString();
+}
+
+bool POTranslator::isEmpty() const
+{
+ return !d->loaded;
+}
diff --git a/archived/projt-launcher/launcher/translations/POTranslator.h b/archived/projt-launcher/launcher/translations/POTranslator.h
new file mode 100644
index 0000000000..8dace908c6
--- /dev/null
+++ b/archived/projt-launcher/launcher/translations/POTranslator.h
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+#pragma once
+
+#include <QTranslator>
+
+struct POTranslatorPrivate;
+
+class POTranslator : public QTranslator
+{
+ Q_OBJECT
+ public:
+ explicit POTranslator(const QString& filename, QObject* parent = nullptr);
+ virtual ~POTranslator();
+ QString translate(const char* context, const char* sourceText, const char* disambiguation, int n) const override;
+ bool isEmpty() const override;
+
+ private:
+ POTranslatorPrivate* d;
+};
diff --git a/archived/projt-launcher/launcher/translations/TranslationsModel.cpp b/archived/projt-launcher/launcher/translations/TranslationsModel.cpp
new file mode 100644
index 0000000000..49d721bbe2
--- /dev/null
+++ b/archived/projt-launcher/launcher/translations/TranslationsModel.cpp
@@ -0,0 +1,846 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ *
+ *
+ * Prism Launcher - Minecraft Launcher
+ * Copyright (c) 2022 flowln <flowlnlnln@gmail.com>
+ * Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
+ *
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * This file incorporates work covered by the following copyright and
+ * permission notice:
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * ======================================================================== */
+
+#include "TranslationsModel.h"
+
+#include <QCoreApplication>
+#include <QDebug>
+#include <QDir>
+#include <QLibraryInfo>
+#include <QLocale>
+#include <QMutex>
+#include <QTranslator>
+#include <locale>
+
+#include "BuildConfig.h"
+#include "FileSystem.h"
+#include "Json.h"
+#include "net/ChecksumValidator.h"
+#include "net/NetJob.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 if (key == "en_NZ")
+ {
+ result = u8"New Zealand English"; // No idea why qt translates this to just english and not to New Zealand
+ // English
+ }
+ else if (key == "en@pirate")
+ {
+ result = u8"Tongue of the High Seas";
+ }
+ else if (key == "en@uwu")
+ {
+ result = u8"Cute Engwish";
+ }
+ else if (key == "tok")
+ {
+ result = u8"toki pona";
+ }
+ else if (key == "nan")
+ {
+ result = u8"閩南語"; // Using traditional Chinese script. Not sure if we should use simplified instead?
+ }
+ else
+ {
+ const auto base_lang = key.section('_', 0, 0);
+ const QLocale base_locale(base_lang);
+ result = QLocale::languageToString(base_locale.language());
+ if (result == "C")
+ {
+ static const QMap<QString, QString> k_languageOverrides = {
+ { "av", "Avaric" }, { "tay", "Atayal" }, { "tlh", "Klingon" },
+ { "tw", "Twi" }, { "ty", "Tahitian" }, { "tzl", "Talossan" },
+ { "val", "Valencian" }, { "vls", "Flemish" }, { "zea", "Zeelandic" },
+ };
+ auto it = k_languageOverrides.find(base_lang);
+ if (it != k_languageOverrides.end())
+ {
+ result = it.value();
+ }
+ }
+ if (result.isEmpty() || result == "C")
+ {
+ result = base_lang.isEmpty() ? key : base_lang;
+ }
+ }
+
+ if (key.contains('_') && !key.contains('@'))
+ {
+ auto region = key.section('_', 1);
+ if (!region.isEmpty())
+ {
+ QLocale::Territory territory = QLocale::codeToTerritory(QStringView{ region });
+ auto territory_name = QLocale::territoryToString(territory);
+ if (!territory_name.isEmpty() && territory != QLocale::AnyTerritory)
+ {
+ result += " (" + territory_name + ")";
+ }
+ else
+ {
+ result += " (" + region + ")";
+ }
+ }
+ }
+ else if (result.isEmpty())
+ {
+ result = key;
+ }
+
+ 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
+ QList<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* 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;
+
+ const QString m_system_locale = QLocale::system().name();
+ const QString m_system_language = m_system_locale.split('_').front();
+
+ bool no_language_set = false;
+};
+
+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;
+ if (!d->no_language_set)
+ {
+ reloadLocalFiles();
+ }
+ selectLanguage(selectedLanguage());
+}
+
+void TranslationsModel::indexReceived()
+{
+ qDebug() << "Got translations index!";
+ d->m_index_job.reset();
+
+ if (d->no_language_set)
+ {
+ reloadLocalFiles();
+
+ auto language = d->m_system_locale;
+ if (!findLanguageAsOptional(language).has_value())
+ {
+ language = d->m_system_language;
+ }
+ selectLanguage(language);
+ if (selectedLanguage() != defaultLangCode)
+ {
+ updateLanguage(selectedLanguage());
+ }
+ APPLICATION->settings()->set("Language", selectedLanguage());
+ d->no_language_set = false;
+ }
+
+ else 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 ([[maybe_unused]] 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 ([[maybe_unused]] 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;
+ }
+ // New entries are sorted into the full list immediately, so a model reset is
+ // safer than advertising a contiguous append range that no longer exists
+ // after sorting.
+ beginResetModel();
+ for (auto& language : languages)
+ {
+ d->m_languages.append(language);
+ }
+ std::sort(d->m_languages.begin(),
+ d->m_languages.end(),
+ [this](const Language& a, const Language& b)
+ {
+ if (a.key != b.key)
+ {
+ if (a.key == d->m_system_locale || a.key == d->m_system_language)
+ {
+ return true;
+ }
+ if (b.key == d->m_system_locale || b.key == d->m_system_language)
+ {
+ return false;
+ }
+ }
+ return a.languageName().toLower() < b.languageName().toLower();
+ });
+ 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:
+ {
+ return QString("%1%").arg(lang.percentTranslated(), 3, 'f', 1);
+ }
+ }
+ qWarning("TranslationModel::data not implemented when role is DisplayRole");
+ }
+ 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([[maybe_unused]] const QModelIndex& parent) const
+{
+ return d->m_languages.size();
+}
+
+int TranslationsModel::columnCount([[maybe_unused]] const QModelIndex& parent) const
+{
+ return 2;
+}
+
+QList<Language>::Iterator TranslationsModel::findLanguage(const QString& key)
+{
+ return std::find_if(d->m_languages.begin(),
+ d->m_languages.end(),
+ [key](Language& lang) { return lang.key == key; });
+}
+
+std::optional<Language> TranslationsModel::findLanguageAsOptional(const QString& key)
+{
+ auto found = findLanguage(key);
+ if (found != d->m_languages.end())
+ return *found;
+ return {};
+}
+
+void TranslationsModel::setUseSystemLocale(bool useSystemLocale)
+{
+ APPLICATION->settings()->set("UseSystemLocale", useSystemLocale);
+ QLocale::setDefault(QLocale(useSystemLocale ? QString::fromStdString(std::locale().name()) : defaultLangCode));
+}
+
+bool TranslationsModel::selectLanguage(QString key)
+{
+ QString& langCode = key;
+ auto langPtr = findLanguageAsOptional(key);
+
+ if (langCode.isEmpty())
+ {
+ d->no_language_set = true;
+ }
+
+ if (!langPtr.has_value())
+ {
+ 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();
+ }
+
+ // Protect locale setting with a mutex to prevent thread-safety issues
+ // QLocale::setDefault is not reentrant, so we serialize access
+ static QMutex localeMutex;
+ QMutexLocker locker(&localeMutex);
+ QLocale::setDefault(QLocale(APPLICATION->settings()->get("UseSystemLocale").toBool()
+ ? QString::fromStdString(std::locale().name())
+ : langCode));
+
+ // Update the layout direction for RTL languages like Arabic
+ qApp->setLayoutDirection(QLocale(langCode).textDirection());
+
+ // if it's the default UI language, finish
+ if (langCode == defaultLangCode)
+ {
+ d->m_selectedLanguage = langCode;
+ return true;
+ }
+
+ // otherwise install new translations
+ bool successful = false;
+ // Note: Qt translations may not be present in all installations (e.g., custom builds).
+ // This is a best-effort attempt; the app continues working without Qt translations.
+ d->m_qt_translator.reset(new QTranslator());
+ if (d->m_qt_translator->load("qt_" + langCode, QLibraryInfo::path(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 != d->m_languages.end())
+ {
+ return index(std::distance(d->m_languages.begin(), found), 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.reset(new NetJob("Translations Index", APPLICATION->network()));
+ MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("translations", "index_v2.json");
+ entry->setStale(true);
+ auto task = Net::Download::makeCached(QUrl(BuildConfig.TRANSLATION_FILES_URL + "index_v2.json"), entry);
+ d->m_index_task = task.get();
+ d->m_index_job->addNetAction(task);
+ d->m_index_job->setAskRetry(false);
+ 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 = findLanguageAsOptional(key);
+ if (!found.has_value())
+ {
+ 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 = findLanguageAsOptional(key);
+ if (!lang.has_value())
+ {
+ 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.TRANSLATION_FILES_URL + lang->file_name), entry);
+ dl->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, lang->file_sha1));
+ dl->setProgress(dl->getProgress(), lang->file_size);
+
+ d->m_dl_job.reset(new NetJob("Translation for " + key, APPLICATION->network()));
+ d->m_dl_job->addNetAction(dl);
+ d->m_dl_job->setAskRetry(false);
+
+ 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();
+}
diff --git a/archived/projt-launcher/launcher/translations/TranslationsModel.h b/archived/projt-launcher/launcher/translations/TranslationsModel.h
new file mode 100644
index 0000000000..b28e35d468
--- /dev/null
+++ b/archived/projt-launcher/launcher/translations/TranslationsModel.h
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: GPL-3.0-only AND Apache-2.0
+// SPDX-FileCopyrightText: 2026 Project Tick
+// SPDX-FileContributor: Project Tick Team
+/*
+ * ProjT Launcher - Minecraft Launcher
+ * 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, version 3.
+ *
+ * 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, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * === Upstream License Block (Do Not Modify) ==============================
+ *
+ * Copyright 2013-2021 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ======================================================================== */
+
+#pragma once
+
+#include <QAbstractListModel>
+#include <memory>
+#include <optional>
+
+struct Language;
+
+class TranslationsModel : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ explicit TranslationsModel(QString path, QObject* parent = 0);
+ virtual ~TranslationsModel();
+
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex& parent) const override;
+
+ bool selectLanguage(QString key);
+ void updateLanguage(QString key);
+ QModelIndex selectedIndex();
+ QString selectedLanguage();
+
+ void downloadIndex();
+ void setUseSystemLocale(bool useSystemLocale);
+
+ private:
+ QList<Language>::Iterator findLanguage(const QString& key);
+ std::optional<Language> findLanguageAsOptional(const QString& key);
+ void reloadLocalFiles();
+ void downloadTranslation(QString key);
+ void downloadNext();
+
+ // hide copy constructor
+ TranslationsModel(const TranslationsModel&) = delete;
+ // hide assign op
+ TranslationsModel& operator=(const TranslationsModel&) = delete;
+
+ private slots:
+ void indexReceived();
+ void indexFailed(QString reason);
+ void dlFailed(QString reason);
+ void dlGood();
+ void translationDirChanged(const QString& path);
+
+ private: /* data */
+ struct Private;
+ std::unique_ptr<Private> d;
+};