mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 20:13:24 -08:00
[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.
This commit is contained in:
parent
0f45dfa12a
commit
77179f913e
9 changed files with 1145 additions and 217 deletions
|
|
@ -24,6 +24,7 @@ set(oracle_SOURCES
|
|||
src/pagetemplates.cpp
|
||||
src/parsehelpers.cpp
|
||||
src/raw_json_scanner.cpp
|
||||
src/tag_database.cpp
|
||||
../cockatrice/src/client/settings/cache_settings.cpp
|
||||
../cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
../cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Inject Scryfall Tagger tags into an MTGJson AllPrintings file.
|
||||
|
||||
MTGJson does not ship Scryfall Tagger tags yet. Until it does, this script
|
||||
fakes the future `tags` card property so Cockatrice's tag search and tag
|
||||
filter can be exercised end to end:
|
||||
|
||||
1. It downloads (or reuses) Scryfall's official "Oracle Tags" bulk data.
|
||||
2. It builds an `oracle_id -> [tag slug, ...]` map from the taggings.
|
||||
3. It walks every card of an AllPrintings file, matches it by
|
||||
`identifiers.scryfallOracleId`, and adds a `tags` array of slugs.
|
||||
|
||||
The output is a regular MTGJson AllPrintings file that Oracle can import
|
||||
through its "local file" option. The `tags` array it writes is exactly what
|
||||
`oracleimporter.cpp` reads once MTGJson starts emitting it, so nothing about
|
||||
the importer has to change later.
|
||||
|
||||
Usage:
|
||||
# Patch a downloaded AllPrintings.json.xz into AllPrintings.tagged.json
|
||||
./inject_scryfall_tags.py AllPrintings.json.xz AllPrintings.tagged.json
|
||||
|
||||
# Reuse an already downloaded tags file and write compressed output
|
||||
./inject_scryfall_tags.py AllPrintings.json --oracle-tags oracle-tags.jsonl.gz \
|
||||
-o AllPrintings.tagged.json.gz
|
||||
|
||||
This is a development stop-gap: it holds the whole input in memory, so expect
|
||||
a few GB of RAM for a full AllPrintings file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import bz2
|
||||
import gzip
|
||||
import json
|
||||
import lzma
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
USER_AGENT = "CockatriceTagMock/0.1 (+https://github.com/Cockatrice/Cockatrice)"
|
||||
DEFAULT_API_URL = "https://api.scryfall.com/bulk-data/oracle-tags"
|
||||
CACHE_FILE = "oracle-tags.jsonl.gz"
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def download(url, destination):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request) as response, open(destination, "wb") as handle:
|
||||
while chunk := response.read(1 << 20):
|
||||
handle.write(chunk)
|
||||
|
||||
|
||||
def open_compressed(path, mode):
|
||||
"""Open a JSON/JSONL file, transparently decompressing by suffix."""
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".gz":
|
||||
return gzip.open(path, mode)
|
||||
if suffix == ".bz2":
|
||||
return bz2.open(path, mode)
|
||||
if suffix == ".xz":
|
||||
return lzma.open(path, mode)
|
||||
if suffix == ".zip":
|
||||
archive = zipfile.ZipFile(path)
|
||||
name = archive.namelist()[0]
|
||||
return archive.open(name, "r" if "r" in mode else "w")
|
||||
return open(path, mode)
|
||||
|
||||
|
||||
def load_or_download_tags(args):
|
||||
if args.oracle_tags:
|
||||
return Path(args.oracle_tags)
|
||||
|
||||
cache = Path(args.cache_dir) / CACHE_FILE
|
||||
if cache.exists() and not args.force_download:
|
||||
print(f"Using cached Scryfall Oracle Tags: {cache}", file=sys.stderr)
|
||||
return cache
|
||||
|
||||
print(f"Fetching {args.api_url}", file=sys.stderr)
|
||||
metadata = fetch_json(args.api_url)
|
||||
download_uri = metadata.get("jsonl_download_uri") or metadata.get("download_uri")
|
||||
if not download_uri:
|
||||
raise SystemExit("Scryfall bulk data did not provide a download URI.")
|
||||
|
||||
print(f"Downloading {download_uri}", file=sys.stderr)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
download(download_uri, cache)
|
||||
return cache
|
||||
|
||||
|
||||
def build_oracle_id_to_tags(tags_path):
|
||||
"""Return {oracle_id: sorted list of tag slugs} from Scryfall tag objects.
|
||||
|
||||
Scryfall serves the tags as gzipped JSONL (one tag object per line); plain
|
||||
JSON arrays are accepted too so a hand-saved file keeps working.
|
||||
"""
|
||||
mapping = {}
|
||||
with open_compressed(tags_path, "rt") as handle:
|
||||
head = handle.read(1)
|
||||
handle.seek(0)
|
||||
if head == "[":
|
||||
for tag in json.load(handle):
|
||||
add_tag_object(mapping, tag)
|
||||
else:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line:
|
||||
add_tag_object(mapping, json.loads(line))
|
||||
|
||||
return {oracle_id: sorted(slugs) for oracle_id, slugs in mapping.items()}
|
||||
|
||||
|
||||
def add_tag_object(mapping, tag):
|
||||
slug = tag.get("slug")
|
||||
if not slug:
|
||||
return
|
||||
for tagging in tag.get("taggings") or []:
|
||||
oracle_id = tagging.get("oracle_id")
|
||||
if oracle_id:
|
||||
mapping.setdefault(oracle_id, set()).add(slug)
|
||||
|
||||
|
||||
def inject_tags(all_printings, oracle_id_to_tags):
|
||||
sets = all_printings.get("data", {})
|
||||
tagged_cards = 0
|
||||
total_cards = 0
|
||||
|
||||
for card_set in sets.values():
|
||||
for card in card_set.get("cards", []):
|
||||
total_cards += 1
|
||||
oracle_id = (card.get("identifiers") or {}).get("scryfallOracleId")
|
||||
slugs = oracle_id_to_tags.get(oracle_id) if oracle_id else None
|
||||
if not slugs:
|
||||
continue
|
||||
|
||||
existing = set(card.get("tags") or [])
|
||||
existing.update(slugs)
|
||||
card["tags"] = sorted(existing)
|
||||
tagged_cards += 1
|
||||
|
||||
return total_cards, tagged_cards
|
||||
|
||||
|
||||
def write_json(path, payload):
|
||||
with open_compressed(path, "wt") as handle:
|
||||
json.dump(payload, handle, separators=(",", ":"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("input", help="MTGJson AllPrintings file (.json/.gz/.xz/.zip/.bz2)")
|
||||
parser.add_argument("-o", "--output", help="Output file; defaults to <input>.tagged.json")
|
||||
parser.add_argument("--oracle-tags", help="Pre-downloaded Scryfall Oracle Tags file (JSONL/JSON, optionally gz)")
|
||||
parser.add_argument("--cache-dir", default=".", help="Where to cache the downloaded tags file")
|
||||
parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Scryfall bulk-data endpoint for oracle tags")
|
||||
parser.add_argument("--force-download", action="store_true", help="Ignore a cached tags file and re-download")
|
||||
args = parser.parse_args()
|
||||
|
||||
output = args.output or f"{args.input}.tagged.json"
|
||||
|
||||
tags_path = load_or_download_tags(args)
|
||||
oracle_id_to_tags = build_oracle_id_to_tags(tags_path)
|
||||
print(f"Loaded tags for {len(oracle_id_to_tags)} oracle ids", file=sys.stderr)
|
||||
|
||||
print(f"Reading {args.input}", file=sys.stderr)
|
||||
with open_compressed(args.input, "rt") as handle:
|
||||
all_printings = json.load(handle)
|
||||
|
||||
total_cards, tagged_cards = inject_tags(all_printings, oracle_id_to_tags)
|
||||
print(f"Tagged {tagged_cards}/{total_cards} cards", file=sys.stderr)
|
||||
|
||||
print(f"Writing {output}", file=sys.stderr)
|
||||
write_json(output, all_printings)
|
||||
print("Done.", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
#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>
|
||||
|
|
@ -517,9 +518,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson
|
|||
properties.insert("coloridentity", colorIdentity);
|
||||
}
|
||||
|
||||
// Scryfall Tagger tags (resolved by MTGJSON into the `tags` array).
|
||||
// Stored space-separated so the `tags:` search can match each slug as a
|
||||
// discrete token; slugs never contain whitespace.
|
||||
// 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();
|
||||
|
|
@ -527,6 +530,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson
|
|||
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(" "));
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@
|
|||
#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
|
||||
|
|
@ -210,6 +212,12 @@ private:
|
|||
*/
|
||||
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
|
||||
|
|
@ -264,6 +272,16 @@ public:
|
|||
{
|
||||
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.
|
||||
|
|
|
|||
643
oracle/src/tag_database.cpp
Normal file
643
oracle/src/tag_database.cpp
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
#include "tag_database.h"
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QList>
|
||||
#include <QSet>
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef HAS_LZMA
|
||||
#include "lzma/decompress.h"
|
||||
#endif
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
#include "zip/unzip.h"
|
||||
|
||||
#include <zlib.h>
|
||||
#endif
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
using TagSets = QHash<QString, QSet<QString>>;
|
||||
|
||||
struct ParsedTag
|
||||
{
|
||||
QString slug;
|
||||
QStringList parentIds;
|
||||
QList<QString> oracleIds;
|
||||
};
|
||||
|
||||
struct ParseContext
|
||||
{
|
||||
bool includeParentTags = false;
|
||||
QHash<QString, ParsedTag> tagsById;
|
||||
TagSets oracleTags;
|
||||
TagSets nameTags;
|
||||
};
|
||||
|
||||
const QByteArray &gzipMagic()
|
||||
{
|
||||
static const QByteArray magic = QByteArray::fromHex("1f8b");
|
||||
return magic;
|
||||
}
|
||||
|
||||
const QByteArray &xzMagic()
|
||||
{
|
||||
static const QByteArray magic = QByteArray::fromHex("fd377a585a00");
|
||||
return magic;
|
||||
}
|
||||
|
||||
const QByteArray &zipMagic()
|
||||
{
|
||||
static const QByteArray magic = QByteArray::fromHex("504b0304");
|
||||
return magic;
|
||||
}
|
||||
|
||||
QString normalizedSlug(const QString &slug)
|
||||
{
|
||||
return slug.trimmed().toLower();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Rejects slugs that cannot survive the space-separated `tags` property.
|
||||
*/
|
||||
bool isValidSlug(const QString &slug)
|
||||
{
|
||||
if (slug.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (const QChar &character : slug) {
|
||||
if (character.isSpace()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QString normalizedName(const QString &name)
|
||||
{
|
||||
return name.trimmed().toLower();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief True when a map key has the shape of a Scryfall oracle id.
|
||||
*
|
||||
* A manifest with an explicit `key` removes the ambiguity for card names that
|
||||
* happen to look like UUIDs.
|
||||
*/
|
||||
bool looksLikeOracleId(const QString &key)
|
||||
{
|
||||
return !QUuid::fromString(key.trimmed()).isNull();
|
||||
}
|
||||
|
||||
void addTags(TagSets &target, const QString &key, const QStringList &slugs)
|
||||
{
|
||||
// Both key spaces are case-insensitive: Scryfall UUIDs are hex, and card
|
||||
// names must match regardless of the source's capitalization.
|
||||
const QString normalizedKey = key.trimmed().toLower();
|
||||
if (normalizedKey.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QSet<QString> &tags = target[normalizedKey];
|
||||
for (const QString &rawSlug : slugs) {
|
||||
const QString slug = normalizedSlug(rawSlug);
|
||||
if (isValidSlug(slug)) {
|
||||
tags.insert(slug);
|
||||
}
|
||||
}
|
||||
if (tags.isEmpty()) {
|
||||
target.remove(normalizedKey);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
bool decompressGzip(const QByteArray &input, QByteArray &output, QString *errorMessage)
|
||||
{
|
||||
z_stream stream{};
|
||||
if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Gzip support could not be initialized.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(input.constData()));
|
||||
stream.avail_in = static_cast<uInt>(input.size());
|
||||
|
||||
int result = Z_OK;
|
||||
char buffer[64 * 1024];
|
||||
while (result == Z_OK) {
|
||||
stream.next_out = reinterpret_cast<Bytef *>(buffer);
|
||||
stream.avail_out = sizeof(buffer);
|
||||
result = inflate(&stream, Z_NO_FLUSH);
|
||||
|
||||
const uInt produced = static_cast<uInt>(sizeof(buffer)) - stream.avail_out;
|
||||
if (produced > 0) {
|
||||
output.append(buffer, static_cast<int>(produced));
|
||||
}
|
||||
|
||||
if (result != Z_OK && result != Z_STREAM_END) {
|
||||
inflateEnd(&stream);
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Gzip decompression failed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (produced == 0 && stream.avail_in == 0 && result == Z_OK) {
|
||||
inflateEnd(&stream);
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The gzip stream is truncated.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
inflateEnd(&stream);
|
||||
return result == Z_STREAM_END;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_LZMA
|
||||
bool decompressXz(const QByteArray &input, QByteArray &output, QString *errorMessage)
|
||||
{
|
||||
QByteArray inputCopy = input;
|
||||
QBuffer inputBuffer(&inputCopy);
|
||||
QBuffer outputBuffer(&output);
|
||||
inputBuffer.open(QBuffer::ReadOnly);
|
||||
outputBuffer.open(QBuffer::WriteOnly);
|
||||
|
||||
XzDecompressor decompressor;
|
||||
if (!decompressor.decompress(&inputBuffer, &outputBuffer)) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Xz decompression failed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
bool decompressZip(const QByteArray &input, QByteArray &output, QString *errorMessage)
|
||||
{
|
||||
QByteArray inputCopy = input;
|
||||
QBuffer inputBuffer(&inputCopy);
|
||||
UnZip archive;
|
||||
const UnZip::ErrorCode openError = archive.openArchive(&inputBuffer);
|
||||
if (openError != UnZip::Ok) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Failed to open the Zip archive: %1.").arg(archive.formatError(openError));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList files = archive.fileList();
|
||||
if (files.size() != 1) {
|
||||
archive.closeArchive();
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The Zip archive must contain exactly one tag database file.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QBuffer outputBuffer(&output);
|
||||
outputBuffer.open(QBuffer::ReadWrite);
|
||||
const UnZip::ErrorCode extractError = archive.extractFile(files.first(), &outputBuffer);
|
||||
archive.closeArchive();
|
||||
if (extractError != UnZip::Ok) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Zip extraction failed: %1.").arg(archive.formatError(extractError));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Unwraps gzip/xz/zip payloads, including nested archives.
|
||||
*/
|
||||
bool decompressData(QByteArray &data, QString *errorMessage)
|
||||
{
|
||||
// Nested compression is legitimate (a zip around a gzip), but a handful of
|
||||
// layers is enough; the cap keeps a hostile file from looping forever.
|
||||
for (int layer = 0; layer < 4; ++layer) {
|
||||
if (data.startsWith(gzipMagic())) {
|
||||
#ifdef HAS_ZLIB
|
||||
QByteArray decompressed;
|
||||
if (!decompressGzip(data, decompressed, errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
data = std::move(decompressed);
|
||||
continue;
|
||||
#else
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("This version of Oracle does not support gzip compressed files.");
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (data.startsWith(xzMagic())) {
|
||||
#ifdef HAS_LZMA
|
||||
QByteArray decompressed;
|
||||
if (!decompressXz(data, decompressed, errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
data = std::move(decompressed);
|
||||
continue;
|
||||
#else
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("This version of Oracle does not support xz compressed files.");
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (data.startsWith(zipMagic())) {
|
||||
#ifdef HAS_ZLIB
|
||||
QByteArray decompressed;
|
||||
if (!decompressZip(data, decompressed, errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
data = std::move(decompressed);
|
||||
continue;
|
||||
#else
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("This version of Oracle does not support zipped files.");
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag database is compressed in too many layers.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool parseKeyedMap(const QJsonObject &map, const QString &explicitKey, ParseContext &context, QString *errorMessage)
|
||||
{
|
||||
if (map.isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag database contains no tag entries.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
|
||||
if (!it.value().isArray()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag list for \"%1\" must be an array of slugs.").arg(it.key());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList slugs;
|
||||
for (const QJsonValue &value : it.value().toArray()) {
|
||||
if (!value.isString()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage =
|
||||
TagDatabase::tr("The tag list for \"%1\" must contain only slug strings.").arg(it.key());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
slugs.append(value.toString());
|
||||
}
|
||||
|
||||
const bool oracleKey = explicitKey.isEmpty() ? looksLikeOracleId(it.key())
|
||||
: (explicitKey == "oracle_id" || explicitKey == "oracleid");
|
||||
if (oracleKey) {
|
||||
addTags(context.oracleTags, it.key().trimmed(), slugs);
|
||||
} else {
|
||||
addTags(context.nameTags, normalizedName(it.key()), slugs);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseTagRow(const QJsonObject &object, ParseContext &context)
|
||||
{
|
||||
QStringList slugs;
|
||||
for (const QJsonValue &value : object.value("tags").toArray()) {
|
||||
if (value.isString()) {
|
||||
slugs.append(value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const QString oracleId = object.value("oracle_id").toString().trimmed();
|
||||
if (!oracleId.isEmpty()) {
|
||||
addTags(context.oracleTags, oracleId, slugs);
|
||||
}
|
||||
|
||||
const QString name = object.value("name").toString().trimmed();
|
||||
if (!name.isEmpty()) {
|
||||
addTags(context.nameTags, normalizedName(name), slugs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseTagObject(const QJsonObject &object, ParseContext &context)
|
||||
{
|
||||
// Art and illustration tags describe a printing's artwork, not the oracle
|
||||
// card, so they never belong in the card-level tag property.
|
||||
const QString type = object.value("type").toString().trimmed().toLower();
|
||||
if (!type.isEmpty() && type != "oracle") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString slug = normalizedSlug(object.value("slug").toString());
|
||||
if (!isValidSlug(slug)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
QList<QString> oracleIds;
|
||||
for (const QJsonValue &value : object.value("taggings").toArray()) {
|
||||
const QString oracleId = value.toObject().value("oracle_id").toString().trimmed();
|
||||
if (!oracleId.isEmpty() && !oracleIds.contains(oracleId)) {
|
||||
oracleIds.append(oracleId);
|
||||
}
|
||||
}
|
||||
|
||||
const QString id = object.value("id").toString().trimmed();
|
||||
if (!context.includeParentTags || id.isEmpty()) {
|
||||
for (const QString &oracleId : oracleIds) {
|
||||
addTags(context.oracleTags, oracleId, QStringList{slug});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ParsedTag &tag = context.tagsById[id];
|
||||
if (tag.slug.isEmpty()) {
|
||||
tag.slug = slug;
|
||||
}
|
||||
for (const QJsonValue &parent : object.value("parent_ids").toArray()) {
|
||||
const QString parentId = parent.toString().trimmed();
|
||||
if (!parentId.isEmpty() && !tag.parentIds.contains(parentId)) {
|
||||
tag.parentIds.append(parentId);
|
||||
}
|
||||
}
|
||||
for (const QString &oracleId : oracleIds) {
|
||||
if (!tag.oracleIds.contains(oracleId)) {
|
||||
tag.oracleIds.append(oracleId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleObject(const QJsonObject &object, ParseContext &context, QString *errorMessage)
|
||||
{
|
||||
if (object.contains("slug")) {
|
||||
return parseTagObject(object, context);
|
||||
}
|
||||
|
||||
const QJsonValue tagsValue = object.value("tags");
|
||||
if (tagsValue.isArray() && (object.contains("oracle_id") || object.contains("name"))) {
|
||||
return parseTagRow(object, context);
|
||||
}
|
||||
if (tagsValue.isObject()) {
|
||||
const QString key = object.value("key").toString().trimmed().toLower();
|
||||
return parseKeyedMap(tagsValue.toObject(), key, context, errorMessage);
|
||||
}
|
||||
|
||||
bool allArrays = !object.isEmpty();
|
||||
for (auto it = object.constBegin(); it != object.constEnd(); ++it) {
|
||||
if (!it.value().isArray()) {
|
||||
allArrays = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allArrays) {
|
||||
return parseKeyedMap(object, QString(), context, errorMessage);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Unrecognized tag database format.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool parseJsonArray(const QJsonArray &array, ParseContext &context, QString *errorMessage)
|
||||
{
|
||||
if (array.isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag database contains no tag entries.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const QJsonValue &value : array) {
|
||||
if (!value.isObject()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag database array must contain only objects.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!handleObject(value.toObject(), context, errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseJsonLines(const QByteArray &payload, ParseContext &context, QString *errorMessage)
|
||||
{
|
||||
const QList<QByteArray> lines = payload.split('\n');
|
||||
bool parsedAny = false;
|
||||
|
||||
for (int index = 0; index < lines.size(); ++index) {
|
||||
const QByteArray line = lines.at(index).trimmed();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QJsonParseError lineError{};
|
||||
const QJsonDocument lineDocument = QJsonDocument::fromJson(line, &lineError);
|
||||
if (lineError.error != QJsonParseError::NoError || !lineDocument.isObject()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("Malformed tag database at line %1: %2")
|
||||
.arg(index + 1)
|
||||
.arg(lineError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!handleObject(lineDocument.object(), context, errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
parsedAny = true;
|
||||
}
|
||||
|
||||
if (!parsedAny) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = TagDatabase::tr("The tag database contains no tag entries.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QHash<QString, QStringList> sortedLists(const TagSets &sets)
|
||||
{
|
||||
QHash<QString, QStringList> result;
|
||||
result.reserve(sets.size());
|
||||
for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) {
|
||||
if (it.value().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
QStringList slugs = it.value().values();
|
||||
slugs.sort();
|
||||
result.insert(it.key(), slugs);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::shared_ptr<TagDatabase>
|
||||
TagDatabase::loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage)
|
||||
{
|
||||
if (errorMessage) {
|
||||
errorMessage->clear();
|
||||
}
|
||||
|
||||
if (data.trimmed().isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = tr("The tag database is empty.");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QByteArray payload = data;
|
||||
if (!decompressData(payload, errorMessage)) {
|
||||
return nullptr;
|
||||
}
|
||||
if (payload.trimmed().isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = tr("The tag database is empty.");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ParseContext context;
|
||||
context.includeParentTags = options.includeParentTags;
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError);
|
||||
if (parseError.error == QJsonParseError::NoError) {
|
||||
if (document.isArray()) {
|
||||
if (!parseJsonArray(document.array(), context, errorMessage)) {
|
||||
return nullptr;
|
||||
}
|
||||
} else if (document.isObject()) {
|
||||
if (!handleObject(document.object(), context, errorMessage)) {
|
||||
return nullptr;
|
||||
}
|
||||
} else {
|
||||
if (errorMessage) {
|
||||
*errorMessage = tr("The tag database is not a JSON object or array.");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
} else if (payload.trimmed().startsWith('[')) {
|
||||
// A broken array is not JSONL; report the parser error instead of
|
||||
// blaming an arbitrary line.
|
||||
if (errorMessage) {
|
||||
*errorMessage = tr("Malformed tag database: %1").arg(parseError.errorString());
|
||||
}
|
||||
return nullptr;
|
||||
} else if (!parseJsonLines(payload, context, errorMessage)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (context.includeParentTags) {
|
||||
// Expand upward only after every tag is known: a parent object may
|
||||
// appear after its children in the file.
|
||||
for (auto it = context.tagsById.constBegin(); it != context.tagsById.constEnd(); ++it) {
|
||||
QStringList slugs{it->slug};
|
||||
QSet<QString> visited;
|
||||
QList<QString> pending = it->parentIds;
|
||||
|
||||
while (!pending.isEmpty()) {
|
||||
const QString parentId = pending.takeLast();
|
||||
if (visited.contains(parentId)) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(parentId);
|
||||
|
||||
const auto parent = context.tagsById.constFind(parentId);
|
||||
if (parent == context.tagsById.constEnd()) {
|
||||
continue;
|
||||
}
|
||||
slugs.append(parent->slug);
|
||||
pending.append(parent->parentIds);
|
||||
}
|
||||
|
||||
for (const QString &oracleId : it->oracleIds) {
|
||||
addTags(context.oracleTags, oracleId, slugs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto database = std::make_shared<TagDatabase>();
|
||||
database->tagsByOracleId = sortedLists(context.oracleTags);
|
||||
database->tagsByName = sortedLists(context.nameTags);
|
||||
|
||||
QSet<QString> distinctSlugs;
|
||||
for (const QStringList &slugs : database->tagsByOracleId) {
|
||||
for (const QString &slug : slugs) {
|
||||
distinctSlugs.insert(slug);
|
||||
}
|
||||
}
|
||||
for (const QStringList &slugs : database->tagsByName) {
|
||||
for (const QString &slug : slugs) {
|
||||
distinctSlugs.insert(slug);
|
||||
}
|
||||
}
|
||||
database->distinctTagCount = distinctSlugs.size();
|
||||
|
||||
if (database->isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = tr("The tag database contains no usable tags.");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
QStringList TagDatabase::tagsForCard(const QString &oracleId, const QString &name) const
|
||||
{
|
||||
QStringList result;
|
||||
if (!oracleId.isEmpty()) {
|
||||
result = tagsByOracleId.value(oracleId.trimmed().toLower());
|
||||
}
|
||||
|
||||
const QString normalizedCardName = normalizedName(name);
|
||||
if (!normalizedCardName.isEmpty()) {
|
||||
const QStringList byName = tagsByName.value(normalizedCardName);
|
||||
for (const QString &slug : byName) {
|
||||
if (!result.contains(slug)) {
|
||||
result.append(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isEmpty()) {
|
||||
result.sort();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool TagDatabase::isEmpty() const
|
||||
{
|
||||
return tagsByOracleId.isEmpty() && tagsByName.isEmpty();
|
||||
}
|
||||
69
oracle/src/tag_database.h
Normal file
69
oracle/src/tag_database.h
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#ifndef TAG_DATABASE_H
|
||||
#define TAG_DATABASE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @brief Tag slugs keyed by Scryfall oracle id and/or card name.
|
||||
*
|
||||
* Loaded from Scryfall's Oracle Tags bulk data or from a user-supplied tag
|
||||
* database. The data is import-time only: Oracle applies the slugs to the
|
||||
* `tags` card property before writing cards.xml, so the client needs no
|
||||
* tag-specific code.
|
||||
*
|
||||
* Accepted inputs, auto-detected:
|
||||
* - Scryfall tag objects, either a JSON array or one object per JSONL line;
|
||||
* - a flat map of `oracle_id` (or card name) to an array of slugs;
|
||||
* - a manifest object `{"key": "oracle_id"|"name", "tags": { ... }}`;
|
||||
* - JSONL rows `{"oracle_id": "...", "tags": [...]}` or the `name` equivalent.
|
||||
*
|
||||
* Gzip, xz and zip inputs are decompressed transparently when the build has
|
||||
* the matching library. Scryfall serves its bulk data as gzipped JSONL.
|
||||
*/
|
||||
class TagDatabase
|
||||
{
|
||||
Q_DECLARE_TR_FUNCTIONS(TagDatabase)
|
||||
public:
|
||||
struct LoadOptions
|
||||
{
|
||||
bool includeParentTags = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Parses and decompresses a tag database.
|
||||
* @return The database, or nullptr with @p errorMessage set on failure.
|
||||
*/
|
||||
static std::shared_ptr<TagDatabase>
|
||||
loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Returns the sorted slugs for one card.
|
||||
*
|
||||
* Oracle ids are the primary key. A name-keyed database is consulted as a
|
||||
* fallback, case-insensitively. An empty list means the card has no tags.
|
||||
*/
|
||||
[[nodiscard]] QStringList tagsForCard(const QString &oracleId, const QString &name) const;
|
||||
[[nodiscard]] bool isEmpty() const;
|
||||
/** @brief Number of distinct slugs in the database. */
|
||||
[[nodiscard]] int tagCount() const
|
||||
{
|
||||
return distinctTagCount;
|
||||
}
|
||||
/** @brief Number of cards that received at least one tag. */
|
||||
[[nodiscard]] int taggedCardCount() const
|
||||
{
|
||||
return tagsByOracleId.size() + tagsByName.size();
|
||||
}
|
||||
|
||||
private:
|
||||
QHash<QString, QStringList> tagsByOracleId;
|
||||
QHash<QString, QStringList> tagsByName; ///< keys are lowercased
|
||||
int distinctTagCount = 0;
|
||||
};
|
||||
|
||||
#endif // TAG_DATABASE_H
|
||||
|
|
@ -1,3 +1,35 @@
|
|||
# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the tag database
|
||||
# loader can decompress whatever format the tag source serves, and the benchmark can
|
||||
# download and decompress whatever AllPrintings format the default URL selects.
|
||||
# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA
|
||||
# reach every test target below regardless of where they are declared.
|
||||
set(_ORACLE_COMPRESSION_SOURCES)
|
||||
set(_ORACLE_COMPRESSION_LIBRARIES)
|
||||
|
||||
find_package(ZLIB)
|
||||
if(ZLIB_FOUND)
|
||||
add_definitions("-DHAS_ZLIB")
|
||||
list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp)
|
||||
cockatrice_disable_sanitizers_for(${_ORACLE_COMPRESSION_SOURCES})
|
||||
cockatrice_disable_coverage_for(${_ORACLE_COMPRESSION_SOURCES})
|
||||
list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${ZLIB_LIBRARIES})
|
||||
include_directories(${ZLIB_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: zlib not found; gzip/zip tag databases and the zip download benchmark disabled")
|
||||
endif()
|
||||
|
||||
find_package(LibLZMA)
|
||||
if(LIBLZMA_FOUND)
|
||||
add_definitions("-DHAS_LZMA")
|
||||
list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/lzma/decompress.cpp)
|
||||
cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp)
|
||||
cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp)
|
||||
list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${LIBLZMA_LIBRARIES})
|
||||
include_directories(${LIBLZMA_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: LibLZMA not found; xz tag databases and the xz download benchmark disabled")
|
||||
endif()
|
||||
|
||||
cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp)
|
||||
|
||||
# Oracle importer unit tests
|
||||
|
|
@ -9,42 +41,29 @@ cockatrice_add_test(
|
|||
../../oracle/src/oracleimporter.cpp
|
||||
../../oracle/src/parsehelpers.cpp
|
||||
../../oracle/src/raw_json_scanner.cpp
|
||||
../../oracle/src/tag_database.cpp
|
||||
oracle_importer_test.cpp
|
||||
${_ORACLE_COMPRESSION_SOURCES}
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
libcockatrice_interfaces
|
||||
EXTRA_LIBS
|
||||
${_ORACLE_COMPRESSION_LIBRARIES}
|
||||
)
|
||||
|
||||
# Tag database format detection, parsing and parent expansion
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
tag_database_test
|
||||
SOURCES
|
||||
../../oracle/src/tag_database.cpp
|
||||
tag_database_test.cpp
|
||||
${_ORACLE_COMPRESSION_SOURCES}
|
||||
EXTRA_LIBS
|
||||
${_ORACLE_COMPRESSION_LIBRARIES}
|
||||
)
|
||||
|
||||
# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark)
|
||||
# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark
|
||||
# can download and decompress whatever AllPrintings format the default URL selects.
|
||||
# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also
|
||||
# reach the two unit tests above. That is harmless: those macros gate code only in
|
||||
# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles.
|
||||
find_package(ZLIB)
|
||||
if(ZLIB_FOUND)
|
||||
add_definitions("-DHAS_ZLIB")
|
||||
set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp)
|
||||
cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES})
|
||||
cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES})
|
||||
set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES})
|
||||
include_directories(${ZLIB_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled")
|
||||
endif()
|
||||
|
||||
find_package(LibLZMA)
|
||||
if(LIBLZMA_FOUND)
|
||||
add_definitions("-DHAS_LZMA")
|
||||
list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp)
|
||||
cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp)
|
||||
cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp)
|
||||
list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES})
|
||||
include_directories(${LIBLZMA_INCLUDE_DIRS})
|
||||
else()
|
||||
message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled")
|
||||
endif()
|
||||
|
||||
cockatrice_add_manual_test(
|
||||
NAME
|
||||
oracle_importer_benchmark_test
|
||||
|
|
@ -53,12 +72,13 @@ cockatrice_add_manual_test(
|
|||
../../oracle/src/oracleimporter.cpp
|
||||
../../oracle/src/parsehelpers.cpp
|
||||
../../oracle/src/raw_json_scanner.cpp
|
||||
../../oracle/src/tag_database.cpp
|
||||
oracle_importer_benchmark_test.cpp
|
||||
${_ORACLE_BENCH_EXTRA_SOURCES}
|
||||
${_ORACLE_COMPRESSION_SOURCES}
|
||||
LIBS
|
||||
libcockatrice_card
|
||||
libcockatrice_interfaces
|
||||
EXTRA_LIBS
|
||||
${_ORACLE_BENCH_EXTRA_LIBRARIES}
|
||||
${_ORACLE_COMPRESSION_LIBRARIES}
|
||||
MANUAL
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "../../oracle/src/oracleimporter.h"
|
||||
#include "../../oracle/src/raw_json_scanner.h"
|
||||
#include "../../oracle/src/tag_database.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
|
|
@ -600,6 +601,100 @@ TEST_F(OracleImporterTest, SplitCardTagsAreUnioned)
|
|||
ASSERT_EQ(result->getProperty("tags"), "card-advantage removal");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag database join tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(OracleImporterTest, AppliesTagDatabaseByOracleId)
|
||||
{
|
||||
QJsonObject card = makeCard("Tagged By Id");
|
||||
QJsonObject identifiers = card["identifiers"].toObject();
|
||||
identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
card["identifiers"] = identifiers;
|
||||
|
||||
QString error;
|
||||
const auto database = TagDatabase::loadFromData(
|
||||
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
importer->setTagDatabase(database);
|
||||
|
||||
importer->importCardsFromSet(set, QJsonArray{card});
|
||||
|
||||
auto result = importer->getCardList().value("Tagged By Id");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, AppliesTagDatabaseByNameWhenOracleIdIsMissing)
|
||||
{
|
||||
QJsonObject card = makeCard("Lightning Bolt");
|
||||
|
||||
QString error;
|
||||
const auto database = TagDatabase::loadFromData(R"json({"Lightning Bolt": ["burn", "removal"]})json",
|
||||
TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
importer->setTagDatabase(database);
|
||||
|
||||
importer->importCardsFromSet(set, QJsonArray{card});
|
||||
|
||||
auto result = importer->getCardList().value("Lightning Bolt");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("tags"), "burn removal");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, TagDatabaseUnionsWithSourceTags)
|
||||
{
|
||||
QJsonObject card = makeCard("Union Card");
|
||||
QJsonObject identifiers = card["identifiers"].toObject();
|
||||
identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
card["identifiers"] = identifiers;
|
||||
card["tags"] = QJsonArray{"ramp"};
|
||||
|
||||
QString error;
|
||||
const auto database = TagDatabase::loadFromData(
|
||||
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
importer->setTagDatabase(database);
|
||||
|
||||
importer->importCardsFromSet(set, QJsonArray{card});
|
||||
|
||||
auto result = importer->getCardList().value("Union Card");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("tags"), "ramp removal");
|
||||
}
|
||||
|
||||
TEST_F(OracleImporterTest, TagDatabaseSplitFacesUnionTheirTags)
|
||||
{
|
||||
QJsonObject face1 = makeCard("Fire // Ice");
|
||||
face1["layout"] = "split";
|
||||
face1["side"] = "a";
|
||||
face1["faceName"] = "Fire";
|
||||
QJsonObject identifiers1 = face1["identifiers"].toObject();
|
||||
identifiers1["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
face1["identifiers"] = identifiers1;
|
||||
|
||||
QJsonObject face2 = makeCard("Fire // Ice");
|
||||
face2["layout"] = "split";
|
||||
face2["side"] = "b";
|
||||
face2["faceName"] = "Ice";
|
||||
QJsonObject identifiers2 = face2["identifiers"].toObject();
|
||||
identifiers2["scryfallOracleId"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
face2["identifiers"] = identifiers2;
|
||||
|
||||
QString error;
|
||||
const auto database = TagDatabase::loadFromData(
|
||||
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": ["card-advantage"]})json",
|
||||
TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
importer->setTagDatabase(database);
|
||||
|
||||
importer->importCardsFromSet(set, QJsonArray{face1, face2});
|
||||
|
||||
auto result = importer->getCardList().value("Fire // Ice");
|
||||
ASSERT_FALSE(result.isNull());
|
||||
ASSERT_EQ(result->getProperty("tags"), "card-advantage removal");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Card deduplication tests
|
||||
// ============================================================================
|
||||
|
|
|
|||
254
tests/oracle/tag_database_test.cpp
Normal file
254
tests/oracle/tag_database_test.cpp
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
#include "../../oracle/src/tag_database.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <memory>
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
#include <zlib.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
const QString ORACLE_A = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const QString ORACLE_B = QStringLiteral("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
|
||||
std::shared_ptr<TagDatabase> load(const QByteArray &data,
|
||||
const TagDatabase::LoadOptions &options = TagDatabase::LoadOptions{},
|
||||
QString *errorMessage = nullptr)
|
||||
{
|
||||
return TagDatabase::loadFromData(data, options, errorMessage);
|
||||
}
|
||||
|
||||
QString keyedByOracleId(const QString &oracleId, const QString &slugs)
|
||||
{
|
||||
return QStringLiteral("{\"%1\": [%2]}").arg(oracleId, slugs);
|
||||
}
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
QByteArray gzipCompress(const QByteArray &input)
|
||||
{
|
||||
z_stream stream{};
|
||||
if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
|
||||
return {};
|
||||
}
|
||||
|
||||
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(input.constData()));
|
||||
stream.avail_in = static_cast<uInt>(input.size());
|
||||
|
||||
QByteArray output;
|
||||
char buffer[4096];
|
||||
int result = Z_OK;
|
||||
while (result == Z_OK) {
|
||||
stream.next_out = reinterpret_cast<Bytef *>(buffer);
|
||||
stream.avail_out = sizeof(buffer);
|
||||
result = deflate(&stream, Z_FINISH);
|
||||
output.append(buffer, static_cast<int>(sizeof(buffer) - stream.avail_out));
|
||||
}
|
||||
|
||||
deflateEnd(&stream);
|
||||
return result == Z_STREAM_END ? output : QByteArray();
|
||||
}
|
||||
#endif
|
||||
} // namespace
|
||||
|
||||
TEST(TagDatabaseTest, ParsesScryfallJsonLines)
|
||||
{
|
||||
const QByteArray data = R"json(
|
||||
{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"}]}
|
||||
{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"ramp","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"},{"oracle_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","weight":"strong"}]}
|
||||
)json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagCount(), 2);
|
||||
EXPECT_EQ(database->taggedCardCount(), 2);
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList({"ramp"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParsesScryfallJsonArray)
|
||||
{
|
||||
const QByteArray data = R"json([
|
||||
{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]}
|
||||
])json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, IgnoresNonOracleTagTypes)
|
||||
{
|
||||
const QByteArray data = R"json(
|
||||
{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]}
|
||||
{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"art-only","type":"art","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]}
|
||||
)json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParsesFlatOracleIdMap)
|
||||
{
|
||||
const QByteArray data = keyedByOracleId(ORACLE_A, R"("Removal", "removal", " ramp ")").toUtf8();
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
// Mixed case, duplicates and surrounding whitespace are normalized.
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A.toUpper(), QString()), QStringList({"ramp", "removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList());
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParsesFlatNameMap)
|
||||
{
|
||||
const QByteArray data = R"json({"Lightning Bolt": ["removal", "burn"]})json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
// Name lookups are case-insensitive; an absent oracle id does not block them.
|
||||
EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("lightning bolt")), QStringList({"burn", "removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParsesManifestWithNameKey)
|
||||
{
|
||||
const QByteArray data =
|
||||
R"json({"version": 2, "key": "name", "tags": {"Lightning Bolt": ["removal"], "zzz-not-a-uuid": ["burn"]}})json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("ZZZ-not-a-uuid")), QStringList({"burn"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParsesJsonLinesRows)
|
||||
{
|
||||
const QByteArray data = R"json(
|
||||
{"oracle_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tags": ["removal"]}
|
||||
{"name": "Lightning Bolt", "tags": ["burn", "removal"]}
|
||||
)json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"}));
|
||||
EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, MergesOracleIdAndNameMatches)
|
||||
{
|
||||
const QByteArray data =
|
||||
R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "Lightning Bolt": ["burn", "removal"]})json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, DropsSlugsContainingWhitespace)
|
||||
{
|
||||
const QByteArray data = keyedByOracleId(ORACLE_A, R"("two words", "valid")").toUtf8();
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
// A slug with a space would corrupt the space-separated `tags` property.
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"valid"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParentTagsAreOffByDefault)
|
||||
{
|
||||
const QByteArray data = R"json(
|
||||
{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]}
|
||||
{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]}
|
||||
{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]}
|
||||
)json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"tutor-creature-giant"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, ParentTagsExpandTransitivelyWhenEnabled)
|
||||
{
|
||||
const QByteArray data = R"json(
|
||||
{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]}
|
||||
{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]}
|
||||
{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]}
|
||||
)json";
|
||||
|
||||
QString error;
|
||||
const auto database = load(data, TagDatabase::LoadOptions{.includeParentTags = true}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
// The child precedes its parents in the file; expansion must not depend on order.
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()),
|
||||
QStringList({"tutor", "tutor-creature", "tutor-creature-giant"}));
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, RejectsEmptyData)
|
||||
{
|
||||
QString error;
|
||||
EXPECT_FALSE(load(QByteArray(), TagDatabase::LoadOptions{}, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, RejectsMalformedJson)
|
||||
{
|
||||
QString error;
|
||||
EXPECT_FALSE(load("not a tag database", TagDatabase::LoadOptions{}, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, RejectsTagListsThatAreNotArrays)
|
||||
{
|
||||
QString error;
|
||||
EXPECT_FALSE(load(R"json({"tags": {"Lightning Bolt": "removal"}})json", TagDatabase::LoadOptions{}, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
|
||||
TEST(TagDatabaseTest, RejectsDatabaseWithoutUsableTags)
|
||||
{
|
||||
QString error;
|
||||
EXPECT_FALSE(load(R"json({"Lightning Bolt": []})json", TagDatabase::LoadOptions{}, &error));
|
||||
EXPECT_FALSE(error.isEmpty());
|
||||
}
|
||||
|
||||
#ifdef HAS_ZLIB
|
||||
TEST(TagDatabaseTest, DecompressesGzipPayloads)
|
||||
{
|
||||
const QByteArray plain = keyedByOracleId(ORACLE_A, R"("removal")").toUtf8();
|
||||
const QByteArray compressed = gzipCompress(plain);
|
||||
ASSERT_FALSE(compressed.isEmpty());
|
||||
ASSERT_EQ(compressed.left(2), QByteArray::fromHex("1f8b"));
|
||||
|
||||
QString error;
|
||||
const auto database = load(compressed, TagDatabase::LoadOptions{}, &error);
|
||||
ASSERT_TRUE(database) << error.toStdString();
|
||||
|
||||
EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"}));
|
||||
}
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue