[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.
This commit is contained in:
Lukas Brübach 2026-09-06 03:35:38 +02:00
parent fd82b140a8
commit 8bb2337117
35 changed files with 1138 additions and 25 deletions

View file

@ -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 + LocalizedCard::displayName(getCardInfo());
}
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
translatedSize.height() - 6 * scaleFactor),

View file

@ -0,0 +1,59 @@
#ifndef COCKATRICE_CARD_LOCALIZATION_H
#define COCKATRICE_CARD_LOCALIZATION_H
#include "../client/settings/cache_settings.h"
#include <QString>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/settings/cards_display_settings.h>
namespace LocalizedCard
{
/**
* @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 LocalizedCard
#endif // COCKATRICE_CARD_LOCALIZATION_H

View file

@ -20,6 +20,7 @@
#include <QThread>
#include <algorithm>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
@ -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<ExactCard>();
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();

View file

@ -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

View file

@ -94,7 +94,20 @@ void CardPictureToLoad::populateSetUrls()
}
}
for (const QString &urlTemplate : urlTemplates) {
QStringList orderedTemplates = urlTemplates;
if (SettingsCache::instance().cardsDisplay().getCardLang() != "en") {
// Scryfall serves localized art from per-language printings, which have
// their own ids. The ids stored in the card database belong to the
// English prints, so id-based templates (cards.scryfall.io,
// api.scryfall.com/cards/!set:uuid!) can only ever resolve English
// scans; the `lang=` parameter is ignored on them. Resolve the localized
// printing by its translated name (and the language code) ahead of the
// id-based templates instead.
orderedTemplates.prepend("https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!"
"&format=image&face=!prop:side!");
}
for (const QString &urlTemplate : orderedTemplates) {
QString transformedUrl = transformUrl(urlTemplate);
if (!transformedUrl.isEmpty()) {
@ -282,8 +295,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()) {

View file

@ -74,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT
update();
});
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
&CardInfoPictureWidget::updatePixmap);
}
/**

View file

@ -1,6 +1,7 @@
#include "card_info_text_widget.h"
#include "../../../game_graphics/board/card_item.h"
#include "../../card_localization.h"
#include <QGridLayout>
#include <QLabel>
@ -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 = "<table width=\"100%\" border=0 cellspacing=0 cellpadding=0>";
text += QString("<tr><td>%1</td><td width=\"5\"></td><td>%2</td></tr>")
.arg(tr("Name:"), card->getName().toHtmlEscaped());
.arg(tr("Name:"), LocalizedCard::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 += "</table>";
setTexts(text, card->getText());
setTexts(text, LocalizedCard::displayText(card));
currentCard = exactCard;
}
void CardInfoTextWidget::setInvalidCardName(const QString &cardName)

View file

@ -24,6 +24,7 @@ private:
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:

View file

@ -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();

View file

@ -1,13 +1,20 @@
#include "deck_state_manager.h"
#include "../../../client/settings/cache_settings.h"
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_history_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer<DeckList>(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()); });
}

View file

@ -416,6 +416,11 @@ void DlgSettings::setTab(int index)
}
}
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
{
return pages.value(static_cast<int>(which));
}
void DlgSettings::updateLanguage()
{
qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)

View file

@ -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);

View file

@ -1,15 +1,20 @@
#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 <QCoreApplication>
#include <QFile>
#include <QFileDialog>
#include <QGridLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QTranslator>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
@ -46,10 +51,27 @@ GeneralSettingsPage::GeneralSettingsPage()
connect(&languageBox, qOverload<int>(&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<int>(&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 +434,43 @@ 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();
const QMessageBox::StandardButton answer =
QMessageBox::question(this, tr("Card text & images language changed"),
tr("<p>The card database only contains English card data. To see cards in <b>%1</b>, "
"<b>Oracle</b> must run once with this language selected and re-import the card "
"database.</p>"
"<p>The cached database and the downloaded card pictures have been cleared, so a "
"re-import is picked up without stale entries.</p>"
"<p>Run Oracle now?</p>")
.arg(cardLanguageBox.itemText(index)),
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 +488,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("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));

View file

@ -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;

View file

@ -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 <QSortFilterProxyModel>
#include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/settings/cards_display_settings.h>
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<DeckList>(new DeckList);

View file

@ -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<GeneralSettingsPage *>(dlg.page(DlgSettings::GeneralPage));
connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates);
dlg.exec();
}

View file

@ -35,6 +35,13 @@
</xs:extension>
</xs:simpleContent>
</xs:complexType>
<xs:complexType name="localizationType">
<xs:sequence>
<xs:element type="xs:string" name="name" minOccurs="0" maxOccurs="1" />
<xs:element type="xs:string" name="text" minOccurs="0" maxOccurs="1" />
</xs:sequence>
<xs:attribute type="xs:string" name="lang" use="required" />
</xs:complexType>
<xs:group name="cardPropertyGroup">
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
@ -49,6 +56,13 @@
<xs:group ref="cardPropertyGroup"/>
</xs:complexType>
</xs:element>
<xs:element name="localizations" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:sequence>
<xs:element type="localizationType" name="localization" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element type="cardInSetType" name="set" minOccurs="1" maxOccurs="unbounded" />
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element type="relatedType" name="related" minOccurs="0" maxOccurs="unbounded" />

View file

@ -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

View file

@ -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<QString, QString> localizedNames; ///< Localized card names, keyed by language code.
QMap<QString, QString> localizedTexts; ///< Localized rules text, keyed by language code.
LazyPropertiesHash properties; ///< Key-value store of dynamic card properties.
QList<CardRelation *> relatedCards; ///< Forward references to related cards.
@ -151,7 +154,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)
@ -270,6 +274,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<QString, QString> &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<QString, QString> &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;

View file

@ -0,0 +1,25 @@
#include "card_localization.h"
#include <QLocale>
#include <QStringList>
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)
{
if (lang == "zhs") {
return QStringLiteral("简体中文");
}
if (lang == "zht") {
return QStringLiteral("繁體中文");
}
const QString nativeName = QLocale(lang).nativeLanguageName();
return nativeName.isEmpty() ? lang : nativeName;
}
} // namespace CardLocalization

View file

@ -0,0 +1,40 @@
#ifndef CARD_LOCALIZATION_H
#define CARD_LOCALIZATION_H
#include <QString>
#include <QStringList>
/**
* @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 native name for a language code.
*
* @param lang Language code (e.g. "de", "ja", "zhs").
* @return The language's name in its own language, or the code itself if
* it cannot be resolved.
*/
[[nodiscard]] QString languageDisplayName(const QString &lang);
} // namespace CardLocalization
#endif // CARD_LOCALIZATION_H

