Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2025 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 : #include "languagesservice.h"
23 :
24 : #include <QBuffer>
25 : #include <QCryptographicHash>
26 : #include <QDir>
27 : #include <QGuiApplication>
28 : #include <QJsonDocument>
29 : #include <QJsonObject>
30 : #include <QJsonParseError>
31 : #include <QQmlEngine>
32 : #include <QTranslator>
33 :
34 : #include "languageserrors.h"
35 :
36 : #include "multiwindows/resourcelockguard.h"
37 :
38 : #include "global/io/buffer.h"
39 : #include "global/serialization/zipreader.h"
40 : #include "global/translation.h"
41 : #include "global/log.h"
42 :
43 : using namespace Qt::Literals::StringLiterals;
44 :
45 : using namespace muse;
46 : using namespace muse::languages;
47 : using namespace muse::network;
48 :
49 : static const std::string LANGUAGES_RESOURCE_NAME("LANGUAGES");
50 :
51 0 : void LanguagesService::init()
52 : {
53 0 : TRACEFUNC;
54 :
55 0 : fileSystem()->makePath(configuration()->languagesUserAppDataPath());
56 :
57 0 : loadLanguages();
58 :
59 0 : ValCh<QString> languageCode = configuration()->currentLanguageCode();
60 0 : setCurrentLanguage(languageCode.val);
61 :
62 : // Remember the active language code so the "Restart required" text can go away if the language
63 : // is changed and later reverted in the same session. Cannot use m_currentLanguage.code
64 : // because m_currentLanguage holds the effective language: if the language code is "system",
65 : // m_currentLanguage will hold "en-us" for example (whatever the system is set to).
66 0 : QString activeLanguageCode = languageCode.val;
67 0 : languageCode.ch.onReceive(this, [this, activeLanguageCode](const QString& newLanguageCode) {
68 : //! NOTE To change the language at the moment, a restart is required
69 0 : m_restartRequiredToApplyLanguage = newLanguageCode != activeLanguageCode;
70 0 : m_restartRequiredToApplyLanguageChanged.send(m_restartRequiredToApplyLanguage);
71 0 : });
72 :
73 0 : m_inited = true;
74 0 : }
75 :
76 0 : const LanguagesHash& LanguagesService::languages() const
77 : {
78 0 : return m_languagesHash;
79 : }
80 :
81 0 : Language LanguagesService::language(const QString& languageCode) const
82 : {
83 0 : QString effectiveLanguageCode = this->effectiveLanguageCode(languageCode);
84 0 : if (effectiveLanguageCode.isEmpty()) {
85 0 : return {};
86 : }
87 :
88 0 : if (effectiveLanguageCode == PLACEHOLDER_LANGUAGE_CODE) {
89 0 : return m_placeholderLanguage;
90 : }
91 :
92 0 : return m_languagesHash[effectiveLanguageCode];
93 0 : }
94 :
95 0 : const Language& LanguagesService::currentLanguage() const
96 : {
97 0 : return m_currentLanguage;
98 : }
99 :
100 0 : async::Notification LanguagesService::currentLanguageChanged() const
101 : {
102 0 : return m_currentLanguageChanged;
103 : }
104 :
105 0 : const Language& LanguagesService::placeholderLanguage() const
106 : {
107 0 : return m_placeholderLanguage;
108 : }
109 :
110 0 : bool LanguagesService::hasPlaceholderLanguage() const
111 : {
112 0 : for (const QString& resourceName : configuration()->languageResourceNames()) {
113 0 : if (!fileSystem()->exists(configuration()->builtinLanguageFilePath(resourceName, PLACEHOLDER_LANGUAGE_CODE))) {
114 0 : return false;
115 : }
116 0 : }
117 :
118 0 : return true;
119 : }
120 :
121 0 : void LanguagesService::loadLanguages()
122 : {
123 0 : TRACEFUNC;
124 :
125 0 : RetVal<ByteArray> languagesJson;
126 : {
127 0 : mi::ReadResourceLockGuard lock_guard(multiwindowsProvider(), LANGUAGES_RESOURCE_NAME);
128 0 : const io::path_t appLanguagesJsonPath = configuration()->appLanguagesJsonPath();
129 0 : if (!appLanguagesJsonPath.empty()) {
130 0 : languagesJson = fileSystem()->readFile(appLanguagesJsonPath);
131 : }
132 :
133 0 : if (!languagesJson.ret) {
134 0 : languagesJson = fileSystem()->readFile(configuration()->builtinLanguagesJsonPath());
135 : }
136 0 : }
137 0 : if (!languagesJson.ret) {
138 0 : LOGE() << "Failed to read languages.json: " << languagesJson.ret.toString();
139 0 : return;
140 : }
141 :
142 0 : QJsonParseError err;
143 0 : QJsonDocument jsonDoc = QJsonDocument::fromJson(languagesJson.val.toQByteArrayNoCopy(), &err);
144 0 : if (err.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
145 0 : LOGE() << "Failed to parse languages.json: " << err.errorString();
146 0 : return;
147 : }
148 :
149 0 : QJsonObject languagesObject = jsonDoc.object();
150 0 : for (auto it = languagesObject.begin(); it != languagesObject.end(); ++it) {
151 0 : QJsonObject langObject = it.value().toObject();
152 :
153 0 : Language lang;
154 0 : lang.code = it.key();
155 0 : lang.name = langObject.value("name").toString();
156 0 : lang.fallbackLanguages = langObject.value("fallbackLanguages").toVariant().toStringList();
157 :
158 0 : m_languagesHash.insert(it.key(), lang);
159 0 : }
160 :
161 0 : m_placeholderLanguage.code = PLACEHOLDER_LANGUAGE_CODE;
162 0 : m_placeholderLanguage.name = "«Placeholder translations»";
163 0 : }
164 :
165 0 : void LanguagesService::setCurrentLanguage(const QString& languageCode)
166 : {
167 0 : TRACEFUNC;
168 :
169 0 : QString effectiveLanguageCode = this->effectiveLanguageCode(languageCode);
170 0 : if (effectiveLanguageCode.isEmpty()) {
171 0 : LOGE() << "Unsupported language code: " << languageCode;
172 0 : return;
173 : }
174 :
175 0 : if (effectiveLanguageCode == m_currentLanguage.code) {
176 0 : return;
177 : }
178 :
179 0 : Language& lang = languageCode == PLACEHOLDER_LANGUAGE_CODE
180 0 : ? m_placeholderLanguage
181 0 : : m_languagesHash[effectiveLanguageCode];
182 :
183 0 : if (!lang.isLoaded()) {
184 0 : loadLanguage(lang);
185 : }
186 :
187 0 : for (QTranslator* t : std::as_const(m_translators)) {
188 0 : QCoreApplication::instance()->removeTranslator(t);
189 0 : delete t;
190 : }
191 0 : m_translators.clear();
192 0 : m_translators.reserve(lang.files.size());
193 :
194 0 : auto installTranslatorsForLanguage = [this](const Language& l) {
195 0 : for (const io::path_t& file : l.files) {
196 0 : QTranslator* translator = new QTranslator();
197 0 : bool ok = translator->load(file.toQString());
198 0 : if (ok) {
199 0 : QCoreApplication::instance()->installTranslator(translator);
200 0 : m_translators.push_back(translator);
201 : } else {
202 0 : LOGE() << "Error loading translator " << file.toQString();
203 0 : delete translator;
204 : }
205 : }
206 0 : };
207 :
208 : // Install translators for fallback languages in reverse order:
209 : // Qt searches the most recently installed translator first,
210 : // and the least recently installed translator last.
211 0 : for (auto it = lang.fallbackLanguages.crbegin(); it != lang.fallbackLanguages.crend(); ++it) {
212 0 : Language& fallbackLang = m_languagesHash[*it];
213 0 : installTranslatorsForLanguage(fallbackLang);
214 : }
215 :
216 0 : installTranslatorsForLanguage(lang);
217 :
218 0 : QLocale locale(lang.code);
219 0 : QLocale::setDefault(locale);
220 0 : qGuiApp->setLayoutDirection(locale.textDirection());
221 :
222 0 : lang.direction = locale.textDirection();
223 :
224 : // Currently, no need to retranslate the UI on language change, because we require restart
225 :
226 0 : m_currentLanguage = lang;
227 0 : m_currentLanguageChanged.notify();
228 0 : }
229 :
230 0 : QString LanguagesService::effectiveLanguageCode(QString languageCode) const
231 : {
232 0 : languageCode.replace(u'-', u'_');
233 :
234 0 : auto tryCode = [this](const QString& code) -> QString {
235 0 : if (m_languagesHash.contains(code)) {
236 0 : return code;
237 : }
238 :
239 : static const std::map<QString, QString> SPECIAL_CASES {
240 0 : { u"ca_valencia"_s, u"ca@valencia"_s },
241 0 : { u"ca_ES_valencia"_s, u"ca@valencia"_s },
242 0 : { u"en_AU"_s, u"en_GB"_s },
243 0 : { u"en_NZ"_s, u"en_GB"_s },
244 0 : { u"en"_s, u"en_US"_s },
245 0 : { u"hi"_s, u"hi_IN"_s },
246 0 : { u"mn"_s, u"mn_MN"_s },
247 0 : { u"zh"_s, u"zh_CN"_s }
248 0 : };
249 :
250 0 : auto it = SPECIAL_CASES.find(code);
251 0 : if (it != SPECIAL_CASES.cend()) {
252 0 : return it->second;
253 : }
254 :
255 : // Not found
256 0 : return QString();
257 0 : };
258 :
259 0 : if (languageCode.isEmpty() || languageCode == SYSTEM_LANGUAGE_CODE) {
260 0 : const QStringList systemLanguages = QLocale::system().uiLanguages();
261 0 : LOGI() << "System languages: " << systemLanguages;
262 :
263 0 : for (QString code : systemLanguages) {
264 0 : code.replace(u'-', u'_');
265 :
266 : // Prefer Swedish (Modern) over Swedish (Traditional)
267 : // when using system language.
268 0 : if (code == u"sv_SE"_s) {
269 0 : code = u"sv"_s;
270 : }
271 :
272 0 : code = tryCode(code);
273 0 : if (!code.isEmpty()) {
274 0 : return code;
275 : }
276 0 : }
277 :
278 : // Not found
279 0 : return QString();
280 0 : }
281 :
282 0 : if (languageCode == PLACEHOLDER_LANGUAGE_CODE) {
283 0 : return languageCode;
284 : }
285 :
286 0 : return tryCode(languageCode);
287 : }
288 :
289 0 : Ret LanguagesService::loadLanguage(Language& lang)
290 : {
291 0 : Ret ret = doLoadLanguage(lang);
292 0 : if (!ret) {
293 0 : LOGE() << "Failed to load language " << lang.code << ": " << ret.toString();
294 0 : return ret;
295 : }
296 :
297 0 : for (const QString& fallbackCode : lang.fallbackLanguages) {
298 0 : Language& fallbackLang = m_languagesHash[fallbackCode];
299 :
300 0 : if (!fallbackLang.isLoaded()) {
301 0 : ret = doLoadLanguage(fallbackLang);
302 0 : if (!ret) {
303 0 : LOGE() << "Failed to load fallback language " << fallbackLang.code << ": " << ret.toString();
304 0 : return ret;
305 : }
306 : }
307 : }
308 :
309 0 : return muse::make_ok();
310 0 : }
311 :
312 0 : Ret LanguagesService::doLoadLanguage(Language& lang)
313 : {
314 0 : io::path_t languagesAppDataPath = configuration()->languagesAppDataPath();
315 0 : io::path_t languagesUserAppDataPath = configuration()->languagesUserAppDataPath();
316 :
317 0 : QString languageSuffix = QString("_%1.qm").arg(lang.code);
318 :
319 0 : std::string filter = ("*" + languageSuffix).toStdString();
320 :
321 0 : RetVal<io::paths_t> appFilePaths = fileSystem()->scanFiles(languagesAppDataPath, { filter });
322 0 : if (!appFilePaths.ret) {
323 0 : LOGE() << "Failed to scan files for language " << lang.code << ": " << appFilePaths.ret.toString();
324 0 : return appFilePaths.ret;
325 : }
326 :
327 0 : for (const io::path_t& appFilePath : appFilePaths.val) {
328 0 : io::path_t filename = io::filename(appFilePath);
329 0 : io::path_t userFilePath = languagesUserAppDataPath.appendingComponent(filename);
330 :
331 0 : QFileInfo appFileInfo(appFilePath.toQString());
332 0 : QFileInfo userFileInfo(userFilePath.toQString());
333 0 : bool useUserPath = userFileInfo.exists() && userFileInfo.lastModified() > appFileInfo.lastModified();
334 :
335 0 : QString resourceName = filename.toQString().chopped(languageSuffix.size());
336 0 : lang.files[resourceName] = useUserPath ? userFilePath : appFilePath;
337 0 : }
338 :
339 0 : for (const QString& resourceName : configuration()->languageResourceNames()) {
340 0 : if (!lang.files.contains(resourceName)) {
341 0 : LOGE() << "Could not find resource " << resourceName << " for language " << lang.code;
342 0 : return muse::make_ret(muse::Ret::Code::InternalError);
343 : }
344 0 : }
345 :
346 0 : return muse::make_ok();
347 0 : }
348 :
349 0 : Progress LanguagesService::update(const QString& languageCode)
350 : {
351 0 : if (m_languageUpdateInProgress) {
352 0 : LOGW() << "Language update already in progress";
353 0 : return {};
354 : }
355 :
356 0 : m_languageUpdateInProgress = true;
357 :
358 0 : QString effectiveLanguageCode = this->effectiveLanguageCode(languageCode);
359 0 : IF_ASSERT_FAILED(!effectiveLanguageCode.isEmpty()) {
360 0 : return {};
361 : }
362 :
363 0 : Language& lang = m_languagesHash[effectiveLanguageCode];
364 0 : if (!lang.isLoaded()) {
365 0 : loadLanguage(lang);
366 : }
367 :
368 0 : if (!m_networkManager) {
369 0 : m_networkManager = networkManagerCreator()->makeNetworkManager();
370 : }
371 :
372 0 : Progress progress;
373 0 : progress.start();
374 0 : progress.progress(0, 0, muse::trc("global", "Checking for updates…"));
375 :
376 0 : downloadServerLanguagesInfo(
377 : effectiveLanguageCode,
378 0 : [this, effectiveLanguageCode, progress]( const RetVal<QJsonObject>& res) mutable {
379 0 : if (!res.ret) {
380 0 : LOGE() << "Failed to download server languages info: " << res.ret.toString();
381 0 : progress.finish(res.ret);
382 0 : return;
383 : }
384 :
385 0 : QJsonObject serverLanguagesInfo = res.val;
386 0 : QStringList languagesToUpdate = this->languagesToUpdate(effectiveLanguageCode, serverLanguagesInfo);
387 :
388 0 : if (languagesToUpdate.isEmpty()) {
389 0 : m_languageUpdateInProgress = false;
390 0 : progress.finish(make_ret(Err::AlreadyUpToDate));
391 0 : return;
392 : }
393 :
394 0 : doUpdateLanguages(languagesToUpdate, progress, [this, progress](const Ret& ret) mutable {
395 0 : m_languageUpdateInProgress = false;
396 0 : progress.finish(ret);
397 :
398 0 : if (ret) {
399 0 : m_restartRequiredToApplyLanguage = true;
400 0 : m_restartRequiredToApplyLanguageChanged.send(true);
401 : }
402 0 : });
403 0 : });
404 :
405 0 : return progress;
406 0 : }
407 :
408 0 : bool LanguagesService::restartRequiredToApplyLanguage() const
409 : {
410 0 : return m_restartRequiredToApplyLanguage;
411 : }
412 :
413 0 : async::Channel<bool> LanguagesService::restartRequiredToApplyLanguageChanged() const
414 : {
415 0 : return m_restartRequiredToApplyLanguageChanged;
416 : }
417 :
418 0 : void LanguagesService::downloadServerLanguagesInfo(const QString& languageCode, std::function<void(const RetVal<QJsonObject>&)> finished)
419 : {
420 0 : auto buff = std::make_shared<QBuffer>();
421 0 : RetVal<Progress> progress = m_networkManager->get(configuration()->languagesUpdateUrl().toString(), buff);
422 0 : if (!progress.ret) {
423 0 : finished(RetVal<QJsonObject>::make_ret(progress.ret));
424 0 : return;
425 : }
426 :
427 0 : progress.val.finished().onReceive(this, [languageCode, buff, finished](const ProgressResult& res) {
428 0 : if (!res.ret) {
429 0 : finished(RetVal<QJsonObject>::make_ret(res.ret));
430 0 : return;
431 : }
432 :
433 0 : QJsonParseError err;
434 0 : QJsonDocument jsonDoc = QJsonDocument::fromJson(buff->data(), &err);
435 0 : if (err.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
436 0 : finished(RetVal<QJsonObject>::make_ret(make_ret(Err::ErrorInvalidServerLanguagesInfo)));
437 0 : return;
438 : }
439 :
440 0 : finished(RetVal<QJsonObject>::make_ok(jsonDoc.object()));
441 0 : });
442 0 : }
443 :
444 0 : QStringList LanguagesService::languagesToUpdate(const QString& mainLanguageCode, const QJsonObject& serverLanguagesInfo) const
445 : {
446 0 : QStringList languagesToUpdate;
447 :
448 0 : auto languageNeedsUpdate = [this, &serverLanguagesInfo](const QString& code) {
449 0 : const Language& lang = m_languagesHash[code];
450 0 : const QJsonObject languageObject = serverLanguagesInfo.value(code).toObject();
451 :
452 0 : for (const QString& resource : configuration()->languageResourceNames()) {
453 0 : RetVal<QString> hash = fileHash(lang.files[resource]);
454 0 : QString latestHash = languageObject.value(resource).toObject().value("hash").toString();
455 :
456 0 : if (!hash.ret || hash.val != latestHash) {
457 0 : return true;
458 : }
459 0 : }
460 :
461 0 : return false;
462 0 : };
463 :
464 0 : if (languageNeedsUpdate(mainLanguageCode)) {
465 0 : languagesToUpdate.append(mainLanguageCode);
466 : }
467 :
468 0 : for (const QString& fallbackCode : m_languagesHash[mainLanguageCode].fallbackLanguages) {
469 0 : if (languageNeedsUpdate(fallbackCode)) {
470 0 : languagesToUpdate.append(fallbackCode);
471 : }
472 0 : }
473 :
474 0 : return languagesToUpdate;
475 0 : }
476 :
477 0 : void LanguagesService::doUpdateLanguages(const QStringList& languageCodes, Progress overallProgress,
478 : std::function<void(const Ret&)> overallFinished)
479 : {
480 0 : assert(!languageCodes.empty());
481 :
482 : // Can't create recursion with self-capturing lambda, because we can't
483 : // capture any local variable by reference (they will have been destroyed by
484 : // the time the lambda is executed). So we pass the lambda as a parameter
485 : // instead. The type of this parameter cannot be written directly, as it
486 : // would be a recursive type.
487 : auto updateLanguage
488 0 : = [this, languageCodes, overallProgress, overallFinished](int languageIndex, auto updateNextLanguage) mutable -> void {
489 0 : auto progressCallback
490 0 : = [languageCodes, overallProgress, languageIndex](int64_t current, int64_t total, const std::string& msg) mutable {
491 0 : const int64_t overallTotal = languageCodes.size() * 10000;
492 0 : const int64_t overallCurrent = languageIndex * 10000 + current * 10000 / total;
493 0 : overallProgress.progress(overallCurrent, overallTotal, msg);
494 : };
495 :
496 0 : auto finished
497 0 : = [languageCodes, overallFinished, languageIndex, updateNextLanguage](const Ret& ret) mutable {
498 0 : if (ret) {
499 0 : if (++languageIndex < languageCodes.size()) {
500 0 : updateNextLanguage(languageIndex, updateNextLanguage);
501 : } else {
502 0 : overallFinished(make_ok());
503 : }
504 : } else {
505 0 : overallFinished(ret);
506 : }
507 : };
508 :
509 0 : doUpdateLanguage(languageCodes[languageIndex], progressCallback, finished);
510 0 : };
511 :
512 0 : updateLanguage(0, updateLanguage);
513 0 : }
514 :
515 0 : void LanguagesService::doUpdateLanguage(const QString& languageCode,
516 : std::function<void(int64_t current, int64_t total, const std::string&)> progressCallback,
517 : std::function<void(const Ret&)> finished)
518 : {
519 0 : auto qbuff = std::make_shared<QBuffer>();
520 0 : const QUrl url = configuration()->languageFileServerUrl(languageCode);
521 0 : RetVal<Progress> downloadProgress = m_networkManager->get(url, qbuff);
522 0 : if (!downloadProgress.ret) {
523 0 : finished(make_ret(Err::ErrorDownloadLanguage));
524 0 : return;
525 : }
526 :
527 0 : const std::string downloadingMsg = muse::qtrc("global", "Downloading %1…").arg(languageCode).toStdString();
528 0 : progressCallback(0, 1, downloadingMsg);
529 :
530 0 : downloadProgress.val.progressChanged().onReceive(this,
531 0 : [progressCallback, downloadingMsg](int64_t current, int64_t total, const std::string&) {
532 0 : progressCallback(current, total,
533 0 : downloadingMsg);
534 0 : });
535 :
536 0 : downloadProgress.val.finished().onReceive(this, [this, progressCallback, finished, languageCode, qbuff](const ProgressResult& res) {
537 0 : if (!res.ret) {
538 0 : finished(make_ret(Err::ErrorDownloadLanguage));
539 0 : return;
540 : }
541 :
542 0 : progressCallback(1, 1, muse::qtrc("global", "Unpacking %1…").arg(languageCode).toStdString());
543 :
544 0 : Ret ret = unpackAndWriteLanguage(qbuff->data());
545 0 : finished(ret);
546 0 : });
547 0 : }
548 :
549 0 : Ret LanguagesService::unpackAndWriteLanguage(const QByteArray& zipData)
550 : {
551 0 : TRACEFUNC;
552 :
553 0 : ByteArray ba = ByteArray::fromQByteArrayNoCopy(zipData);
554 0 : io::Buffer buff(&ba);
555 0 : ZipReader zipReader(&buff);
556 :
557 : {
558 0 : mi::WriteResourceLockGuard lock_guard(multiwindowsProvider(), LANGUAGES_RESOURCE_NAME);
559 :
560 0 : for (const auto& info : zipReader.fileInfoList()) {
561 0 : io::path_t userFilePath = configuration()->languagesUserAppDataPath().appendingComponent(info.filePath);
562 0 : Ret ret = fileSystem()->writeFile(userFilePath, zipReader.fileData(info.filePath.toStdString()));
563 0 : if (!ret) {
564 0 : return make_ret(Err::ErrorWriteLanguage);
565 : }
566 0 : }
567 0 : }
568 :
569 0 : return make_ok();
570 0 : }
571 :
572 0 : RetVal<QString> LanguagesService::fileHash(const io::path_t& path) const
573 : {
574 0 : RetVal<ByteArray> fileBytes;
575 : {
576 0 : mi::ReadResourceLockGuard lock_guard(multiwindowsProvider(), LANGUAGES_RESOURCE_NAME);
577 0 : fileBytes = fileSystem()->readFile(path);
578 0 : }
579 0 : if (!fileBytes.ret) {
580 0 : return fileBytes.ret;
581 : }
582 :
583 0 : QCryptographicHash hasher(QCryptographicHash::Sha1);
584 0 : hasher.reset();
585 0 : hasher.addData(fileBytes.val.toQByteArrayNoCopy());
586 0 : return RetVal<QString>::make_ok(QString(hasher.result().toHex()));
587 0 : }
|