From 77179f913e00c4446738637e2c9f60444ee9e178 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Lukas=20Br=C3=BCbach?=
<44814898+BruebachL@users.noreply.github.com>
Date: Sun, 8 Nov 2026 19:59:04 +0100
Subject: [PATCH] [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/CMakeLists.txt | 1 +
oracle/scripts/inject_scryfall_tags.py | 183 -------
oracle/src/oracleimporter.cpp | 17 +-
oracle/src/oracleimporter.h | 18 +
oracle/src/tag_database.cpp | 643 +++++++++++++++++++++++++
oracle/src/tag_database.h | 69 +++
tests/oracle/CMakeLists.txt | 82 ++--
tests/oracle/oracle_importer_test.cpp | 95 ++++
tests/oracle/tag_database_test.cpp | 254 ++++++++++
9 files changed, 1145 insertions(+), 217 deletions(-)
delete mode 100755 oracle/scripts/inject_scryfall_tags.py
create mode 100644 oracle/src/tag_database.cpp
create mode 100644 oracle/src/tag_database.h
create mode 100644 tests/oracle/tag_database_test.cpp
diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt
index 587463572..06d5e97bf 100644
--- a/oracle/CMakeLists.txt
+++ b/oracle/CMakeLists.txt
@@ -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
diff --git a/oracle/scripts/inject_scryfall_tags.py b/oracle/scripts/inject_scryfall_tags.py
deleted file mode 100755
index 14a2d9c2a..000000000
--- a/oracle/scripts/inject_scryfall_tags.py
+++ /dev/null
@@ -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 .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()
diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp
index a99f18631..7a9bfdf27 100644
--- a/oracle/src/oracleimporter.cpp
+++ b/oracle/src/oracleimporter.cpp
@@ -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
#include
@@ -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(" "));
diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h
index d872e184e..187b2dfd6 100644
--- a/oracle/src/oracleimporter.h
+++ b/oracle/src/oracleimporter.h
@@ -17,12 +17,14 @@
#include
#include
#include
+#include
#include
#include
#include
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 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 database)
+ {
+ tagDatabase = std::move(database);
+ }
/**
* Selects the ISO-639 language code whose foreignData is imported.
* English (the default) and unsupported codes disable localization.
diff --git a/oracle/src/tag_database.cpp b/oracle/src/tag_database.cpp
new file mode 100644
index 000000000..b9c57a969
--- /dev/null
+++ b/oracle/src/tag_database.cpp
@@ -0,0 +1,643 @@
+#include "tag_database.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef HAS_LZMA
+#include "lzma/decompress.h"
+#endif
+
+#ifdef HAS_ZLIB
+#include "zip/unzip.h"
+
+#include
+#endif
+
+#include
+
+namespace
+{
+using TagSets = QHash>;
+
+struct ParsedTag
+{
+ QString slug;
+ QStringList parentIds;
+ QList oracleIds;
+};
+
+struct ParseContext
+{
+ bool includeParentTags = false;
+ QHash 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 &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(const_cast(input.constData()));
+ stream.avail_in = static_cast(input.size());
+
+ int result = Z_OK;
+ char buffer[64 * 1024];
+ while (result == Z_OK) {
+ stream.next_out = reinterpret_cast(buffer);
+ stream.avail_out = sizeof(buffer);
+ result = inflate(&stream, Z_NO_FLUSH);
+
+ const uInt produced = static_cast(sizeof(buffer)) - stream.avail_out;
+ if (produced > 0) {
+ output.append(buffer, static_cast(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 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 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 sortedLists(const TagSets &sets)
+{
+ QHash 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::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 visited;
+ QList 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();
+ database->tagsByOracleId = sortedLists(context.oracleTags);
+ database->tagsByName = sortedLists(context.nameTags);
+
+ QSet 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();
+}
diff --git a/oracle/src/tag_database.h b/oracle/src/tag_database.h
new file mode 100644
index 000000000..5874000f7
--- /dev/null
+++ b/oracle/src/tag_database.h
@@ -0,0 +1,69 @@
+#ifndef TAG_DATABASE_H
+#define TAG_DATABASE_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * @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
+ 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 tagsByOracleId;
+ QHash tagsByName; ///< keys are lowercased
+ int distinctTagCount = 0;
+};
+
+#endif // TAG_DATABASE_H
diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt
index 6958673f7..32827629c 100644
--- a/tests/oracle/CMakeLists.txt
+++ b/tests/oracle/CMakeLists.txt
@@ -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
)
diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp
index 2908ff415..5e40b31b1 100644
--- a/tests/oracle/oracle_importer_test.cpp
+++ b/tests/oracle/oracle_importer_test.cpp
@@ -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
// ============================================================================
diff --git a/tests/oracle/tag_database_test.cpp b/tests/oracle/tag_database_test.cpp
new file mode 100644
index 000000000..92b4013a2
--- /dev/null
+++ b/tests/oracle/tag_database_test.cpp
@@ -0,0 +1,254 @@
+#include "../../oracle/src/tag_database.h"
+
+#include "gtest/gtest.h"
+#include
+#include
+#include
+#include
+
+#ifdef HAS_ZLIB
+#include
+#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 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(const_cast(input.constData()));
+ stream.avail_in = static_cast(input.size());
+
+ QByteArray output;
+ char buffer[4096];
+ int result = Z_OK;
+ while (result == Z_OK) {
+ stream.next_out = reinterpret_cast(buffer);
+ stream.avail_out = sizeof(buffer);
+ result = deflate(&stream, Z_FINISH);
+ output.append(buffer, static_cast(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