Cockatrice/oracle/src/oracleimporter.cpp
BruebachL 1c93309952
[Client] Show localized card names, texts and pictures (#7294)
* [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 <Bruebach.Lukas@bdosecurity.de>
2026-09-18 12:03:07 +02:00

858 lines
35 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "oracleimporter.h"
#include "libcockatrice/interfaces/noop_card_preference_provider.h"
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
#include "parsehelpers.h"
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QRegularExpression>
#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>
static const QList<AllowedCount> kConstructedCounts = {{4, "legal"}, {0, "banned"}};
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,
const QString &_localizedText)
: name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo)
{
}
const QRegularExpression OracleImporter::formatRegex = QRegularExpression("^format-");
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())) {
qDebug() << "warning: Set type" << setType << "unrecognized for prioritization";
}
CardSet::Priority priority = setTypePriorities.value(setType.toLower(), CardSet::PriorityOther);
if (nonEnglishSets.contains(shortName)) {
priority = CardSet::PriorityLowest;
}
return priority;
}
bool OracleImporter::readSetsFromByteArray(QByteArray data)
{
const RawJson::ScanProgressCallback progress =
progressReporting
? [this](
qsizetype bytesRead,
qsizetype
totalBytes) { emit dataReadProgress(static_cast<int>(bytesRead), static_cast<int>(totalBytes)); }
: RawJson::ScanProgressCallback{};
RawJson::ScanError error;
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error, progress);
if (error.isError()) {
qDebug() << "error: RawJson::scanSetRanges():" << error.message;
return false;
}
QList<SetToDownload> newSetList;
newSetList.reserve(ranges.size());
for (const RawJson::SetRange &range : ranges) {
QString shortName = range.code.toUpper();
QString longName = range.name;
QString setType = range.type;
QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate);
CardSet::Priority priority = getSetPriority(setType, shortName);
// capitalize set type
if (setType.length() > 0) {
// basic grammar for words that aren't capitalized, like in "From the Vault"
static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for",
"of", "in", "and", "with", "or"};
QStringList words = setType.split("_");
setType.clear();
bool first = false;
for (auto &item : words) {
if (first && noCapitalize.contains(item)) {
setType += item + QString(" ");
} else {
setType += item[0].toUpper() + item.mid(1) + QString(" ");
first = true;
}
}
setType = setType.trimmed();
}
SetToDownload set(shortName, longName, priority, setType, releaseDate);
set.setRawRange(range.dataRange);
newSetList.append(set);
}
std::sort(newSetList.begin(), newSetList.end());
if (newSetList.isEmpty()) {
return false;
}
allSets = newSetList;
rawSetsData = std::move(data);
return true;
}
/**
* The priority order used to pick a card's main type when a card has multiple
* types (e.g. "Artifact Creature") or multiple faces (e.g. split/adventure cards).
* A lower index means a higher priority.
*/
static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery",
"Instant", "Artifact", "Enchantment"};
/**
* Returns the priority (index) of the given main card type. Known types map to their
* position in {@link mainCardTypePriority()}, unknown types map to -1 (lowest priority).
*/
static int mainCardTypePriority(const QString &mainCardType)
{
return MAIN_CARD_TYPE_PRIORITY.indexOf(mainCardType);
}
static QString getMainCardType(const QStringList &typeList)
{
if (typeList.isEmpty()) {
return {};
}
for (const auto &type : MAIN_CARD_TYPE_PRIORITY) {
if (typeList.contains(type)) {
return type;
}
}
return typeList.first();
}
/**
* Sorts and deduplicates the color chars in the string by WUBRG order.
*
* @param colors The string containing the color chars. Will be modified in-place
*/
static void sortAndReduceColors(QString &colors)
{
// sort
static const QHash<QChar, unsigned int> colorOrder{{'W', 0}, {'U', 1}, {'B', 2}, {'R', 3}, {'G', 4}};
std::sort(colors.begin(), colors.end(),
[](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); });
// reduce
auto last = std::unique(colors.begin(), colors.end());
colors.erase(last, colors.end());
}
CardInfoPtr OracleImporter::addCard(QString name,
const QString &text,
bool isToken,
QHash<QString, QString> properties,
const QList<CardRelation *> &relatedCards,
const PrintingInfo &printingInfo)
{
// Workaround for card name weirdness
name = name.replace("Æ", "AE");
name = name.replace("", "'");
auto existingIt = cards.constFind(name);
if (existingIt != cards.constEnd()) {
CardInfoPtr card = existingIt.value();
card->addToSet(printingInfo.getSet(), printingInfo);
// Only merge legalities when the card has none yet, so multi-format
// printings don't overwrite each other's legality lists.
if (card->getProperties().filter(formatRegex).empty()) {
card->combineLegalities(properties);
}
return card;
}
// Remove {} around mana costs, except if it's split cost
QString manacost = properties.value("manacost");
if (!manacost.isEmpty()) {
QStringList symbols = manacost.split("}");
QString formattedCardCost;
for (QString symbol : symbols) {
static const auto manaCostPattern = QRegularExpression("[0-9WUBGRP]/[0-9WUBGRP]");
if (symbol.contains(manaCostPattern)) {
symbol.append("}");
} else {
symbol.remove(QChar('{'));
}
formattedCardCost.append(symbol);
}
properties.insert("manacost", formattedCardCost);
}
// fix colors
QString allColors = properties.value("colors");
if (allColors.size() > 1) {
sortAndReduceColors(allColors);
properties.insert("colors", allColors);
}
QString allColorIdent = properties.value("coloridentity");
if (allColorIdent.size() > 1) {
sortAndReduceColors(allColorIdent);
properties.insert("coloridentity", allColorIdent);
}
// DETECT CARD POSITIONING INFO
QString layoutVal = properties.value("layout");
bool landscapeOrientation =
properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar";
// cards that enter the field tapped
bool cipt = parseCipt(name, text) || landscapeOrientation;
// table row
int tableRow = 1;
QString mainCardType = properties.value("maintype");
if (mainCardType == "Land") {
tableRow = 0;
} else if (mainCardType == "Sorcery" || mainCardType == "Instant") {
tableRow = 3;
} else if (mainCardType == "Creature") {
tableRow = 2;
}
// card side
QString side = properties.value("side") == "b" ? "back" : "front";
properties.insert("side", side);
// upsideDown (flip cards)
QString layout = properties.value("layout");
bool upsideDown = layout == "flip" && side == "back";
// insert the card and its properties
SetToPrintingsMap setsInfo;
setsInfo[printingInfo.getSet()->getShortName()].append(printingInfo);
CardInfo::UiAttributes attributes = {cipt, landscapeOrientation, tableRow, upsideDown};
CardInfoPtr newCard =
CardInfo::newInstance(name, text, isToken, properties, relatedCards, {}, setsInfo, attributes);
if (name.isEmpty()) {
qDebug() << "warning: an empty card was added to set" << printingInfo.getSet()->getShortName();
}
cards.insert(name, newCard);
return newCard;
}
static QString getJsonString(const QJsonObject &obj, const QString &key)
{
// QVariant coerces numbers and booleans to text, while QJsonValue::toString()
// returns a null string for them — some MTGJSON fields (manaValue,
// convertedManaCost, isOnlineOnly, isRebalanced) carry those types.
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
static const QMap<QString, QString> cardProperties{
{"manaCost", "manacost"}, {"manaValue", "cmc"}, {"type", "type"},
{"loyalty", "loyalty"}, {"layout", "layout"}, {"side", "side"},
{"convertedManaCost", "cmc"}, // old name for manaValue, for backwards compatibility
};
// mtgjson name => xml name
static const QMap<QString, QString> setInfoProperties{{"number", "num"},
{"rarity", "rarity"},
{"isOnlineOnly", "isOnlineOnly"},
{"isRebalanced", "isRebalanced"},
{"artist", "artist"}};
// mtgjson name => xml name
static const QMap<QString, QString> identifierProperties{{"multiverseId", "muid"}, {"scryfallId", "uuid"}};
static const QString ptSeparator = "/";
static constexpr bool isToken = false;
static const QSet<QString> setsWithCardsWithSameNameButDifferentText = {"UST"};
int numCards = 0;
// Keeps track of any split card faces encountered so far
QMap<QString, QPair<QList<SplitCardPart>, QString>> splitCards;
// Keeps track of all names encountered so far
QSet<QString> allNameProps;
for (const QJsonValue &cardVal : cardsList) {
QJsonObject card = cardVal.toObject();
/* Currently used layouts are:
* augment, double_faced_token, flip, host, leveler, meld, normal, planar,
* saga, scheme, split, token, transform, vanguard
*/
QString layout = getJsonString(card, "layout");
// don't import tokens from the json file
if (layout == "token") {
continue;
}
// normal cards handling
QString name = getJsonString(card, "name");
QString text = getJsonString(card, "text");
QString faceName = getJsonString(card, "faceName");
if (faceName.isEmpty()) {
faceName = name;
}
// card properties
QHash<QString, QString> properties;
for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) {
QString propertyValue = getJsonString(card, i.key());
if (!propertyValue.isEmpty()) {
properties.insert(i.value(), propertyValue);
}
}
// per-set properties
QHash<QString, QString> printingProps;
for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) {
QString propertyValue = getJsonString(card, i.key());
if (!propertyValue.isEmpty()) {
printingProps.insert(i.value(), propertyValue);
}
}
// handle flavorNames specially due to double-faced cards
QString faceFlavorName = getJsonString(card, "faceFlavorName");
QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName");
if (!flavorName.isEmpty()) {
printingProps.insert("flavorName", flavorName);
}
// Identifiers
QJsonObject identifiers = card.value("identifiers").toObject();
for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) {
QString propertyValue = getJsonString(identifiers, i.key());
if (!propertyValue.isEmpty()) {
printingProps.insert(i.value(), propertyValue);
}
}
PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps));
QString numComponent;
const QString numProperty = printingInfo.getProperty("num");
const QChar lastChar = numProperty.isEmpty() ? QChar() : numProperty.back();
// Un-Sets do some wonky stuff. Split up these cards as individual entries.
// these cards will have a num with a letter (abc) behind it, put that letter into the name
if (setsWithCardsWithSameNameButDifferentText.contains(currentSet->getShortName()) &&
allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) {
numComponent = " (" + QString(lastChar).toLower() + ")";
}
allNameProps.insert(faceName);
// special handling properties
QString colors;
for (const QJsonValue &color : card.value("colors").toArray()) {
colors += color.toString();
}
if (!colors.isEmpty()) {
properties.insert("colors", colors);
}
QString colorIdentity;
for (const QJsonValue &color : card.value("colorIdentity").toArray()) {
colorIdentity += color.toString();
}
if (!colorIdentity.isEmpty()) {
properties.insert("coloridentity", colorIdentity);
}
const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList());
if (mainCardType.isEmpty()) {
qDebug() << "warning: no mainCardType for card:" << name;
} else {
properties.insert("maintype", mainCardType);
}
// Depending on whether power and/or toughness are present, the format
// is either P/T (most common), P (no toughness), or /T (no power).
QString power = getJsonString(card, "power");
QString toughness = getJsonString(card, "toughness");
if (toughness.isEmpty() && !power.isEmpty()) {
properties.insert("pt", power);
} else if (!toughness.isEmpty()) {
properties.insert("pt", power + ptSeparator + toughness);
}
auto legalities = card.value("legalities").toObject();
for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) {
properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower());
}
// 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");
// 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;
// add other face for split cards as card relation
if (!getJsonString(card, "side").isEmpty()) {
auto faceManaValue = getJsonString(card, "faceManaValue");
if (faceManaValue.isEmpty()) {
// check the old name for the property, for backwards compatibility purposes
faceManaValue = getJsonString(card, "faceConvertedManaCost");
}
properties["cmc"] = faceManaValue;
if (layout == "meld") { // meld cards don't work
static const QRegularExpression meldNameRegex{"then meld them into ([^\\.]*)"};
QString additionalName = meldNameRegex.match(text).captured(1);
if (!additionalName.isNull()) {
relatedCards.append(new CardRelation(additionalName, CardRelationType::TransformInto));
}
} else {
for (const QString &additionalName : name.split(" // ")) {
if (additionalName != faceName) {
relatedCards.append(new CardRelation(additionalName, CardRelationType::TransformInto));
}
}
}
name = faceName;
}
// mtgjson related cards
QJsonObject givenRelated = card.value("relatedCards").toObject();
if (!givenRelated.isEmpty()) {
// conjured cards from a spellbook
QJsonArray spellbook = givenRelated.value("spellbook").toArray();
if (!spellbook.isEmpty()) {
for (const QJsonValue &spbkVal : spellbook) {
relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false,
false, 1, true));
}
}
}
collectForeignData(normalizeCardName(name + numComponent), currentSet, card);
CardInfoPtr newCard =
addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo);
numCards++;
}
}
// split cards handling
static const QString splitCardPropSeparator = QString(" // ");
static const QString splitCardTextSeparator = QString("\n\n---\n\n");
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;
for (const SplitCardPart &tmp : splitCardParts) {
if (!text.isEmpty()) {
text.append(splitCardTextSeparator);
}
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();
} else {
const QHash<QString, QString> &tmpProps = tmp.getProperties();
for (auto i = tmpProps.cbegin(), end = tmpProps.cend(); i != end; ++i) {
QString prop = i.key();
QString originalPropertyValue = properties.value(prop);
QString thisCardPropertyValue = i.value();
if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) {
if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty
properties.insert(prop, thisCardPropertyValue);
} else if (prop == "colors" || prop == "coloridentity") { // the card is both colors
properties.insert(prop, originalPropertyValue + thisCardPropertyValue);
} else if (prop == "maintype") {
// Use the same priority as getMainCardType() to pick the
// "best" type across faces — e.g. Creature over Instant
// for adventure cards like Bonecrusher Giant.
int currentPriority = mainCardTypePriority(originalPropertyValue);
int newPriority = mainCardTypePriority(thisCardPropertyValue);
if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) {
properties.insert(prop, thisCardPropertyValue);
}
} else {
properties.insert(prop,
originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue);
}
}
}
}
}
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++;
}
return numCards;
}
static FormatRulesNameMap buildDefaultMagicFormats()
{
// Predefined common exceptions
CardCondition superTypeIsBasic;
superTypeIsBasic.field = "type";
superTypeIsBasic.matchType = "regex";
superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)";
ExceptionRule basicLands;
basicLands.conditions.append(superTypeIsBasic);
CardCondition anyNumberAllowed;
anyNumberAllowed.field = "text";
anyNumberAllowed.matchType = "contains";
anyNumberAllowed.value = "A deck can have any number of";
ExceptionRule mayContainAnyNumber;
mayContainAnyNumber.conditions.append(anyNumberAllowed);
FormatRulesNameMap defaultFormatRulesNameMap;
// ----------------- Helper lambda to create format -----------------
auto makeFormat = [&](const QString &name, int minDeck = 60, int maxDeck = -1, int maxSideboardSize = 15,
const QList<AllowedCount> &allowedCounts = kConstructedCounts) -> FormatRulesPtr {
FormatRulesPtr f(new FormatRules);
f->formatName = name;
f->allowedCounts = allowedCounts;
f->minDeckSize = minDeck;
f->maxDeckSize = maxDeck;
f->maxSideboardSize = maxSideboardSize;
f->exceptions.append(basicLands);
f->exceptions.append(mayContainAnyNumber);
defaultFormatRulesNameMap.insert(name.toLower(), f);
return f;
};
// ----------------- Standard formats -----------------
makeFormat("Standard");
makeFormat("Modern");
makeFormat("Legacy");
makeFormat("Pioneer");
makeFormat("Historic");
makeFormat("Timeless");
makeFormat("Future");
makeFormat("OldSchool");
makeFormat("Premodern");
makeFormat("Pauper");
makeFormat("Penny");
// ----------------- Singleton formats -----------------
makeFormat("Commander", 100, 100, 15, kSingletonCounts);
makeFormat("Duel", 100, 100, 15, kSingletonCounts);
makeFormat("Brawl", 60, 60, 15, kSingletonCounts);
makeFormat("StandardBrawl", 60, 60, 15, kSingletonCounts);
makeFormat("Oathbreaker", 60, 60, 15, kSingletonCounts);
makeFormat("PauperCommander", 100, 100, 15, kSingletonCounts);
makeFormat("Predh", 100, 100, 15, kSingletonCounts);
// ----------------- Restricted formats -----------------
makeFormat("Vintage", 60, -1, 15, {{4, "legal"}, {1, "restricted"}, {0, "banned"}});
return defaultFormatRulesNameMap;
}
const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats()
{
static const FormatRulesNameMap cached = buildDefaultMagicFormats();
return cached;
}
int OracleImporter::startImport()
{
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
importCancelled.storeRelease(0);
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
// distinct card names while raw ranges only count printings (AllPrintings
// ~100k printings vs ~35k names), so this over-reserves somewhat; an exact
// distinct-name count would require eagerly parsing, which the lazy reader
// deliberately avoids. It's a capacity hint, so the overshoot is harmless.
int estimatedCards = 0;
for (const SetToDownload &curSetToParse : allSets) {
estimatedCards += curSetToParse.getRawRange().cardCount;
}
cards.reserve(estimatedCards);
// add an empty set for tokens
CardSetPtr tokenSet =
CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens");
sets.insert(CardSet::TOKENS_SETNAME, tokenSet);
int setIndex = 0;
for (const SetToDownload &curSetToParse : allSets) {
if (importCancelled.loadAcquire()) {
// The wizard was closed mid-import: stop at the next set boundary so
// the caller can wait for this future without processing every set.
break;
}
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
curSetToParse.getLongName(), curSetToParse.getSetType(),
curSetToParse.getReleaseDate(), curSetToParse.getPriority());
// parse only this set's slice of the raw document so the whole JSON tree is
// never kept in memory at once
const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange();
const qsizetype rangeEnd = rawRange.start + rawRange.length;
if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) {
// rawSetsData is cleared by releaseSetData() while SetToDownload copies
// taken from getSets() keep their ranges, and nothing else enforces the
// pairing — so never index past the buffer on stale/mismatched ranges.
qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping";
++setIndex;
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
}
// sliced() shares the buffer instead of deep-copying the slice; the largest
// sets in AllPrintings are tens of MB, so the copy is worth avoiding here.
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
QJsonParseError parseError;
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":"
<< parseError.errorString();
++setIndex;
// Keep the progress accounting honest: a set that failed to parse
// still advanced the index, so report it (with zero imported cards)
// rather than letting SaveSetsPage's bar stall per failed set.
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
}
// Only add the set to the database once its slice parsed cleanly;
// a set that fails here must not persist as an empty set in cards.xml.
if (!sets.contains(newSet->getShortName())) {
sets.insert(newSet->getShortName(), newSet);
}
const QJsonArray setCards = setDoc.object().value("cards").toArray();
int numCardsInSet = importCardsFromSet(newSet, setCards);
++setIndex;
emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName());
}
applyLocalizedData();
emit setIndexChanged(0, setIndex, QString());
// total number of sets
return setIndex;
}
bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion)
{
CockatriceXml4Parser parser(new NoopCardPreferenceProvider(), new NoopCardSetPriorityController());
return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion);
}
void OracleImporter::releaseSetData()
{
allSets.clear();
rawSetsData.clear();
}
void OracleImporter::clear()
{
sets.clear();
cards.clear();
allSets.clear();
rawSetsData.clear();
localizedEntries.clear();
splitLocalizedTexts.clear();
}