/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "chat_helpers/spellchecker_common.h" #ifndef TDESKTOP_DISABLE_SPELLCHECK #include "base/platform/base_platform_info.h" #include "base/weak_ptr.h" #include "base/zlib_help.h" #include "data/data_session.h" #include "lang/lang_instance.h" #include "lang/lang_keys.h" #include "main/main_account.h" #include "main/main_domain.h" #include "main/main_session.h" #include "mainwidget.h" #include "mtproto/dedicated_file_loader.h" #include "spellcheck/platform/platform_spellcheck.h" #include "spellcheck/spellcheck_utils.h" #include "spellcheck/spellcheck_value.h" #include "core/application.h" #include "core/core_settings.h" #include #include #include #include #include namespace Spellchecker { namespace { using namespace Storage::CloudBlob; constexpr auto kDictExtensions = { "dic", "aff" }; constexpr auto kExceptions = { AppFile, "\xd0\xa2\xd0\xb5\xd0\xbb\xd0\xb5\xd0\xb3\xd1\x80\xd0\xb0\xd0\xbc"_cs, }; constexpr auto kLangsForLWC = { QLocale::English, QLocale::Portuguese }; constexpr auto kDefaultCountries = { QLocale::UnitedStates, QLocale::Brazil }; // Language With Country. inline auto LWC(QLocale::Language language, QLocale::Country country) { if (ranges::contains(kDefaultCountries, country)) { return int(language); } return (language * 1000) + country; } inline auto LanguageFromLocale(QLocale loc) { const auto locLang = loc.language(); return (ranges::contains(kLangsForLWC, locLang) && (loc.country() != QLocale::AnyCountry)) ? LWC(locLang, loc.country()) : int(locLang); } constexpr auto kDictionariesManifestChannel = "tdhbcfiles"_cs; constexpr auto kDictionariesManifestPostId = 2949; // Runtime-loaded dictionaries manifest. Kept in memory only: fetched from // the pinned JSON post the first time something actually needs it // (Manage Dictionaries, auto-download). Nothing persists to disk, so an // install whose enabled dictionaries are all on disk never hits the net. std::vector DictionariesList; rpl::event_stream<> DictionariesListChanged; void EnsurePath(); bool ParseLocation(const QString &text, QString &channel, int &postId) { const auto sep = text.indexOf('#'); if (sep <= 0 || sep == text.size() - 1) { return false; } auto ok = false; const auto parsed = text.mid(sep + 1).toInt(&ok); if (!ok || parsed <= 0) { return false; } channel = text.left(sep); postId = parsed; return true; } std::vector ParseManifest(const QByteArray &bytes) { auto result = std::vector(); auto err = QJsonParseError(); const auto doc = QJsonDocument::fromJson(bytes, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { LOG(("Spellcheck Error: manifest JSON parse failed: %1" ).arg(err.errorString())); return result; } const auto list = doc.object().value(u"dictionaries"_q).toArray(); result.reserve(list.size()); for (const auto &v : list) { const auto obj = v.toObject(); auto d = Dict(); d.id = obj.value(u"id"_q).toInt(); d.size = int64(obj.value(u"size"_q).toDouble()); d.name = obj.value(u"name"_q).toString(); const auto location = obj.value(u"location"_q).toString(); if (!d.id || d.name.isEmpty() || !ParseLocation(location, d.channel, d.postId)) { continue; } result.push_back(std::move(d)); } return result; } class DictManifestLoader final : public base::has_weak_ptr { public: explicit DictManifestLoader(base::weak_ptr session); void start(); private: void resolved(const MTPInputChannel &channel); void received(const MTPmessages_Messages &result); void apply(const QByteArray &bytes); void finish(); MTP::WeakInstance _mtp; }; std::shared_ptr ActiveManifestLoader; DictManifestLoader::DictManifestLoader(base::weak_ptr session) : _mtp(session) { } void DictManifestLoader::start() { if (!_mtp.valid()) { finish(); return; } const auto weak = base::make_weak(this); MTP::ResolveChannel(&_mtp, kDictionariesManifestChannel.utf16(), [=]( const MTPInputChannel &channel) { if (const auto strong = weak.get()) { strong->resolved(channel); } }, [=] { if (const auto strong = weak.get()) { strong->finish(); } }); } void DictManifestLoader::resolved(const MTPInputChannel &channel) { const auto weak = base::make_weak(this); _mtp.send( MTPchannels_GetMessages( channel, MTP_vector(1, MTP_inputMessageID( MTP_int(kDictionariesManifestPostId)))), [=](const MTPmessages_Messages &result) { if (const auto strong = weak.get()) { strong->received(result); } }, [=](const MTP::Error &) { if (const auto strong = weak.get()) { strong->finish(); } }); } void DictManifestLoader::received(const MTPmessages_Messages &result) { const auto message = MTP::GetMessagesElement(result); if (!message || message->type() != mtpc_message) { LOG(("Spellcheck Error: manifest message not found.")); finish(); return; } apply(message->c_message().vmessage().v); finish(); } void DictManifestLoader::apply(const QByteArray &bytes) { auto parsed = ParseManifest(bytes); if (parsed.empty()) { LOG(("Spellcheck Error: manifest empty or unparseable.")); return; } DictionariesList = std::move(parsed); DictionariesListChanged.fire({}); } void DictManifestLoader::finish() { crl::on_main([] { ActiveManifestLoader.reset(); }); } void StartManifestRefresh(not_null session) { if (ActiveManifestLoader) { return; } ActiveManifestLoader = std::make_shared( base::make_weak(session)); ActiveManifestLoader->start(); } // Callers waiting for the first manifest arrival in this session. // We don't cache manifest to disk, so each cold start begins empty; // queued callbacks fire once when the MTP fetch lands. std::vector> ManifestPending; rpl::lifetime ManifestPendingSubscription; void EnsureManifestThen( not_null session, Fn callback) { if (!DictionariesList.empty()) { callback(); return; } const auto firstCaller = ManifestPending.empty(); ManifestPending.push_back(std::move(callback)); if (firstCaller) { DictionariesListChanged.events( ) | rpl::take(1) | rpl::on_next([] { auto fns = std::move(ManifestPending); ManifestPending.clear(); for (auto &fn : fns) { fn(); } }, ManifestPendingSubscription); } StartManifestRefresh(session); } inline auto IsSupportedLang(int lang) { return ranges::contains(DictionariesList, lang, &Dict::id); } void EnsurePath() { if (!QDir::current().mkpath(Spellchecker::DictionariesPath())) { LOG(("App Error: Could not create dictionaries path.")); } } bool IsGoodPartName(const QString &name) { return ranges::any_of(kDictExtensions, [&](const auto &ext) { return name.endsWith(ext); }); } using DictLoaderPtr = std::shared_ptr>; DictLoaderPtr BackgroundLoader; rpl::event_stream BackgroundLoaderChanged; void SetBackgroundLoader(DictLoaderPtr loader) { BackgroundLoader = std::move(loader); } void DownloadDictionaryInBackground( not_null session, int counter, std::vector langs) { if (counter >= langs.size()) { return; } const auto id = langs[counter]; counter++; const auto destroyer = [=] { BackgroundLoader = nullptr; BackgroundLoaderChanged.fire(0); if (DictionaryExists(id)) { auto dicts = Core::App().settings().dictionariesEnabled(); if (ranges::contains(dicts, id)) { Platform::Spellchecker::UpdateLanguages(dicts); } else { dicts.push_back(id); Core::App().settings().setDictionariesEnabled( std::move(dicts)); Core::App().saveSettingsDelayed(); } } DownloadDictionaryInBackground(session, counter, langs); }; if (DictionaryExists(id)) { destroyer(); return; } auto sharedLoader = std::make_shared>(); *sharedLoader = base::make_unique_q( QCoreApplication::instance(), session, id, GetDownloadLocation(id), DictPathByLangId(id), GetDownloadSize(id), crl::guard(session, destroyer)); SetBackgroundLoader(std::move(sharedLoader)); BackgroundLoaderChanged.fire_copy(id); } void AddExceptions() { const auto exceptions = ranges::views::all( kExceptions ) | ranges::views::transform([](const auto &word) { return word.utf16(); }) | ranges::views::filter([](const auto &word) { return !(Platform::Spellchecker::IsWordInDictionary(word) || Spellchecker::IsWordSkippable(word)); }) | ranges::to_vector; ranges::for_each(exceptions, Platform::Spellchecker::AddWord); } } // namespace DictLoaderPtr GlobalLoader() { return BackgroundLoader; } rpl::producer GlobalLoaderChanged() { return BackgroundLoaderChanged.events(); } DictLoader::DictLoader( QObject *parent, not_null session, int id, MTP::DedicatedLoader::Location location, const QString &folder, int64 size, Fn destroyCallback) : BlobLoader(parent, session, id, location, folder, size) , _destroyCallback(std::move(destroyCallback)) { } void DictLoader::unpack(const QString &path) { crl::async([=] { const auto success = Spellchecker::UnpackDictionary(path, id()); if (success) { QFile(path).remove(); destroy(); return; } crl::on_main([=] { fail(); }); }); } void DictLoader::destroy() { Expects(_destroyCallback); crl::on_main(_destroyCallback); } void DictLoader::fail() { BlobLoader::fail(); destroy(); } std::vector Dictionaries() { return DictionariesList; } rpl::producer<> DictionariesChanged() { return DictionariesListChanged.events(); } void RefreshDictionariesManifest(not_null session) { StartManifestRefresh(session); } int64 GetDownloadSize(int id) { const auto i = ranges::find(DictionariesList, id, &Dict::id); return (i == end(DictionariesList)) ? 0 : i->size; } MTP::DedicatedLoader::Location GetDownloadLocation(int id) { const auto i = ranges::find(DictionariesList, id, &Dict::id); if (i == end(DictionariesList)) { return MTP::DedicatedLoader::Location{}; } return MTP::DedicatedLoader::Location{ i->channel, i->postId }; } QString DictPathByLangId(int langId) { EnsurePath(); return u"%1/%2"_q.arg( DictionariesPath(), Spellchecker::LocaleFromLangId(langId).name()); } QString DictionariesPath() { return cWorkingDir() + u"tdata/dictionaries"_q; } bool UnpackDictionary(const QString &path, int langId) { const auto folder = DictPathByLangId(langId); if (!UnpackBlob(path, folder, IsGoodPartName)) { return false; } // The Serbian archive ships "sr_Cyrl_RS.{dic,aff}", but // QLocale(QLocale::Serbian).name() is "sr_RS" on our Qt, so the // Hunspell loader and DictionaryExists miss them. Rename to the // expected stem. if (langId == int(QLocale::Serbian)) { const auto dir = QDir(folder); for (const auto &ext : kDictExtensions) { const auto from = u"sr_Cyrl_RS.%1"_q.arg(ext); const auto to = u"sr_RS.%1"_q.arg(ext); if (dir.exists(from) && !dir.exists(to)) { QFile::rename(dir.filePath(from), dir.filePath(to)); } } } return true; } bool DictionaryExists(int langId) { if (!langId) { return true; } const auto folder = DictPathByLangId(langId) + '/'; return ranges::none_of(kDictExtensions, [&](const auto &ext) { const auto name = Spellchecker::LocaleFromLangId(langId).name(); return !QFile(folder + name + '.' + ext).exists(); }); } bool RemoveDictionary(int langId) { if (!langId) { return true; } const auto fileName = Spellchecker::LocaleFromLangId(langId).name(); const auto folder = u"%1/%2/"_q.arg( DictionariesPath(), fileName); return QDir(folder).removeRecursively(); } bool WriteDefaultDictionary() { // This is an unused function. const auto en = QLocale::English; if (DictionaryExists(en)) { return false; } const auto fileName = QLocale(en).name(); const auto folder = u"%1/%2/"_q.arg( DictionariesPath(), fileName); QDir(folder).removeRecursively(); const auto path = folder + fileName; QDir().mkpath(folder); auto input = QFile(u":/misc/en_US_dictionary"_q); auto output = QFile(path); if (input.open(QIODevice::ReadOnly) && output.open(QIODevice::WriteOnly)) { output.write(input.readAll()); const auto result = Spellchecker::UnpackDictionary(path, en); output.remove(); return result; } return false; } rpl::producer ButtonManageDictsState( not_null session) { if (Platform::Spellchecker::IsSystemSpellchecker()) { return rpl::single(QString()); } const auto computeString = [=] { if (!Core::App().settings().spellcheckerEnabled()) { return QString(); } const auto dicts = Core::App().settings().dictionariesEnabled(); if (dicts.empty()) { return QString(); } const auto filtered = ranges::views::all( dicts ) | ranges::views::filter( DictionaryExists ) | ranges::to_vector; return (filtered.size() < dicts.size()) ? tr::lng_contacts_loading(tr::now) : QString::number(filtered.size()); }; return rpl::single( computeString() ) | rpl::then( rpl::merge( Spellchecker::SupportedScriptsChanged(), Spellchecker::DictionariesChanged(), Core::App().settings().dictionariesEnabledChanges( ) | rpl::to_empty, Core::App().settings().spellcheckerEnabledChanges( ) | rpl::to_empty ) | rpl::map(computeString) ); } std::vector DefaultLanguages() { std::vector langs; const auto append = [&](const auto loc) { const auto l = LanguageFromLocale(loc); if (!ranges::contains(langs, l) && IsSupportedLang(l)) { langs.push_back(l); } }; const auto method = QGuiApplication::inputMethod(); langs.reserve(method ? 3 : 2); if (method) { append(method->locale()); } append(QLocale(Platform::SystemLanguage())); append(QLocale(Lang::LanguageIdOrDefault(Lang::Id()))); return langs; } void Start(not_null session) { Spellchecker::SetPhrases({ { { &ph::lng_spellchecker_submenu, tr::lng_spellchecker_submenu() }, { &ph::lng_spellchecker_add, tr::lng_spellchecker_add() }, { &ph::lng_spellchecker_remove, tr::lng_spellchecker_remove() }, { &ph::lng_spellchecker_ignore, tr::lng_spellchecker_ignore() }, } }); const auto settings = &Core::App().settings(); auto &lifetime = session->lifetime(); const auto onEnabled = [=](auto enabled) { Platform::Spellchecker::UpdateLanguages( enabled ? settings->dictionariesEnabled() : std::vector()); }; const auto guard = gsl::finally([=] { onEnabled(settings->spellcheckerEnabled()); }); if (Platform::Spellchecker::IsSystemSpellchecker()) { Spellchecker::SupportedScriptsChanged() | rpl::take(1) | rpl::on_next(AddExceptions, lifetime); return; } Spellchecker::SupportedScriptsChanged( ) | rpl::on_next(AddExceptions, lifetime); Spellchecker::SetWorkingDirPath(DictionariesPath()); settings->dictionariesEnabledChanges( ) | rpl::on_next([](auto dictionaries) { Platform::Spellchecker::UpdateLanguages(dictionaries); }, lifetime); settings->spellcheckerEnabledChanges( ) | rpl::on_next(onEnabled, lifetime); const auto method = QGuiApplication::inputMethod(); const auto connectInput = [=] { if (!method || !settings->spellcheckerEnabled()) { return; } auto callback = [=] { if (BackgroundLoader) { return; } const auto l = LanguageFromLocale(method->locale()); // Avoid pulling the manifest just because an input-method // locale flipped; only fetch if we'd actually need to // download something for the new locale. if (DictionaryExists(l)) { return; } EnsureManifestThen(session, crl::guard(session, [=] { if (!IsSupportedLang(l) || DictionaryExists(l)) { return; } DownloadDictionaryInBackground(session, 0, { l }); })); }; QObject::connect( method, &QInputMethod::localeChanged, std::move(callback)); }; if (settings->autoDownloadDictionaries()) { session->data().contactsLoaded().changes( ) | rpl::on_next([=](bool loaded) { if (!loaded) { return; } const auto enabled = settings->dictionariesEnabled(); if (!enabled.empty() && ranges::all_of(enabled, &DictionaryExists)) { // Every previously-enabled dictionary is already on // disk; no manifest fetch, no network traffic. return; } EnsureManifestThen(session, crl::guard(session, [=] { DownloadDictionaryInBackground( session, 0, DefaultLanguages()); })); }, lifetime); connectInput(); } const auto disconnect = [=] { QObject::disconnect( method, &QInputMethod::localeChanged, nullptr, nullptr); }; lifetime.add([=] { disconnect(); for (auto &[index, account] : session->domain().accounts()) { if (const auto anotherSession = account->maybeSession()) { if (anotherSession->uniqueId() != session->uniqueId()) { Spellchecker::Start(anotherSession); return; } } } }); rpl::combine( settings->spellcheckerEnabledValue(), settings->autoDownloadDictionariesValue() ) | rpl::on_next([=](bool spell, bool download) { if (spell && download) { connectInput(); return; } disconnect(); }, lifetime); } } // namespace Spellchecker #endif // !TDESKTOP_DISABLE_SPELLCHECK