mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 19:13:25 -08:00
* [Card] Import Scryfall Tagger tags from MTGJson * [Card] Search cards by Scryfall Tagger tag * [Card] Add a Tag attribute to the card filter tree * [Card] Add a Tags filter to the visual card database * [Oracle] Add a script to inject Scryfall tags into AllPrintings * [Card] Elide long tag lists in the card info and drop the Scryfall label from the tag filter tooltip * [Oracle] Load card tags from a tag database during import Add a TagDatabase loader that understands Scryfall's Oracle Tags bulk data and user-supplied tag files (tag objects, flat oracle-id or name maps, optional manifest, JSON or JSONL, gzip/xz/zip). The importer joins the slugs on identifiers.scryfallOracleId while the identifiers are still available, falls back to the card name, and unions them with any tags the source already provides. Split-card faces merge through the existing tag union path. This replaces oracle/scripts/inject_scryfall_tags.py, which patched AllPrintings.json before import. Loading during import keeps tags on by default for every source and lets users pair Oracle with any tag database. * [Oracle] Add a tag data step to the import wizard Insert a Tags page between set selection and the import. It downloads Scryfall's Oracle Tags by default (or a user-supplied file/URL), can include parent tags, and persists tagsenabled, tagssource and tagsparents in oracle.ini. The page loads asynchronously and soft-fails in background mode, so a tag download or parse error never aborts the card database import. * [Help] Describe tag search without naming Scryfall Tags can come from Scryfall's Oracle Tags or any user-supplied tag database, so the search help should not claim a single source. * [Oracle] Fix Scryfall tag requests and the retry loop api.scryfall.com rejects requests that lack an Accept header with HTTP 400, and QNetworkAccessManager does not add one. Send application/json for the bulk metadata request and */* for the tag file, and surface the URL plus Scryfall's JSON error body when a request fails. A failed tag load used to call next(), which re-ran validatePage() and started the same failing download again, trapping the user in an error loop. Stay on the page instead, drop the unusable database, and tell the user they can retry, change the source, or untick the option to import without tags. Also explain parent tags inline with a real Scryfall hierarchy example. * [Oracle] Give the parent-tag explainer room to breathe The wrapped explainer sat flush under the checkbox with no separation from the status label, which made the paragraph look cramped. Indent it under the checkbox and add a little vertical margin. * [Oracle] Stop the parent-tag explainer from being clipped A word-wrapped QLabel reports a one-line sizeHint, so the wizard page reserved too little height and the explanation lost its top and bottom lines. Pin the vertical size policy to Minimum on the explainer and on the status label, so the wrapped height becomes part of the page minimum. Verified with a layout probe: the label went from 28 px tall (needed 56) to its full height. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
927 lines
36 KiB
C++
927 lines
36 KiB
C++
#include "oracleimporter.h"
|
||
|
||
#include "libcockatrice/card/card_info.h"
|
||
#include "libcockatrice/card/format/format_legality_rules.h"
|
||
#include "libcockatrice/card/lazy_properties_hash.h"
|
||
#include "libcockatrice/card/relation/card_relation_type.h"
|
||
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
|
||
#include "parsehelpers.h"
|
||
#include "tag_database.h"
|
||
|
||
#include <QChar>
|
||
#include <QDebug>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QJsonParseError>
|
||
#include <QJsonValue>
|
||
#include <QJsonValueConstRef>
|
||
#include <QJsonValueRef>
|
||
#include <QPair>
|
||
#include <QRegularExpression>
|
||
#include <QRegularExpressionMatch>
|
||
#include <QSet>
|
||
#include <QSharedPointer>
|
||
#include <QVariant>
|
||
#include <QtGlobal>
|
||
#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>
|
||
#include <qlogging.h>
|
||
|
||
class ICardSetPriorityController;
|
||
|
||
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)
|
||
{
|
||
importSuccessful = false;
|
||
importError.clear();
|
||
allSets.clear();
|
||
rawSetsData.clear();
|
||
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 to text, while QJsonValue::toString() returns a
|
||
// null string for them — MTGJSON fields like manaValue and convertedManaCost
|
||
// 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 ¤tSet,
|
||
const QJsonObject &card,
|
||
bool collectText)
|
||
{
|
||
if (!localizationEnabled) {
|
||
return;
|
||
}
|
||
|
||
LocalizedCardEntry incoming;
|
||
bool found = false;
|
||
const QJsonArray foreignData = card.value("foreignData").toArray();
|
||
for (const QJsonValue &entryValue : foreignData) {
|
||
const QJsonObject entry = entryValue.toObject();
|
||
// MTGJSON reports languages by long-form name ("German"); match the
|
||
// short code ("de") that Scryfall and cards.xml use.
|
||
if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) {
|
||
continue;
|
||
}
|
||
incoming.name = getJsonString(entry, "name");
|
||
incoming.text = collectText ? getJsonString(entry, "text") : QString();
|
||
found = true;
|
||
break;
|
||
}
|
||
if (!found) {
|
||
return;
|
||
}
|
||
|
||
// Prefer the entry from the highest-priority set (lower enum value = more
|
||
// authoritative); printings of equal priority keep the first one seen.
|
||
incoming.priority = currentSet->getPriority();
|
||
const auto existing = localizedEntries.constFind(cardKey);
|
||
if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) {
|
||
localizedEntries.insert(cardKey, incoming);
|
||
}
|
||
}
|
||
|
||
void OracleImporter::applyLocalizedData()
|
||
{
|
||
if (!localizationEnabled) {
|
||
return;
|
||
}
|
||
for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) {
|
||
CardInfoPtr card = cards.value(it.key());
|
||
if (card.isNull()) {
|
||
continue;
|
||
}
|
||
const LocalizedCardEntry &entry = it.value();
|
||
if (!entry.name.isEmpty()) {
|
||
card->setLocalizedName(cardLang, entry.name);
|
||
}
|
||
if (!entry.text.isEmpty()) {
|
||
card->setLocalizedText(cardLang, entry.text);
|
||
}
|
||
}
|
||
localizedEntries.clear();
|
||
|
||
for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) {
|
||
CardInfoPtr card = cards.value(it.key());
|
||
if (card.isNull()) {
|
||
continue;
|
||
}
|
||
if (!it.value().text.isEmpty()) {
|
||
card->setLocalizedText(cardLang, it.value().text);
|
||
}
|
||
}
|
||
splitLocalizedTexts.clear();
|
||
}
|
||
|
||
int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList)
|
||
{
|
||
// mtgjson name => xml name
|
||
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"}, {"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::serializedFrom(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);
|
||
}
|
||
|
||
// Card tags: the source's own `tags` array is used when present (MTGJson
|
||
// may ship one eventually), then the configured tag database is joined in
|
||
// while the card's identifiers are still available. Stored space-separated
|
||
// so `tags:` can match each slug as a discrete token; slugs never contain
|
||
// whitespace.
|
||
QStringList tags;
|
||
for (const QJsonValue &tag : card.value("tags").toArray()) {
|
||
const QString tagSlug = tag.toString().trimmed().toLower();
|
||
if (!tagSlug.isEmpty() && !tags.contains(tagSlug)) {
|
||
tags.append(tagSlug);
|
||
}
|
||
}
|
||
if (tagDatabase) {
|
||
const QString oracleId = getJsonString(identifiers, "scryfallOracleId");
|
||
for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) {
|
||
if (!tags.contains(tagSlug)) {
|
||
tags.append(tagSlug);
|
||
}
|
||
}
|
||
}
|
||
if (!tags.isEmpty()) {
|
||
tags.sort();
|
||
properties.insert("tags", tags.join(" "));
|
||
}
|
||
|
||
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 if (prop == "tags") {
|
||
// Tags are oracle-level: union both faces instead of
|
||
// concatenating them with the split-card separator.
|
||
QStringList merged =
|
||
(originalPropertyValue + " " + thisCardPropertyValue).split(" ", Qt::SkipEmptyParts);
|
||
merged.removeDuplicates();
|
||
merged.sort();
|
||
properties.insert(prop, merged.join(" "));
|
||
} 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();
|
||
|
||
importSuccessful = false;
|
||
importError.clear();
|
||
sets.clear();
|
||
cards.clear();
|
||
localizedEntries.clear();
|
||
splitLocalizedTexts.clear();
|
||
if (allSets.isEmpty()) {
|
||
return failImport(tr("No set has been selected for import."));
|
||
}
|
||
|
||
// 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()) {
|
||
return failImport(tr("The card database import was cancelled."));
|
||
}
|
||
|
||
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();
|
||
if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() ||
|
||
rawRange.length > rawSetsData.size() - rawRange.start) {
|
||
return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.")
|
||
.arg(curSetToParse.getShortName()));
|
||
}
|
||
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
|
||
QJsonParseError parseError;
|
||
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
|
||
if (parseError.error != QJsonParseError::NoError) {
|
||
return failImport(
|
||
tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString()));
|
||
}
|
||
if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) {
|
||
return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName()));
|
||
}
|
||
|
||
// 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());
|
||
}
|
||
|
||
if (importCancelled.loadAcquire()) {
|
||
return failImport(tr("The card database import was cancelled."));
|
||
}
|
||
if (cards.isEmpty()) {
|
||
return failImport(tr("No cards were imported. The existing card database has been kept."));
|
||
}
|
||
applyLocalizedData();
|
||
importSuccessful = true;
|
||
|
||
emit setIndexChanged(0, setIndex, QString());
|
||
|
||
// total number of sets
|
||
return setIndex;
|
||
}
|
||
|
||
int OracleImporter::failImport(const QString &message)
|
||
{
|
||
importSuccessful = false;
|
||
importError = message;
|
||
sets.clear();
|
||
cards.clear();
|
||
localizedEntries.clear();
|
||
splitLocalizedTexts.clear();
|
||
qWarning() << message;
|
||
return 0;
|
||
}
|
||
|
||
bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion)
|
||
{
|
||
if (!importSuccessful) {
|
||
return false;
|
||
}
|
||
CockatriceXml4Parser parser(new NoopCardSetPriorityController());
|
||
|
||
return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion);
|
||
}
|
||
|
||
void OracleImporter::releaseSetData()
|
||
{
|
||
allSets.clear();
|
||
rawSetsData.clear();
|
||
}
|
||
|
||
void OracleImporter::clear()
|
||
{
|
||
prepareImport();
|
||
sets.clear();
|
||
cards.clear();
|
||
allSets.clear();
|
||
rawSetsData.clear();
|
||
localizedEntries.clear();
|
||
splitLocalizedTexts.clear();
|
||
}
|