[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

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