From 1c933099521ceaf0f26839f38163b85542d5d3dd Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:03:07 +0200 Subject: [PATCH] [Client] Show localized card names, texts and pictures (#7294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Show localized card names, texts and pictures Localization wiring now runs end to end: the oracle importer collects foreignData for the configured language and the client renders it. - [Oracle] Import localized names and rules texts for the selected cardLang - single-face cards store their foreignData name and full text - multi-face (split/adventure/aftermath/prepare) cards collect the joined name once and join each face's translated text with the same separator as the English merge; an incomplete translation falls back to English; the joined text follows the same highest-priority-set policy as the single-face path and is only collected when localization is enabled - the wizard switching languages re-imports the card database - [Client] Display localized card info throughout the client - card info text/picture widgets and the game board re-render on language change - pictures resolve cardLang art through Scryfall's named endpoint using the localized name, falling back to id-based art when no match exists - deck editor keeps canonical English names as card identity (EditRole) while showing localized names (DisplayRole), so decks and wire names stay stable - [Card] Add CardLocalization-backed name/text lookup and cards.xml v4 localization elements with a bounded-size translation cache - [Tests] Cover oracle foreignData import (incl. multi-face joins, priority and fallback paths), XML v4 localization parsing, deck model localized display and the language-aware settings default Existing installations need to re-run Oracle to see translations: localized data only lands in cards.xml when the Oracle app is started with the preferred language selected — launch the separate "Oracle" program that ships with Cockatrice, pick the language in the wizard and let it re-import the card database. The client's database cache (cards.xml.cache) is invalidated by the cache format bump and the source-hash checks, but a cache written before the re-import can still hold English-only entries (the hash uses file size and mtime, so a same-size/same-timestamp rewrite may be served as-is); delete cards.xml.cache and relaunch if no localized names/texts show up after re-importing. * [Card] Pass localized card names and texts into CardInfo construction Address review: instead of constructing the card and then calling setLocalizedName/setLocalizedText (which emit a cardInfoChanged signal per language), both constructors, both newInstance overloads and their callers (cards.xml v4 parser and the binary cache reader) now pass the localized maps as constructor arguments. * [Client] Rename LocalizedCard:: helpers namespace to CardLocalization The namespace now matches its header file name, as the review pointed out; LocalizedCard reads more like a class or struct. Callers (card info text widget, board card name rendering) are updated to match. * [Client] Drop unused info member from the card info text widget The CardInfoPtr member was only ever initialized to nullptr and never read; remove it together with its initializer. * [PictureLoader] Add the localized picture URL explicitly, not implicitly Address review: silently prepending the Scryfall named-picture URL to the download list whenever a non-English card language was active was surprising, consumed quota per card when it failed, and could grab the wrong (canon) art on name collisions, with no way to turn it off. The insert is now opt-in and user-controlled: changing the card language adds the template to the top of the download URLs once (persisted, documented in the re-import prompt, and editable/removable in the deck editor settings), while the picture loader no longer injects it at request time. * [Card] Show card languages in the same native (English) format as the UI Address review: the card text & images language dropdown listed bare native names, some in inconsistent lowercase (e.g. "čeština", "español de España"), which makes the languages easy to mix up for users that do not read the script (e.g. 日本語 vs 한국어). It now mirrors the UI language dropdown and always pairs the native name with its English name (e.g. "Deutsch (German)", "日本語 (Japanese)"), using the same fixed casing. --------- Co-authored-by: Lukas Brübach --- .../board/abstract_card_item.cpp | 5 +- cockatrice/src/interface/card_localization.h | 59 +++++ .../card_picture_loader.cpp | 12 + .../card_picture_loader/card_picture_loader.h | 6 + .../card_picture_to_load.cpp | 14 +- .../cards/card_info_picture_widget.cpp | 2 + .../widgets/cards/card_info_text_widget.cpp | 14 +- .../widgets/cards/card_info_text_widget.h | 2 +- .../deck_editor_deck_dock_widget.cpp | 4 +- .../deck_editor/deck_state_manager.cpp | 19 +- .../widgets/dialogs/dlg_settings.cpp | 5 + .../interface/widgets/dialogs/dlg_settings.h | 1 + .../settings_page/general_settings_page.cpp | 75 +++++- .../settings_page/general_settings_page.h | 9 + ...idekt_api_response_deck_display_widget.cpp | 5 + cockatrice/src/interface/window_main.cpp | 3 + doc/carddatabase_v4/cards.xsd | 14 + libcockatrice_card/CMakeLists.txt | 2 + .../libcockatrice/card/card_info.cpp | 26 +- .../libcockatrice/card/card_info.h | 120 ++++++++- .../libcockatrice/card/card_localization.cpp | 38 +++ .../libcockatrice/card/card_localization.h | 43 +++ .../card/database/card_database_cache.cpp | 50 +++- .../card/database/parser/cockatrice_xml_4.cpp | 65 ++++- ...nterface_cards_display_settings_provider.h | 3 + .../models/deck_list/deck_list_model.cpp | 19 +- .../models/deck_list/deck_list_model.h | 11 + .../settings/cards_display_settings.cpp | 18 ++ .../settings/cards_display_settings.h | 3 + .../settings/download_settings.cpp | 19 +- .../settings/download_settings.h | 2 + oracle/src/oracleimporter.cpp | 182 ++++++++++++- oracle/src/oracleimporter.h | 74 +++++- oracle/src/oraclewizard.cpp | 4 + tests/carddatabase/carddatabase_test.cpp | 50 +++- tests/oracle/oracle_importer_test.cpp | 249 ++++++++++++++++++ tests/settings/settings_defaults_test.cpp | 13 + 37 files changed, 1199 insertions(+), 41 deletions(-) create mode 100644 cockatrice/src/interface/card_localization.h create mode 100644 libcockatrice_card/libcockatrice/card/card_localization.cpp create mode 100644 libcockatrice_card/libcockatrice/card/card_localization.h diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 1410d0c80..3969b7d03 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,6 +1,7 @@ #include "abstract_card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../game_scene.h" #include "../z_values.h" @@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, @@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS if (SettingsCache::instance().debug().getShowCardId()) { prefix = "#" + QString::number(id) + " "; } - nameStr = prefix + cardRef.name; + nameStr = prefix + CardLocalization::displayName(getCardInfo()); } painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, translatedSize.height() - 6 * scaleFactor), diff --git a/cockatrice/src/interface/card_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..0bfe4a764 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#include +#include +#include + +namespace CardLocalization +{ +/** + * @brief The language code selected for localized card text and images. + */ +inline QString displayLang() +{ + return SettingsCache::instance().cardsDisplay().getCardLang(); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized name, or an empty string for a null card. + */ +inline QString displayName(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized text, or an empty string for a null card. + */ +inline QString displayText(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedText(displayLang()); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayName(const CardInfo &card) +{ + return card.getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayText(const CardInfo &card) +{ + return card.getLocalizedText(displayLang()); +} +} // namespace CardLocalization + +#endif // COCKATRICE_CARD_LOCALIZATION_H \ No newline at end of file diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 8c81d641d..65b995de9 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,8 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) &CardPictureLoader::picsPathChanged); connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, &CardPictureLoader::picDownloadChanged); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardPictureLoader::cardLangChanged); qRegisterMetaType(); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); @@ -327,6 +330,15 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } +void CardPictureLoader::cardLangChanged() +{ + // Localized images are fetched via a different URL, but the in-memory + // pixmap cache is keyed by card name/uuid, so drop everything cached + // (including failure timestamps) to force a reload in the new language. + QPixmapCache::clear(); + failedAt.clear(); +} + bool CardPictureLoader::hasCustomArt() { auto picsPath = SettingsCache::instance().paths().getPicsPath(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index 5c3ac84a3..0d3a9d474 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -134,6 +134,12 @@ private slots: * Clears the QPixmap cache to reload images. */ void picsPathChanged(); + + /** + * @brief Triggered when the card language setting changes. + * Clears the in-memory picture caches so images reload in the new language. + */ + void cardLangChanged(); }; #endif diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index 5f4ff0bbd..7cb502e92 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -94,7 +94,8 @@ void CardPictureToLoad::populateSetUrls() } } - for (const QString &urlTemplate : urlTemplates) { + const QStringList orderedTemplates = urlTemplates; + for (const QString &urlTemplate : orderedTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { @@ -282,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } // language setting - transformMap["!sflang!"] = QString(QCoreApplication::translate( - "PictureLoader", "en", "code for scryfall's language property, not available for all languages")); + const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); + transformMap["!sflang!"] = cardLang; + + // The localized printing's own id is unknown, so Scryfall must resolve it by + // its translated name (see populateSetUrls); expose that name for the + // `/cards/named` template. + if (cardLang != "en") { + transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang); + } QString transformedUrl = urlTemplate; for (const QString &prop : transformMap.keys()) { diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index de622bdc8..2dd21e78a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -74,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index c6af5320b..e98c3c02a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,6 +1,7 @@ #include "card_info_text_widget.h" #include "../../../game_graphics/board/card_item.h" +#include "../../card_localization.h" #include #include @@ -10,7 +11,7 @@ #include #include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n grid->setRowStretch(1, 1); retranslateUi(); + + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] { + if (currentCard) { + setCard(currentCard); + } + }); } void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText) @@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) QString text = ""; text += QString("") - .arg(tr("Name:"), card->getName().toHtmlEscaped()); + .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); if (!exactCard.getPrinting().isEmpty()) { QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped(); @@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) } text += "
%1%2
"; - setTexts(text, card->getText()); + setTexts(text, CardLocalization::displayText(card)); + currentCard = exactCard; } void CardInfoTextWidget::setInvalidCardName(const QString &cardName) diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index a9c29da37..683be5ab5 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -23,7 +23,7 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - CardInfoPtr info; + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index a3653f03e..b0b31074c 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -345,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard() if (!current.isValid()) { return {}; } - const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); const QModelIndex gparent = current.parent().parent(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index e563729a4..cfe989e9f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,13 +1,20 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" + #include #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this)) { + deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel, + [this](const QString &lang) { deckListModel->setDisplayLanguage(lang); }); + connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] { setModified(true); emit historyChanged(); @@ -260,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); QModelIndex gparent = idx.parent().parent(); @@ -277,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) QString reason = tr("Moved to %1 1 × \"%2\" (%3)") // .arg(otherZoneName) - .arg(cardName) + .arg(displayCardName) .arg(providerId); return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) { @@ -291,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); - - QString reason = tr("Removed \"%1\" (all copies)").arg(cardName); + QString reason = + tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString()); return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); }); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index fb559fc4b..581920dbc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -416,6 +416,11 @@ void DlgSettings::setTab(int index) } } +AbstractSettingsPage *DlgSettings::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + void DlgSettings::updateLanguage() { qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index b700f7af9..845c0b4d6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -54,6 +54,7 @@ public: explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: void onTabClicked(int index); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 62b06fb60..36436f8a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,15 +1,21 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" #include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" #include +#include #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -46,10 +52,27 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&languageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::languageBoxChanged); + // card text & images language, independent of the UI language + cardLanguageBox.addItem(tr("English"), "en"); + for (const QString &code : CardLocalization::supportedLanguages()) { + cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code); + } + const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang()); + cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex); + + connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 1, 0); + languageGrid->addWidget(&cardLanguageBox, 1, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); @@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index) SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); } +void GeneralSettingsPage::cardLanguageBoxChanged(int index) +{ + const QString lang = cardLanguageBox.itemData(index).toString(); + SettingsCache::instance().cardsDisplay().setCardLang(lang); + + // Switching to a non-default language only takes effect after the card + // database is re-imported with that language selected; English data is always + // present, so switching back to English needs no prompt. + if (lang == "en") { + return; + } + + // The binary cache does not track the language its entries were imported in, + // and the downloaded pictures were fetched with English art names, so both are + // stale until Oracle re-imports the database in the new language: drop them. + QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache"); + CardPictureLoader::clearNetworkCache(); + CardPictureLoader::clearPixmapCache(); + + // Art is resolved by the translated card name for non-English languages, so the + // matching Scryfall URL is added to the top of the download list. It stays + // visible in the deck editor settings, where it can be removed or reordered. + const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl(); + + QString message = tr("

The card database only contains English card data. To see cards in %1, " + "Oracle must run once with this language selected and re-import the card " + "database.

" + "

The cached database and the downloaded card pictures have been cleared, so a " + "re-import is picked up without stale entries.

") + .arg(cardLanguageBox.itemText(index)); + if (localizedUrlAdded) { + message += tr("

The Scryfall URL that resolves card art by translated name was added to the top of your " + "download list. You can remove or reorder it any time.

"); + } + message += tr("

Run Oracle now?

"); + + const QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + // The answer only controls whether Oracle starts right away; the caches stay + // cleared so the next import or launch rebuilds them in the new language. + if (answer == QMessageBox::Yes) { + emit cardDatabaseUpdateRequested(); + } +} + void GeneralSettingsPage::updateStartupServerControlsVisibility() { const int index = startupTabSelector.currentIndex(); @@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi() languageGroupBox->setTitle(tr("Language settings")); languageLabel.setText(tr("Language:")); + cardLanguageBox.setItemText(0, tr("English")); + cardLanguageLabel.setText(tr("Card text & images language:")); + cardLanguageNoteLabel.setText( + tr("Foreign card names, text and art apply after you update the card database (Oracle).")); advertiseTranslationPageLabel.setText( QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index e0c1a47bf..7afba158f 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -23,6 +23,10 @@ public: static QStringList findQmFiles(); static QString languageName(const QString &lang); +signals: + /// Request to re-import the card database with the newly selected card language + void cardDatabaseUpdateRequested(); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -33,6 +37,7 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(int index); void updateStartupServerControlsVisibility(); private: @@ -46,6 +51,10 @@ private: QComboBox languageBox; QLabel advertiseTranslationPageLabel; + QLabel cardLanguageLabel; + QComboBox cardLanguageBox; + QLabel cardLanguageNoteLabel; + QLabel updateReleaseChannelLabel; QComboBox updateReleaseChannelBox; QCheckBox startupUpdateCheckCheckBox; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 66b68d823..657ef3dbe 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_api_response_deck_display_widget.h" +#include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" #include "../../../../../deck_loader/deck_loader.h" #include "../../../../cards/card_size_widget.h" @@ -10,6 +11,7 @@ #include #include +#include ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi } model = new DeckListModel(this); + model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model, + [this](const QString &lang) { model->setDisplayLanguage(lang); }); connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset); auto decklist = QSharedPointer(new DeckList); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 3daaeb8d3..722df90ef 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -33,6 +33,7 @@ #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" #include "../interface/widgets/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" @@ -246,6 +247,8 @@ void MainWindow::actFullScreen(bool checked) void MainWindow::actSettings() { DlgSettings dlg(this); + auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage)); + connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates); dlg.exec(); } diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 59ca3e560..82aed32ab 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -35,6 +35,13 @@ + + + + + + + @@ -49,6 +56,13 @@ + + + + + + + diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index 081e6fd05..15388ecfc 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/card_localization.h libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h @@ -27,6 +28,7 @@ add_library( ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp + libcockatrice/card/card_localization.cpp libcockatrice/card/lazy_properties_hash.cpp libcockatrice/card/database/card_database.cpp libcockatrice/card/database/card_database_cache.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 786e17950..56c737793 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -40,8 +40,11 @@ CardInfo::CardInfo(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(LazyPropertiesHash(_properties)), + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)), + localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) { @@ -59,8 +62,11 @@ CardInfo::CardInfo(const QString &_name, SetToPrintingsMap _sets, const UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames) + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) @@ -83,10 +89,12 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) { - CardInfoPtr ptr( - new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes)); + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, + _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -109,11 +117,13 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets) + bool _appendToSets, + QMap _localizedNames, + QMap _localizedTexts) { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), - std::move(_altNames))); + std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); if (_appendToSets) { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 392dc3849..17894cec5 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -77,6 +77,9 @@ private: QString text; ///< Text description or rules text of the card. bool isToken; ///< Whether this card is a token or not. + QMap localizedNames; ///< Localized card names, keyed by language code. + QMap localizedTexts; ///< Localized rules text, keyed by language code. + LazyPropertiesHash properties; ///< Key-value store of dynamic card properties. QList relatedCards; ///< Forward references to related cards. @@ -100,6 +103,8 @@ public: * @param _reverseRelatedCards Backward references to related cards. * @param _sets Map of set names to printing information. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -108,7 +113,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Constructs a CardInfo from a cache snapshot with precomputed derived @@ -130,6 +137,8 @@ public: * @param _uiAttributes Attributes that affect display and game logic. * @param _simpleName Precomputed simplified name. * @param _altNames Precomputed alternate names. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -140,7 +149,9 @@ public: SetToPrintingsMap _sets, UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames); + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -151,7 +162,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), + isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), + properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -179,6 +191,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -188,7 +202,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Creates a new instance from a cache snapshot with precomputed @@ -208,6 +224,8 @@ public: * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -220,7 +238,9 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true); + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -270,6 +290,96 @@ public: text = _text; emit cardInfoChanged(smartThis); } + + /** + * @brief Returns the card name in the given language, falling back to the + * English name when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized name, or the English name as fallback. + */ + [[nodiscard]] const QString &getLocalizedName(const QString &lang) const + { + const auto it = localizedNames.constFind(lang); + return it != localizedNames.constEnd() ? it.value() : name; + } + + /** + * @brief Returns the rules text in the given language, falling back to the + * English text when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized text, or the English text as fallback. + */ + [[nodiscard]] const QString &getLocalizedText(const QString &lang) const + { + const auto it = localizedTexts.constFind(lang); + return it != localizedTexts.constEnd() ? it.value() : text; + } + + /** + * @brief Returns the localized card names keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedNames() const + { + return localizedNames; + } + + /** + * @brief Returns the localized rules text keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedTexts() const + { + return localizedTexts; + } + + /** + * @brief Sets the card name for the given language. + * + * @param lang Language code. + * @param _localizedName The localized card name. + */ + void setLocalizedName(const QString &lang, const QString &_localizedName) + { + if (localizedNames.value(lang) == _localizedName) { + return; + } + localizedNames.insert(lang, _localizedName); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Sets the rules text for the given language. + * + * @param lang Language code. + * @param _localizedText The localized rules text. + */ + void setLocalizedText(const QString &lang, const QString &_localizedText) + { + if (localizedTexts.value(lang) == _localizedText) { + return; + } + localizedTexts.insert(lang, _localizedText); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Returns the language codes for which this card has a localized + * name or rules text. + */ + [[nodiscard]] QStringList localizationLanguages() const + { + QStringList languages = localizedNames.keys(); + languages.append(localizedTexts.keys()); + languages.removeDuplicates(); + return languages; + } [[nodiscard]] bool getIsToken() const { return isToken; diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..03c6c659b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,38 @@ +#include "card_localization.h" + +#include +#include +#include + +namespace CardLocalization +{ +const QStringList &supportedLanguages() +{ + static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"}; + return languages; +} + +QString languageDisplayName(const QString &lang) +{ + static const QHash displayNames = { + {"cs", "Česky (Czech)"}, + {"de", "Deutsch (German)"}, + {"es", "Español (Spanish)"}, + {"fr", "Français (French)"}, + {"it", "Italiano (Italian)"}, + {"ja", "日本語 (Japanese)"}, + {"ko", "한국어 (Korean)"}, + {"pt", "Português (Portuguese)"}, + {"ru", "Русский (Russian)"}, + {"he", "עברית (Hebrew)"}, + {"zhs", "简体中文 (Chinese Simplified)"}, + {"zht", "繁體中文 (Chinese Traditional)"}, + }; + const QString displayName = displayNames.value(lang); + if (!displayName.isEmpty()) { + return displayName; + } + const QString nativeName = QLocale(lang).nativeLanguageName(); + return nativeName.isEmpty() ? lang : nativeName; +} +} // namespace CardLocalization \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h new file mode 100644 index 000000000..a9c8d28ea --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,43 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include + +/** + * @namespace CardLocalization + * @ingroup Cards + * + * @brief Shared language metadata for localized card text and images. + * + * Lists the language codes Cockatrice can display localized card data for and + * provides human-readable names. The list is shared between Oracle (which + * imports the selected language's card data) and the client settings UI (which + * offers the language choice). + */ +namespace CardLocalization +{ +/** + * @brief Language codes for which localized card data can be imported/displayed. + * + * Matches the languages Scryfall can serve localized card images for. "en" is + * always available as the default/fallback and is not listed here. + * + * @return The list of supported language codes. + */ +[[nodiscard]] const QStringList &supportedLanguages(); + +/** + * @brief Human-readable name for a language code. + * + * Follows the same "native name (English name)" format the UI language list + * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The language's native name with its English name in parentheses, or + * the code itself if it cannot be resolved. + */ +[[nodiscard]] QString languageDisplayName(const QString &lang); +} // namespace CardLocalization + +#endif // CARD_LOCALIZATION_H \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 2b27f50f8..3165ff871 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -17,7 +17,7 @@ namespace { constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" -constexpr quint32 CACHE_VERSION = 2; +constexpr quint32 CACHE_VERSION = 3; // ---- Primitives ----------------------------------------------------------- @@ -71,6 +71,35 @@ QDate readDate(QDataStream &in) return d; } +void writeStringMap(QDataStream &out, const QMap &map) +{ + out << static_cast(map.size()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + writeString(out, it.key()); + writeString(out, it.value()); + } +} + +QMap readStringMap(QDataStream &in) +{ + QMap map; + quint32 count = 0; + in >> count; + if (in.status() != QDataStream::Ok) { + return map; + } + for (quint32 i = 0; i < count; ++i) { + QString key = readString(in); + QString value = readString(in); + if (in.status() != QDataStream::Ok) { + map.clear(); + return map; + } + map.insert(key, value); + } + return map; +} + // ---- CardRelation ---------------------------------------------------------- void writeRelation(QDataStream &out, const CardRelation *rel) @@ -195,6 +224,10 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) for (const CardRelation *rel : reverse) { writeRelation(out, rel); } + + // localized card data + writeStringMap(out, card->getLocalizedNames()); + writeStringMap(out, card->getLocalizedTexts()); } CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) @@ -268,8 +301,19 @@ CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) reverse.append(readRelation(in)); } - return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName, - altNames, false); + const QMap localizedNames = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + const QMap localizedTexts = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + + CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, + simpleName, altNames, false, localizedNames, localizedTexts); + + return card; } // ---- FormatRules ----------------------------------------------------------- diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index ec460d685..19e972a7a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -273,6 +273,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString name = QString(""); QString text = QString(""); QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -298,6 +300,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) // generic properties } else if (xmlName == "prop") { properties = loadCardPropertiesFromXml(xml); + // localized card data + } else if (xmlName == "localizations") { + while (!xml.atEnd()) { + if (xml.readNextStartElement()) { + const QString elementName = xml.name().toString(); + if (elementName == "localization") { + const QString lang = xml.attributes().value("lang").toString(); + QString localizedName; + QString localizedText; + while (!xml.atEnd()) { + if (xml.readNext() == QXmlStreamReader::EndElement) { + break; + } + if (xml.isStartElement()) { + const QString childName = xml.name().toString(); + QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements); + if (childName == "name") { + localizedName = value; + } else if (childName == "text") { + localizedText = value; + } + } + } + if (!lang.isEmpty()) { + if (!localizedName.isEmpty()) { + localizedNames.insert(lang, localizedName); + } + if (!localizedText.isEmpty()) { + localizedTexts.insert(lang, localizedText); + } + } + } else { + xml.skipCurrentElement(); + } + } else { + break; + } + } // positioning info } else if (xmlName == "tablerow") { tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt(); @@ -399,8 +439,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, + attributes, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. @@ -517,6 +558,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } xml.writeEndElement(); + // localized card data + const QStringList localizedLanguages = info->localizationLanguages(); + if (!localizedLanguages.isEmpty()) { + xml.writeStartElement("localizations"); + const QMap &localizedNames = info->getLocalizedNames(); + const QMap &localizedTexts = info->getLocalizedTexts(); + for (const QString &lang : localizedLanguages) { + xml.writeStartElement("localization"); + xml.writeAttribute("lang", lang); + if (localizedNames.contains(lang)) { + xml.writeTextElement("name", localizedNames.value(lang)); + } + if (localizedTexts.contains(lang)) { + xml.writeTextElement("text", localizedTexts.value(lang)); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + } + // sets for (const auto &printings : info->getSets()) { for (const PrintingInfo &set : printings) { diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3f2cbbe8e..304ab3cb7 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#include + class ICardsDisplaySettingsProvider { public: @@ -26,6 +28,7 @@ public: [[nodiscard]] virtual int getEDHRecCardSize() const = 0; [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; [[nodiscard]] virtual int getSampleHandSize() const = 0; + [[nodiscard]] virtual QString getCardLang() const = 0; }; #endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 76afca0c4..a5628a844 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -28,6 +28,16 @@ DeckListModel::~DeckListModel() delete root; } +void DeckListModel::setDisplayLanguage(const QString &lang) +{ + if (displayLang == lang) { + return; + } + displayLang = lang; + emit layoutAboutToBeChanged(); + emit layoutChanged(); +} + /** * @brief Extract the value from the card that is used for the group criteria. * @param info Pointer to card information. @@ -181,8 +191,15 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: return card->getNumber(); - case DeckListModelColumns::CARD_NAME: + case DeckListModelColumns::CARD_NAME: { + if (role == Qt::DisplayRole) { + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); + if (info) { + return info->getLocalizedName(displayLang); + } + } return card->getName(); + } case DeckListModelColumns::CARD_SET: return card->getCardSetShortName(); case DeckListModelColumns::CARD_COLLECTOR_NUMBER: diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 09600ca67..ce6d7f8cb 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -287,6 +287,16 @@ public: explicit DeckListModel(QObject *parent, const QSharedPointer &deckList); ~DeckListModel() override; + /** + * @brief Selects the language code for localized card names in the display role. + * + * The model never reads global settings itself; callers wire this to the card + * language setting (including reacting to its changes) rather than the model + * querying it. + * @param lang Language code; "en" shows the canonical English names. + */ + void setDisplayLanguage(const QString &lang); + /** * @brief Returns the root index of the model. * @return QModelIndex representing the root node. @@ -408,6 +418,7 @@ private: DeckListModelGroupCriteria::Type activeGroupCriteria = DeckListModelGroupCriteria::MAIN_TYPE; int lastKnownColumn; /**< Last column used for sorting. */ Qt::SortOrder lastKnownOrder; /**< Last known sort order. */ + QString displayLang = "en"; /**< Language code for localized card names in the display role. */ InnerDecklistNode *createNodeIfNeeded(const QString &name, InnerDecklistNode *parent); QModelIndex nodeToIndex(AbstractDecklistNode *node) const; diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f528a7c4b..b8eafca6c 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -105,6 +105,11 @@ int CardsDisplaySettings::getSampleHandSize() const return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); } +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) { setValue(_displayCardNames, "displayCardNames"); @@ -224,3 +229,16 @@ void CardsDisplaySettings::setSampleHandSize(int _sampleHandSize) setValue(_sampleHandSize, "sampleHandSize", "cards", "cardSize"); emit sampleHandSizeChanged(_sampleHandSize); } + +void CardsDisplaySettings::setCardLang(const QString &_cardLang) +{ + if (_cardLang == getCardLang()) { + return; + } + setValue(_cardLang, "cardLang"); + // Flush to disk immediately: the Oracle tool is a separate process that + // reads this value to decide which foreignData to import, so it must not + // observe a stale (pre-change) value. + sync(); + emit cardLangChanged(_cardLang); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index dbafa32ae..0b47ce490 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -31,6 +31,7 @@ public: [[nodiscard]] int getEDHRecCardSize() const override; [[nodiscard]] int getArchidektPreviewSize() const override; [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; void setDisplayCardNames(bool _displayCardNames); void setRoundCardCorners(bool _roundCardCorners); @@ -52,6 +53,7 @@ public: void setEDHRecCardSize(int _edhrecCardSize); void setArchidektPreviewCardSize(int _archidektPreviewCardSize); void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); signals: void displayCardNamesChanged(); @@ -68,6 +70,7 @@ signals: void edhRecCardSizeChanged(); void archidektPreviewSizeChanged(); void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index cfa1c054e..eb73e58ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -4,11 +4,14 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { "https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg", - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!&lang=!sflang!", + "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image&lang=!sflang!", "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card", "https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"}; +const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = + "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -29,6 +32,18 @@ void DownloadSettings::resetToDefaultURLs() setValue(QVariant::fromValue(DEFAULT_DOWNLOAD_URLS), "urls"); } +bool DownloadSettings::addLocalizedScryfallUrl() +{ + const QStringList urls = getAllURLs(); + if (urls.contains(SCRYFALL_NAMED_LOCALIZED_URL)) { + return false; + } + QStringList updated = urls; + updated.prepend(SCRYFALL_NAMED_LOCALIZED_URL); + setDownloadUrls(updated); + return true; +} + bool DownloadSettings::getPicDownload() const { return getValue("pictureDownload", QString(), QString(), true).toBool(); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index a3a6f4ca9..ae49884f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -15,6 +15,7 @@ class DownloadSettings : public SettingsManager friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; public: explicit DownloadSettings(const QString &, QObject *); @@ -22,6 +23,7 @@ public: QStringList getAllURLs() const; void setDownloadUrls(const QStringList &downloadURLs); void resetToDefaultURLs(); + [[nodiscard]] bool addLocalizedScryfallUrl(); [[nodiscard]] bool getPicDownload() const; void setPicDownload(bool _picDownload); [[nodiscard]] bool getDownloadSpoilersStatus() const; diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index fdf11ef02..88b522197 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -22,8 +23,9 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -33,6 +35,42 @@ OracleImporter::OracleImporter(QObject *parent) : QObject(parent) { } +void OracleImporter::setCardLang(const QString &lang) +{ + cardLang = lang.trimmed().toLower(); + localizationEnabled = cardLang != "en" && CardLocalization::supportedLanguages().contains(cardLang); +} + +/** + * @brief Maps the MTGJSON foreignData language names to the short codes used by + * Scryfall and stored in cards.xml (e.g. "German" -> "de"). + * @param language The language name found in the MTGJSON foreignData entries. + * @return The short language code, or an empty string if unknown. + */ +static QString mtgjsonLanguageToCode(const QString &language) +{ + static const QHash map = { + {"Chinese Simplified", "zhs"}, + {"Chinese Traditional", "zht"}, + {"English", "en"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "grc"}, + {"Ancient Greek", "grc"}, + {"Hebrew", "he"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latin", "la"}, + {"Phyrexian", "ph"}, + {"Portuguese (Brazil)", "pt"}, + {"Russian", "ru"}, + {"Sanskrit", "sa"}, + {"Spanish", "es"}, + }; + return map.value(language); +} + static CardSet::Priority getSetPriority(const QString &setType, const QString &shortName) { if (!setTypePriorities.contains(setType.toLower())) { @@ -254,6 +292,101 @@ static QString getJsonString(const QJsonObject &obj, const QString &key) return obj.value(key).toVariant().toString(); } +static QString normalizeCardName(QString name) +{ + // Mirror of the name cleanup applied in addCard(), so collected localization + // keys line up with the card map keys (Æ → AE, curly apostrophe → straight). + name = name.replace("Æ", "AE"); + name = name.replace("’", "'"); + return name; +} + +static QString matchingForeignEntryText(const QJsonObject &card, const QString &cardLang) +{ + // Multi-face cards (split/aftermath/adventure/prepare) expose each face as a + // separate card object, each with its own foreignData entry carrying that + // face's rules text; single-face cards carry the full text in one entry. + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) == cardLang) { + return getJsonString(entry, "text"); + } + } + return QString(); +} + +void OracleImporter::collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText) +{ + if (!localizationEnabled) { + return; + } + + LocalizedCardEntry incoming; + bool found = false; + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) { + continue; + } + incoming.name = getJsonString(entry, "name"); + incoming.text = collectText ? getJsonString(entry, "text") : QString(); + found = true; + break; + } + if (!found) { + return; + } + + // Prefer the entry from the highest-priority set (lower enum value = more + // authoritative); printings of equal priority keep the first one seen. + incoming.priority = currentSet->getPriority(); + const auto existing = localizedEntries.constFind(cardKey); + if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) { + localizedEntries.insert(cardKey, incoming); + } +} + +void OracleImporter::applyLocalizedData() +{ + if (!localizationEnabled) { + return; + } + for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + const LocalizedCardEntry &entry = it.value(); + if (!entry.name.isEmpty()) { + card->setLocalizedName(cardLang, entry.name); + } + if (!entry.text.isEmpty()) { + card->setLocalizedText(cardLang, entry.text); + } + } + localizedEntries.clear(); + + for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + if (!it.value().text.isEmpty()) { + card->setLocalizedText(cardLang, it.value().text); + } + } + splitLocalizedTexts.clear(); +} + int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name @@ -397,13 +530,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { auto _faceName = getJsonString(card, "faceName"); - SplitCardPart split(_faceName, text, properties, printingInfo); + // MTGJSON exposes each face as a separate card object, each with its + // own foreignData entry holding that face's rules text; collect it so + // the per-face texts can be joined the same way as the English text. + const QString faceLocalizedText = + localizationEnabled ? matchingForeignEntryText(card, cardLang) : QString(); + SplitCardPart split(_faceName, text, properties, printingInfo, faceLocalizedText); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { splitCards.insert(name + numProperty, {{split}, name}); } else { found_iter->first.append(split); } + // MTGJSON's foreignData name is the joined name present on every + // face, so collect the name once. + collectForeignData(normalizeCardName(name), currentSet, card, false); } else { // relations QList relatedCards; @@ -446,6 +587,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } + collectForeignData(normalizeCardName(name + numComponent), currentSet, card); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; @@ -459,6 +602,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { QString text; + QString localizedText; + bool localizedTextComplete = true; QHash properties; PrintingInfo printingInfo; @@ -468,6 +613,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } text.append(tmp.getText()); + // Build the cardLang text by joining each face's translated text with + // the same separator as the English text. Any face missing a complete + // translation abandons the whole join, falling back to the English text. + if (localizedTextComplete) { + const QString partLocalizedText = tmp.getLocalizedText(); + if (partLocalizedText.isEmpty()) { + localizedTextComplete = false; + } else { + if (!localizedText.isEmpty()) { + localizedText.append(splitCardTextSeparator); + } + localizedText.append(partLocalizedText); + } + } + if (properties.isEmpty()) { properties = tmp.getProperties(); printingInfo = tmp.getPrintingInfo(); @@ -500,6 +660,18 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + if (localizationEnabled && localizedTextComplete && !localizedText.isEmpty()) { + // Same priority policy as collectForeignData(): the joined text from the + // highest-priority set seen so far wins, applied once all printings are in. + LocalizedCardEntry entry; + entry.text = localizedText; + entry.priority = currentSet->getPriority(); + const QString entryKey = normalizeCardName(name); + const auto existing = splitLocalizedTexts.constFind(entryKey); + if (existing == splitLocalizedTexts.constEnd() || entry.priority < existing->priority) { + splitLocalizedTexts.insert(entryKey, entry); + } + } numCards++; } @@ -654,6 +826,8 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + applyLocalizedData(); + emit setIndexChanged(0, setIndex, QString()); // total number of sets @@ -679,4 +853,6 @@ void OracleImporter::clear() cards.clear(); allSets.clear(); rawSetsData.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 748056772..c8d3a19bf 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -107,7 +107,8 @@ public: SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo); + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -116,6 +117,13 @@ public: { return text; } + /** + * @brief The cardLang rules text of this face's foreignData entry, if any. + */ + inline const QString &getLocalizedText() const + { + return localizedText; + } inline const QHash &getProperties() const { return properties; @@ -128,10 +136,18 @@ public: private: QString name; QString text; + QString localizedText; QHash properties; PrintingInfo printingInfo; }; +struct LocalizedCardEntry +{ + QString name; + QString text; + CardSet::Priority priority = CardSet::PriorityLowest; +}; + class OracleImporter : public QObject { Q_OBJECT @@ -171,12 +187,53 @@ private: */ QAtomicInt importCancelled; + /** + * The ISO-639 language code whose foreignData is imported; "en" by default. + */ + QString cardLang = "en"; + + /** + * Whether cardLang is a supported language other than English, so per-card + * foreignData scanning can be skipped entirely when disabled. + */ + bool localizationEnabled = false; + + /** + * Localized name/text collected per imported card key while parsing sets, + * applied to the CardInfo objects by applyLocalizedData() once all + * printings have been seen so the best-priority one wins. + */ + QMap localizedEntries; + + /** + * cardLang rules text collected for split-card names while parsing sets, + * applied by applyLocalizedData(). Kept apart from localizedEntries because + * MTGJSON emits each split face as its own card object with the joined name + * on every foreignData entry: names and the per-face text join have different + * completeness and must not overwrite each other under the same key. + */ + QMap splitLocalizedTexts; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo); + + /** + * Records the first foreignData entry matching cardLang for the given card + * key, keeping the entry from the highest-priority set seen so far. + * + * Multi-face cards (split, adventure, aftermath, prepare) pass collectText = + * false: MTGJSON emits one foreignData entry per face with the same joined + * name but only that face's text, so the name is collected here while the + * per-face texts are joined during the split-card merge. + */ + void collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText = true); signals: void setIndexChanged(int cardsImported, int setIndex, const QString &setName); void dataReadProgress(int bytesRead, int totalBytes); @@ -195,6 +252,15 @@ public: { progressReporting = enabled; } + /** + * Selects the ISO-639 language code whose foreignData is imported. + * English (the default) and unsupported codes disable localization. + */ + void setCardLang(const QString &lang); + const QString &getCardLang() const + { + return cardLang; + } /** * Scans the given JSON document for set metadata. Takes the data by value so * the wizard can hand over its decompressed buffer without copying it. @@ -212,6 +278,12 @@ public: { importCancelled.storeRelease(1); } + /** + * Applies the collected localized names/texts to the imported cards. + * Called automatically at the end of startImport(); exposed separately so + * tests can drive it after importing sets directly. + */ + void applyLocalizedData(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); /** diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index dd3ac6459..9ce0d7b51 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) @@ -37,6 +38,9 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage); importer = new OracleImporter(this); + // Import card text in the language the client displays, if supported: + // foreignData for any other language is never imported. + importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); nam = new QNetworkAccessManager(this); diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..392f6395a 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -2,9 +2,14 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include #include #include - namespace { @@ -33,6 +38,49 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(0, db->query()->getAllMainCardTypes().size()) << "Types not empty after clear"; ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } + +TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); + card->setLocalizedName("de", "Blitzschlag"); + card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + const QString fileName = tempDir.filePath("cards.xml"); + NoopCardPreferenceProvider prefProvider; + CockatriceXml4Parser writer(&prefProvider, &controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&prefProvider, &controller); + QFile file(fileName); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + parser.parseFileInto(file, data); + + CardInfoPtr loaded = data.cards.value("Lightning Bolt"); + ASSERT_FALSE(loaded.isNull()); + ASSERT_EQ(loaded->getName(), "Lightning Bolt"); + ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); +} } // namespace int main(int argc, char **argv) diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 66c7bdbe1..f66616e37 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -72,6 +72,18 @@ protected: return card; } + // Helper: build a single MTGJSON foreignData entry + QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text) + { + QJsonObject entry; + entry["language"] = language; + entry["name"] = name; + if (!text.isEmpty()) { + entry["text"] = text; + } + return entry; + } + NoopCardSetPriorityController *controller; OracleImporter *importer; CardSetPtr set; @@ -872,6 +884,243 @@ TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions) ASSERT_GT(emissions, 0); } +// Localized card text tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + // English identity untouched + ASSERT_EQ(result->getName(), "Lightning Bolt"); + ASSERT_EQ(result->getText(), "Rules text."); +} + +TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards) +{ + // MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one + // card object per face; every face carries the joined name but only its own + // face's rules text in foreignData. The importer joins the per-face texts with + // the same separator as the English merge. + QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge"); + front["layout"] = "adventure"; + front["faceName"] = "Disruptive Stormbrood"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{ + makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine " + "Verzauberung deiner Wahl.")}; + QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge"); + back["layout"] = "adventure"; + back["faceName"] = "Petty Revenge"; + back["side"] = "b"; + back["text"] = "Destroy target creature."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache"); + ASSERT_EQ(result->getLocalizedText("de"), + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung " + "deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger."); + // English identity untouched + ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge"); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature."); +} + +TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText) +{ + // Both faces must carry a foreignData text for the joined text; otherwise the + // rules text stays English while the localized name (from a later complete + // printing) is still applied. + QJsonObject front = makeCard("Wear // Tear"); + front["layout"] = "split"; + front["faceName"] = "Wear"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")}; + QJsonObject back = makeCard("Wear // Tear"); + back["layout"] = "split"; + back["faceName"] = "Tear"; + back["side"] = "b"; + back["text"] = "Tear rules text."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + // The joined name is still applied. + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + // The incomplete text must not become the card's localized text. + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text."); +} + +TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + // cardLang defaults to "en" — foreignData must never be imported + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); +} + +TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")}; + QJsonArray cards{card}; + + importer->setCardLang("xx"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint) +{ + // First printing in a reprint set, then another in a (more authoritative) + // core set: the core set's German text must win even though it was seen later. + QJsonObject reprintCard = makeCard("Lightning Bolt"); + reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard}); + + QJsonObject primaryCard = makeCard("Lightning Bolt"); + primaryCard["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryCard}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint) +{ + // Split cards print each face as its own card object; the joined text is + // collected per set with the same priority policy as single-face cards, so a + // reprint set's German text must yield to the core set's even when reprints + // are imported first. + QJsonObject reprintFront = makeCard("Wear // Tear"); + reprintFront["layout"] = "split"; + reprintFront["faceName"] = "Wear"; + reprintFront["side"] = "a"; + reprintFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")}; + QJsonObject reprintBack = makeCard("Wear // Tear"); + reprintBack["layout"] = "split"; + reprintBack["faceName"] = "Tear"; + reprintBack["side"] = "b"; + reprintBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack}); + + QJsonObject primaryFront = makeCard("Wear // Tear"); + primaryFront["layout"] = "split"; + primaryFront["faceName"] = "Wear"; + primaryFront["side"] = "a"; + primaryFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")}; + QJsonObject primaryBack = makeCard("Wear // Tear"); + primaryBack["layout"] = "split"; + primaryBack["faceName"] = "Tear"; + primaryBack["side"] = "b"; + primaryBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")}; + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = QJsonArray{card}; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + importer->setCardLang("pt"); + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact))); + ASSERT_EQ(importer->startImport(), 1); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("pt"), "Raio"); + ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index eda778ae9..340859323 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -549,6 +549,19 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardLang(), QString("en")); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setCardLang("de"); + ASSERT_EQ(s.getCardLang(), QString("de")); +} + // --- VisualDeckStorageSettings --- TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default)