View file

@ -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<QString, QString> &map)
{
out << static_cast<quint32>(map.size());
for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
writeString(out, it.key());
writeString(out, it.value());
}
}
QMap<QString, QString> readStringMap(QDataStream &in)
{
QMap<QString, QString> 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,25 @@ 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);
CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui,
simpleName, altNames, false);
const QMap<QString, QString> localizedNames = readStringMap(in);
if (in.status() != QDataStream::Ok) {
return nullptr;
}
const QMap<QString, QString> localizedTexts = readStringMap(in);
if (in.status() != QDataStream::Ok) {
return nullptr;
}
for (auto it = localizedNames.constBegin(); it != localizedNames.constEnd(); ++it) {
card->setLocalizedName(it.key(), it.value());
}
for (auto it = localizedTexts.constBegin(); it != localizedTexts.constEnd(); ++it) {
card->setLocalizedText(it.key(), it.value());
}
return card;
}
// ---- FormatRules -----------------------------------------------------------

View file

@ -273,6 +273,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml)
QString name = QString("");
QString text = QString("");
QHash<QString, QString> properties;
QMap<QString, QString> localizedNames;
QMap<QString, QString> localizedTexts;
QList<CardRelation *> 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();
@ -401,6 +441,12 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml)
.upsideDownArt = upsideDown};
CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards,
reverseRelatedCards, _sets, attributes);
for (auto it = localizedNames.constBegin(); it != localizedNames.constEnd(); ++it) {
newCard->setLocalizedName(it.key(), it.value());
}
for (auto it = localizedTexts.constBegin(); it != localizedTexts.constEnd(); ++it) {
newCard->setLocalizedText(it.key(), it.value());
}
if (targetData) {
// Mirror CardDatabase::addCard: if a card with this name already
// exists, merge the new printings into it instead of replacing.
@ -517,6 +563,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<QString, QString> &localizedNames = info->getLocalizedNames();
const QMap<QString, QString> &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) {

View file

@ -1,6 +1,8 @@
#ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H
#define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H
#include <QString>
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

View file

@ -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:

View file

@ -287,6 +287,16 @@ public:
explicit DeckListModel(QObject *parent, const QSharedPointer<DeckList> &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;

View file

@ -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);
}

View file

@ -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);

View file

@ -4,8 +4,8 @@
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"};

View file

@ -12,6 +12,7 @@
#include <QSet>
#include <algorithm>
#include <climits>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/card/database/parser/cockatrice_xml_4.h>
#include <libcockatrice/card/relation/card_relation.h>
@ -22,8 +23,9 @@ static const QList<AllowedCount> kSingletonCounts = {{1, "legal"}, {0, "banned"}
SplitCardPart::SplitCardPart(const QString &_name,
const QString &_text,
const QHash<QString, QString> &_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<QString, QString> 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 &currentSet,
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 &currentSet, const QJsonArray &cardsList)
{
// mtgjson name => xml name
@ -397,13 +530,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, 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<CardRelation *> relatedCards;
@ -446,6 +587,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, 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 &currentSet, const QJson
QList<QPair<QList<SplitCardPart>, QString>> partsAndNames = splitCards.values();
for (auto [splitCardParts, name] : partsAndNames) {
QString text;
QString localizedText;
bool localizedTextComplete = true;
QHash<QString, QString> properties;
PrintingInfo printingInfo;
@ -468,6 +613,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, 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 &currentSet, 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();
}

View file

@ -107,7 +107,8 @@ public:
SplitCardPart(const QString &_name,
const QString &_text,
const QHash<QString, QString> &_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<QString, QString> &getProperties() const
{
return properties;
@ -128,10 +136,18 @@ public:
private:
QString name;
QString text;
QString localizedText;
QHash<QString, QString> 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<QString, LocalizedCardEntry> 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<QString, LocalizedCardEntry> splitLocalizedTexts;
CardInfoPtr addCard(QString name,
const QString &text,
bool isToken,
QHash<QString, QString> properties,
const QList<CardRelation *> &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 &currentSet,
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 &currentSet, const QJsonArray &cardsList);
/**

View file

@ -15,6 +15,7 @@
#include <QScrollBar>
#include <QtConcurrent>
#include <QtGui>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/personal_settings.h>
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);

View file

@ -2,9 +2,14 @@
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <QTemporaryDir>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/card/database/card_database_data.h>
#include <libcockatrice/card/database/parser/cockatrice_xml_4.h>
#include <libcockatrice/card/lazy_properties_hash.h>
#include <libcockatrice/card/printing/printing_info.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
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<QString, QString> 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)

View file

@ -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);

View file

@ -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)