Cockatrice/oracle/src/oracleimporter.h
BruebachL 6138fbd0a0
[Card] Add Scryfall Tagger tag search and filtering (#7337)
* [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>
2026-11-08 20:57:45 +01:00

361 lines
11 KiB
C++

#ifndef ORACLEIMPORTER_H
#define ORACLEIMPORTER_H
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/card/set/card_set.h"
#include "raw_json_scanner.h"
#include <QAtomicInt>
#include <QByteArray>
#include <QDate>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QMap>
#include <QObject>
#include <QString>
#include <QStringList>
#include <libcockatrice/card/card_info.h>
#include <memory>
#include <qnamespace.h>
#include <qtmetamacros.h>
#include <utility>
class CardRelation;
class QRegularExpression;
class TagDatabase;
// many users prefer not to see these sets with non english arts
// they will given priority PriorityLowest
const QStringList nonEnglishSets = {"4BB", "FBB", "PS11", "PSAL", "REN", "RIN"};
const QMap<QString, CardSet::Priority> setTypePriorities{
{"core", CardSet::PriorityPrimary},
{"expansion", CardSet::PriorityPrimary},
{"commander", CardSet::PrioritySecondary},
{"starter", CardSet::PrioritySecondary},
{"draft_innovation", CardSet::PrioritySecondary},
{"duel_deck", CardSet::PrioritySecondary},
{"archenemy", CardSet::PriorityReprint},
{"arsenal", CardSet::PriorityReprint},
{"box", CardSet::PriorityReprint},
{"eternal", CardSet::PriorityReprint},
{"from_the_vault", CardSet::PriorityReprint},
{"masterpiece", CardSet::PriorityReprint},
{"masters", CardSet::PriorityReprint},
{"memorabilia", CardSet::PriorityReprint},
{"planechase", CardSet::PriorityReprint},
{"premium_deck", CardSet::PriorityReprint},
{"promo", CardSet::PriorityReprint},
{"spellbook", CardSet::PriorityReprint},
{"token", CardSet::PriorityReprint},
{"treasure_chest", CardSet::PriorityReprint},
{"alchemy", CardSet::PriorityOther},
{"funny", CardSet::PriorityOther},
{"minigame", CardSet::PriorityOther},
{"vanguard", CardSet::PriorityOther},
};
class SetToDownload
{
private:
QString shortName, longName;
QDate releaseDate;
QString setType;
CardSet::Priority priority;
// Byte range of this set's object within the importer's raw JSON text. Parsing
// one set at a time keeps peak memory low instead of holding the whole document.
RawJson::SetDataRange rawRange;
public:
const QString &getShortName() const
{
return shortName;
}
const QString &getLongName() const
{
return longName;
}
const QString &getSetType() const
{
return setType;
}
const QDate &getReleaseDate() const
{
return releaseDate;
}
CardSet::Priority getPriority() const
{
return priority;
}
const RawJson::SetDataRange &getRawRange() const
{
return rawRange;
}
SetToDownload(QString _shortName,
QString _longName,
CardSet::Priority _priority,
QString _setType = QString(),
const QDate &_releaseDate = QDate())
: shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate),
setType(std::move(_setType)), priority(_priority)
{
}
void setRawRange(const RawJson::SetDataRange &_rawRange)
{
rawRange = _rawRange;
}
bool operator<(const SetToDownload &set) const
{
return longName.compare(set.longName, Qt::CaseInsensitive) < 0;
}
};
class SplitCardPart
{
public:
SplitCardPart(const QString &_name,
const QString &_text,
const QHash<QString, QString> &_properties,
const PrintingInfo &_printingInfo,
const QString &_localizedText = QString());
inline const QString &getName() const
{
return name;
}
inline const QString &getText() const
{
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;
}
inline const PrintingInfo &getPrintingInfo() const
{
return printingInfo;
}
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
private:
static const QRegularExpression formatRegex;
/**
* The cards, indexed by name.
*/
CardNameMap cards;
/**
* The sets, indexed by short name.
*/
SetNameMap sets;
QList<SetToDownload> allSets;
/**
* The raw JSON text of the source document, retained for lazy per-set
* parsing during startImport(). Frees the card data as each set is imported.
*/
QByteArray rawSetsData;
/**
* Whether readSetsFromByteArray() should report scan progress via
* dataReadProgress. The wizard streams that signal to stdout (for the
* hosting Cockatrice client to parse) in both interactive and background
* runs; the flag exists to skip the scanner instrumentation entirely when
* no consumer needs it.
*/
bool progressReporting = true;
/**
* Atomic "please stop importing" flag. startImport() checks it between sets
* so a wizard being closed mid-import can be torn down without waiting for
* the whole import (or racing it).
*/
QAtomicInt importCancelled;
bool importSuccessful = false;
QString importError;
// Discard the incomplete in-memory database; the installed file is untouched.
int failImport(const QString &message);
/**
* 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;
/**
* Optional tag lookup applied to every imported card. Owned by the wizard
* so it outlives the import worker; null when tags are disabled.
*/
std::shared_ptr<const TagDatabase> tagDatabase;
/**
* 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);
public:
explicit OracleImporter(QObject *parent = nullptr);
/**
* @brief Controls whether readSetsFromByteArray() instruments the raw scan.
*
* When enabled (the default) the raw scanner reports progress via
* dataReadProgress(), which the wizard streams to stdout (so the hosting
* Cockatrice client can show it) and also displays on its own progress bar.
* Switch it off only when nothing will consume scan progress.
*/
void setProgressReporting(bool enabled)
{
progressReporting = enabled;
}
/**
* @brief Supplies the tag database applied to every imported card.
*
* Pass nullptr to import without tags. The wizard keeps the database alive
* for the whole import, so the worker sees a stable pointer.
*/
void setTagDatabase(std::shared_ptr<const TagDatabase> database)
{
tagDatabase = std::move(database);
}
/**
* 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.
*/
bool readSetsFromByteArray(QByteArray data);
// Returns the complete selected-set count on success, zero on failure or cancellation.
int startImport();
bool hasSuccessfulImport() const
{
return importSuccessful;
}
const QString &getImportError() const
{
return importError;
}
// Call on the owning thread before scheduling a worker, so a cancellation
// arriving before that worker starts is not lost.
void prepareImport()
{
importCancelled.storeRelease(0);
importSuccessful = false;
importError.clear();
}
/**
* @brief Requests an in-flight startImport() to stop at the next set boundary.
*
* Works by setting an atomic flag that startImport() polls between sets, so
* cancelImport() followed by a short waitForFinished() on the running future is
* safe the moment the wizard is about to be destroyed.
*/
void cancelImport()
{
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);
/**
* @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable.
*/
const FormatRulesNameMap &createDefaultMagicFormats();
const CardNameMap &getCardList() const
{
return cards;
}
QList<SetToDownload> &getSets()
{
return allSets;
}
const QByteArray &getRawSetsData() const
{
return rawSetsData;
}
void releaseSetData();
void clear();
};
#endif