From 6138fbd0a09395aaaa18f4f943ba67c2737c93ee Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:57:45 +0100 Subject: [PATCH] [Card] Add Scryfall Tagger tag search and filtering (#7337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Card] Import Scryfall Tagger tags from MTGJson * [Card] Search cards by Scryfall Tagger tag * [Card] Add a Tag attribute to the card filter tree * [Card] Add a Tags filter to the visual card database * [Oracle] Add a script to inject Scryfall tags into AllPrintings * [Card] Elide long tag lists in the card info and drop the Scryfall label from the tag filter tooltip * [Oracle] Load card tags from a tag database during import Add a TagDatabase loader that understands Scryfall's Oracle Tags bulk data and user-supplied tag files (tag objects, flat oracle-id or name maps, optional manifest, JSON or JSONL, gzip/xz/zip). The importer joins the slugs on identifiers.scryfallOracleId while the identifiers are still available, falls back to the card name, and unions them with any tags the source already provides. Split-card faces merge through the existing tag union path. This replaces oracle/scripts/inject_scryfall_tags.py, which patched AllPrintings.json before import. Loading during import keeps tags on by default for every source and lets users pair Oracle with any tag database. * [Oracle] Add a tag data step to the import wizard Insert a Tags page between set selection and the import. It downloads Scryfall's Oracle Tags by default (or a user-supplied file/URL), can include parent tags, and persists tagsenabled, tagssource and tagsparents in oracle.ini. The page loads asynchronously and soft-fails in background mode, so a tag download or parse error never aborts the card database import. * [Help] Describe tag search without naming Scryfall Tags can come from Scryfall's Oracle Tags or any user-supplied tag database, so the search help should not claim a single source. * [Oracle] Fix Scryfall tag requests and the retry loop api.scryfall.com rejects requests that lack an Accept header with HTTP 400, and QNetworkAccessManager does not add one. Send application/json for the bulk metadata request and */* for the tag file, and surface the URL plus Scryfall's JSON error body when a request fails. A failed tag load used to call next(), which re-ran validatePage() and started the same failing download again, trapping the user in an error loop. Stay on the page instead, drop the unusable database, and tell the user they can retry, change the source, or untick the option to import without tags. Also explain parent tags inline with a real Scryfall hierarchy example. * [Oracle] Give the parent-tag explainer room to breathe The wrapped explainer sat flush under the checkbox with no separation from the status label, which made the paragraph look cramped. Indent it under the checkbox and add a little vertical margin. * [Oracle] Stop the parent-tag explainer from being clipped A word-wrapped QLabel reports a one-line sizeHint, so the wizard page reserved too little height and the explanation lost its top and bottom lines. Pin the vertical size policy to Minimum on the explainer and on the status label, so the wrapped height becomes part of the page minimum. Verified with a layout probe: the label went from 28 px tall (needed 56) to its full height. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/cockatrice.qrc | 1 + cockatrice/resources/help/search.md | 5 + cockatrice/resources/icons/tags.svg | 4 + .../widgets/cards/card_info_text_widget.cpp | 42 +- .../widgets/cards/card_info_text_widget.h | 3 +- ...database_display_filter_toolbar_widget.cpp | 13 +- ...l_database_display_filter_toolbar_widget.h | 3 + ...ual_database_display_tag_filter_widget.cpp | 198 ++++++ ...isual_database_display_tag_filter_widget.h | 47 ++ .../card/database/card_database_querier.cpp | 17 + .../card/database/card_database_querier.h | 11 + .../libcockatrice/card/game_specific_terms.h | 4 + .../libcockatrice/filters/filter_card.cpp | 2 + .../libcockatrice/filters/filter_card.h | 1 + .../libcockatrice/filters/filter_string.cpp | 18 +- .../libcockatrice/filters/filter_tree.cpp | 13 + .../libcockatrice/filters/filter_tree.h | 1 + oracle/CMakeLists.txt | 1 + oracle/src/oracleimporter.cpp | 34 + oracle/src/oracleimporter.h | 18 + oracle/src/oraclewizard.cpp | 3 +- oracle/src/oraclewizard.h | 4 + oracle/src/pages.cpp | 505 ++++++++++++++ oracle/src/pages.h | 70 ++ oracle/src/tag_database.cpp | 643 ++++++++++++++++++ oracle/src/tag_database.h | 69 ++ tests/carddatabase/filter_string_test.cpp | 59 ++ tests/oracle/CMakeLists.txt | 82 ++- tests/oracle/oracle_importer_test.cpp | 153 +++++ tests/oracle/tag_database_test.cpp | 254 +++++++ 31 files changed, 2242 insertions(+), 37 deletions(-) create mode 100644 cockatrice/resources/icons/tags.svg create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h 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/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cc6debdf0..a09a81ac9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -317,6 +317,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp + src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_widget.cpp src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 229cdd125..e398033e9 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -49,6 +49,7 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg + resources/icons/tags.svg resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..f29e57bec 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
[t:basic](#t:basic) (Any card with the type basic)
[t:arcane t:instant](#t:arcane t:instant) (Any card with the types arcane and instant)
+
Tags:
+
[tags:ramp](#tags:ramp) (Any card tagged "ramp")
+
[tags:draw tags:ramp](#tags:draw tags:ramp) (Any card tagged both "draw" and "ramp")
+
[tags:draw OR tags:ramp](#tags:draw OR tags:ramp) (Any card tagged "draw" or "ramp")
+
Colors:
[c:w](#c:w) (Any card that is white)
[c:wu](#c:wu) (Any card that is white or blue)
diff --git a/cockatrice/resources/icons/tags.svg b/cockatrice/resources/icons/tags.svg new file mode 100644 index 000000000..e3defcd0d --- /dev/null +++ b/cockatrice/resources/icons/tags.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index 218d0e964..a94f01085 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -23,12 +23,25 @@ #include #include +namespace +{ +constexpr int TAGS_PREVIEW_LIMIT = 3; +const QString SHOW_ALL_TAGS_LINK = QStringLiteral("cockatrice://show-all-tags"); +} // namespace + CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); propsLabel->setWordWrap(true); - connect(propsLabel, SIGNAL(linkActivated(const QString &)), this, SIGNAL(linkActivated(const QString &))); + connect(propsLabel, &QLabel::linkActivated, this, [this](const QString &link) { + if (link == SHOW_ALL_TAGS_LINK) { + showAllTags = true; + setCard(currentCard); + return; + } + emit linkActivated(link); + }); textLabel = new QTextEdit(); textLabel->setReadOnly(true); @@ -75,6 +88,12 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) return; } + // Expanding the tag list re-renders the same card, so only reset the + // collapsed state when a genuinely different card is shown. + if (exactCard != currentCard) { + showAllTags = false; + } + QString text = ""; text += QString("") .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); @@ -94,6 +113,27 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) continue; } QString keyText = Mtg::getNicePropertyName(key).toHtmlEscaped() + ":"; + + if (key == Mtg::Tags) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + QString tagsText; + if (!showAllTags && tags.size() > TAGS_PREVIEW_LIMIT) { + const QStringList preview = tags.mid(0, TAGS_PREVIEW_LIMIT); + const int remaining = tags.size() - preview.size(); + // Join with non-breaking spaces so "… and N more" never wraps mid-phrase. + const QString moreText = + tr("and %n more", nullptr, remaining).toHtmlEscaped().split(" ").join(" "); + tagsText = preview.join(", ").toHtmlEscaped() + + QString(" … %2").arg(SHOW_ALL_TAGS_LINK, moreText); + } else { + tagsText = tags.join(", ").toHtmlEscaped(); + } + + text += QString("").arg(keyText, tagsText); + continue; + } + text += QString("").arg(keyText, card->getProperty(key).toHtmlEscaped()); } diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index 8223093fe..eae2fb8e3 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -26,7 +26,8 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + bool showAllTags = false; ///< Whether the potentially long tag list is fully expanded. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index ecb949b67..f109b25c1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -10,6 +10,7 @@ #include "visual_database_display_name_filter_widget.h" #include "visual_database_display_set_filter_widget.h" #include "visual_database_display_sub_type_filter_widget.h" +#include "visual_database_display_tag_filter_widget.h" #include "visual_database_display_widget.h" #include @@ -106,6 +107,9 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + quickFilterTagWidget = new SettingsButtonWidget(this); + quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags"))); + retranslateUi(); } @@ -126,6 +130,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel); subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel); setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel); + tagFilterWidget = new VisualDatabaseDisplayTagFilterWidget(this, filterModel); quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget); quickFilterNameWidget->addSettingsWidget(nameFilterWidget); @@ -133,6 +138,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget); quickFilterSetWidget->addSettingsWidget(setFilterWidget); quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget); + quickFilterTagWidget->addSettingsWidget(tagFilterWidget); // fill groupbox layouts sortLayout->addWidget(sortByLabel); @@ -145,6 +151,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() filterLayout->addWidget(quickFilterSubTypeWidget); filterLayout->addWidget(quickFilterSetWidget); filterLayout->addWidget(quickFilterFormatLegalityWidget); + filterLayout->addWidget(quickFilterTagWidget); // put everything into main layout addWidget(sortGroupBox); @@ -172,6 +179,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type")); quickFilterSetWidget->setToolTip(tr("Filter by set")); quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality")); + quickFilterTagWidget->setToolTip(tr("Filter by card tag")); quickFilterSaveLoadWidget->setButtonText(tr("Save/Load")); quickFilterNameWidget->setButtonText(tr("Name")); @@ -179,6 +187,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setButtonText(tr("Sub Type")); quickFilterSetWidget->setButtonText(tr("Sets")); quickFilterFormatLegalityWidget->setButtonText(tr("Formats")); + quickFilterTagWidget->setButtonText(tr("Tags")); } void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event) @@ -192,8 +201,8 @@ void VisualDatabaseDisplayFilterToolbarWidget::updateCompactMode(int availableWi const bool compact = availableWidth < fullWidthHint; const QList filterButtons = { - quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, - quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget, + quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget, + quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget, }; for (auto *btn : filterButtons) { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f97310c1b..9f36f3cd1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -17,6 +17,7 @@ class VisualDatabaseDisplayMainTypeFilterWidget; class VisualDatabaseDisplayNameFilterWidget; class VisualDatabaseDisplaySetFilterWidget; class VisualDatabaseDisplaySubTypeFilterWidget; +class VisualDatabaseDisplayTagFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget : public FlowWidget { @@ -54,6 +55,8 @@ private: VisualDatabaseDisplaySetFilterWidget *setFilterWidget; SettingsButtonWidget *quickFilterFormatLegalityWidget; VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget; + SettingsButtonWidget *quickFilterTagWidget; + VisualDatabaseDisplayTagFilterWidget *tagFilterWidget; int fullWidthHint = 0; void updateCompactMode(int availableWidth); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp new file mode 100644 index 000000000..ab7bb27a4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp @@ -0,0 +1,198 @@ +#include "visual_database_display_tag_filter_widget.h" + +#include "../../../filters/filter_tree_model.h" +#include "visual_database_display_filter_button.h" + +#include +#include +#include +#include +#include +#include +#include + +VisualDatabaseDisplayTagFilterWidget::VisualDatabaseDisplayTagFilterWidget(QWidget *parent, + FilterTreeModel *_filterModel) + : QWidget(parent), filterModel(_filterModel) +{ + allTagsWithCount = CardDatabaseManager::query()->getAllTagsWithCount(); + + setMinimumWidth(300); + setMaximumHeight(300); + + layout = new QVBoxLayout(this); + setLayout(layout); + layout->setContentsMargins(0, 1, 0, 1); + layout->setSpacing(1); + layout->setAlignment(Qt::AlignTop); + + // Create search box + searchBox = new QLineEdit(this); + layout->addWidget(searchBox); + connect(searchBox, &QLineEdit::textChanged, this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + layout->addWidget(flowWidget); + + // Create a container for the threshold control + auto *thresholdLayout = new QHBoxLayout(); + thresholdLayout->setContentsMargins(0, 0, 0, 0); + + thresholdLabel = new QLabel(this); + thresholdLayout->addWidget(thresholdLabel); + + // Create the spinbox + spinBox = new QSpinBox(this); + spinBox->setMinimum(1); + spinBox->setMaximum(getMaxTagCount()); + spinBox->setValue(150); + thresholdLayout->addWidget(spinBox); + thresholdLayout->addStretch(); + + layout->addLayout(thresholdLayout); + + connect(spinBox, qOverload(&QSpinBox::valueChanged), this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + // Create the toggle button for Exact Match/Includes mode + toggleButton = new QPushButton(this); + layout->addWidget(toggleButton); + connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayTagFilterWidget::updateFilterMode); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel); }); + + createTagButtons(); // Populate buttons initially + updateFilterMode(); // Initialize toggle button text + + retranslateUi(); +} + +void VisualDatabaseDisplayTagFilterWidget::retranslateUi() +{ + searchBox->setPlaceholderText(tr("Search tags...")); + thresholdLabel->setText(tr("Show tags with at least:")); + spinBox->setSuffix(tr(" cards")); + spinBox->setToolTip(tr("Do not display tags with less than this amount of cards in the database")); + toggleButton->setToolTip(tr("Filter mode (require all selected tags, or any of them)")); +} + +void VisualDatabaseDisplayTagFilterWidget::createTagButtons() +{ + // Iterate through tags and create buttons + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + auto *button = new QPushButton(it.key(), flowWidget); + button->setCheckable(true); + button->setStyleSheet(visualDatabaseDisplayFilterButtonStyle); + + flowWidget->addWidget(button); + tagButtons[it.key()] = button; + + // Connect toggle signal for each button + connect(button, &QPushButton::toggled, this, + [this, tag = it.key()](bool checked) { handleTagToggled(tag, checked); }); + } + updateTagButtonsVisibility(); // Ensure visibility is updated initially +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility() +{ + const int threshold = spinBox->value(); + const QString filterText = searchBox->text().trimmed().toLower(); + + // Iterate through buttons and hide/disable those below the threshold. An + // active search overrides the threshold so rare tags stay reachable. + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + const bool isActive = activeTags.value(it.key(), false); + const bool visible = isActive || (filterText.isEmpty() ? allTagsWithCount[it.key()] >= threshold + : it.key().toLower().contains(filterText)); + + it.value()->setVisible(visible); + it.value()->setEnabled(visible); + } +} + +int VisualDatabaseDisplayTagFilterWidget::getMaxTagCount() const +{ + int maxCount = 1; + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + maxCount = qMax(maxCount, it.value()); + } + return maxCount; +} + +void VisualDatabaseDisplayTagFilterWidget::handleTagToggled(const QString &tag, bool active) +{ + activeTags[tag] = active; + + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(active); + } + + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagFilter() +{ + filterModel->blockSignals(true); + filterModel->filterTree()->blockSignals(true); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrTag); + + for (const auto &activeTag : activeTags.keys()) { + if (activeTags[activeTag]) { + QString tag = activeTag; + filterModel->addFilter(new CardFilter( + tag, exactMatchMode ? CardFilter::Type::TypeAnd : CardFilter::Type::TypeOr, CardFilter::Attr::AttrTag)); + } + } + + filterModel->blockSignals(false); + filterModel->filterTree()->blockSignals(false); + + emit filterModel->filterTree()->changed(); + emit filterModel->layoutChanged(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateFilterMode() +{ + exactMatchMode = !exactMatchMode; + toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel() +{ + // Temporarily block signals for each button to prevent toggling while updating button states + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(true); + } + + // Uncheck all buttons + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->setChecked(false); + } + + // Get active filters for tags + QSet activeTypes; + for (const auto &filter : filterModel->getFiltersOfType(CardFilter::AttrTag)) { + if (filter->type() == CardFilter::Type::TypeAnd || filter->type() == CardFilter::Type::TypeOr) { + activeTypes.insert(filter->term()); + } + } + + // Check the buttons for active tags + for (const auto &tag : activeTypes) { + activeTags[tag] = true; + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(true); + } + } + + // Re-enable signal emissions for each button + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(false); + } + + // Update the visibility of buttons + updateTagButtonsVisibility(); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h new file mode 100644 index 000000000..092a10587 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H +#define COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H + +#include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +class VisualDatabaseDisplayTagFilterWidget : public QWidget +{ + Q_OBJECT +public: + explicit VisualDatabaseDisplayTagFilterWidget(QWidget *parent, FilterTreeModel *filterModel); + void retranslateUi(); + void createTagButtons(); + void updateTagButtonsVisibility(); + [[nodiscard]] int getMaxTagCount() const; + + void handleTagToggled(const QString &tag, bool active); + void updateTagFilter(); + void updateFilterMode(); + void syncWithFilterModel(); + +private: + FilterTreeModel *filterModel; + QMap allTagsWithCount; + + QVBoxLayout *layout; + QLineEdit *searchBox; + FlowWidget *flowWidget; + QLabel *thresholdLabel; + QSpinBox *spinBox; + QPushButton *toggleButton; // Mode switch button + + QMap activeTags; // Track active filters + QMap tagButtons; // Store toggle buttons + + bool exactMatchMode = true; // Toggle between "Exact Match" (all) and "Includes" (any) +}; + +#endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 8df0d2e20..fb8542808 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -1,6 +1,7 @@ #include "card_database_querier.h" #include "../card_info.h" +#include "../game_specific_terms.h" #include "../printing/exact_card.h" #include "../set/card_set_comparator.h" #include "card_database.h" @@ -31,6 +32,7 @@ void CardDatabaseQuerier::invalidateCaches() mainCardTypeCountsCache.clear(); subCardTypeCountsCache.clear(); formatsCountCache.clear(); + tagCountsCache.clear(); } /** @@ -381,6 +383,21 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const return db->formats.value(formatName.toLower()); } +QMap CardDatabaseQuerier::getAllTagsWithCount() const +{ + if (tagCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + for (const QString &tag : tags) { + tagCountsCache[tag]++; + } + } + } + + return tagCountsCache; +} + QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index a1409b3cc..8508e4245 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: * @return Map of subtype string to count. */ [[nodiscard]] QMap getAllSubCardTypesWithCount() const; + + /** + * @brief Returns a mapping of Scryfall Tagger tag slugs to their occurrence counts. + * + * Tags are stored space-separated in the `tags` card property, so a card + * tagged "ramp" and "removal" contributes one to each slug's count. + * + * @return Map of tag slug to count. + */ + [[nodiscard]] QMap getAllTagsWithCount() const; FormatRulesPtr getFormat(const QString &formatName) const; QMap getAllFormatsWithCount() const; @@ -233,6 +243,7 @@ private: mutable QMap mainCardTypeCountsCache; mutable QMap subCardTypeCountsCache; mutable QMap formatsCountCache; + mutable QMap tagCountsCache; private slots: void invalidateCaches(); diff --git a/libcockatrice_card/libcockatrice/card/game_specific_terms.h b/libcockatrice_card/libcockatrice/card/game_specific_terms.h index e9160e514..5be8ec6d5 100644 --- a/libcockatrice_card/libcockatrice/card/game_specific_terms.h +++ b/libcockatrice_card/libcockatrice/card/game_specific_terms.h @@ -28,6 +28,7 @@ QString const PowTough("pt"); QString const Side("side"); QString const Layout("layout"); QString const ColorIdentity("coloridentity"); +QString const Tags("tags"); inline static const QString getNicePropertyName(QString key) { @@ -61,6 +62,9 @@ inline static const QString getNicePropertyName(QString key) if (key == ColorIdentity) { return QCoreApplication::translate("Mtg", "Color Identity"); } + if (key == Tags) { + return QCoreApplication::translate("Mtg", "Tags"); + } return key; } } // namespace Mtg diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 8af39c098..041ab9c0c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -89,6 +89,8 @@ const QString CardFilter::attrName(Attr a) return tr("Loyalty"); case AttrFormat: return tr("Format"); + case AttrTag: + return tr("Tag"); case AttrMainType: return tr("Main Type"); case AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index bc613fc9c..dee9e14bc 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -46,6 +46,7 @@ public: AttrMainType, AttrSubType, AttrFormat, + AttrTag, AttrEnd, }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index f7ca82344..e91817967 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +42,7 @@ ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / Somew SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart -QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery +QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TagQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart SetQuery <- ('e'/'set') ws? SetQueryValue @@ -63,6 +64,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue +TagQuery <- [tT]('ags'/'ag') [:] String + Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] @@ -158,6 +161,19 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; + search["TagQuery"] = [](const peg::SemanticValues &sv) -> Filter { + // Tags are stored space-separated, so matching whole tokens keeps + // `tags:ram` from matching `ramp`. Combine tags with AND: + // `tags:draw tags:ramp`. + const auto tag = std::any_cast(sv[0]).trimmed(); + return [=](const CardData &x) -> bool { + const QString stored = x->getProperty(Mtg::Tags); + if (stored.isEmpty() || tag.isEmpty()) { + return false; + } + return stored.split(" ", Qt::SkipEmptyParts).contains(tag, Qt::CaseInsensitive); + }; + }; search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 817c7298d..13c78e1f7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -2,6 +2,7 @@ #include "filter_card.h" #include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/game_specific_terms.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" @@ -477,6 +478,16 @@ bool FilterItem::acceptFormat(const CardInfoPtr &info) const return info->getLegalityProp(lowercaseTerm) == "legal"; } +bool FilterItem::acceptTag(const CardInfoPtr &info) const +{ + const QString stored = info->getProperty(Mtg::Tags); + if (stored.isEmpty()) { + return false; + } + // Tags are stored space-separated; match whole slugs, not substrings. + return stored.split(" ", Qt::SkipEmptyParts).contains(term.trimmed(), Qt::CaseInsensitive); +} + bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { const QString loyaltyString = info->getLoyalty(); @@ -576,6 +587,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptLoyalty(info); case CardFilter::AttrFormat: return acceptFormat(info); + case CardFilter::AttrTag: + return acceptTag(info); case CardFilter::AttrMainType: return acceptMainType(info); case CardFilter::AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index f1a5803e5..1b899fed7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -255,6 +255,7 @@ public: [[nodiscard]] bool acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptTag(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index b500f79ab..6c61a7e27 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -25,6 +25,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/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index c83c962e3..4657bf055 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 @@ -521,6 +522,31 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert("coloridentity", colorIdentity); } + // Card tags: the source's own `tags` array is used when present (MTGJson + // may ship one eventually), then the configured tag database is joined in + // while the card's identifiers are still available. Stored space-separated + // so `tags:` can match each slug as a discrete token; slugs never contain + // whitespace. + QStringList tags; + for (const QJsonValue &tag : card.value("tags").toArray()) { + const QString tagSlug = tag.toString().trimmed().toLower(); + if (!tagSlug.isEmpty() && !tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + if (tagDatabase) { + const QString oracleId = getJsonString(identifiers, "scryfallOracleId"); + for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) { + if (!tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + } + if (!tags.isEmpty()) { + tags.sort(); + properties.insert("tags", tags.join(" ")); + } + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; @@ -667,6 +693,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { properties.insert(prop, thisCardPropertyValue); } + } else if (prop == "tags") { + // Tags are oracle-level: union both faces instead of + // concatenating them with the split-card separator. + QStringList merged = + (originalPropertyValue + " " + thisCardPropertyValue).split(" ", Qt::SkipEmptyParts); + merged.removeDuplicates(); + merged.sort(); + properties.insert(prop, merged.join(" ")); } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 5ebe9cf36..694babf4b 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 @@ -215,6 +217,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 @@ -269,6 +277,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/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index dc8c7ecb3..43e6f30ca 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -57,7 +57,8 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) QList pages; if (!isSpoilersOnly) { - pages << new IntroPage << new LoadSetsPage << new SaveSetsPage << new LoadTokensPage << new OutroPage; + pages << new IntroPage << new LoadSetsPage << new TagsPage << new SaveSetsPage << new LoadTokensPage + << new OutroPage; } else { pages << new LoadSpoilersPage << new OutroPage; } diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index b1dc5eab2..121acd577 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ class QNetworkAccessManager; class OracleImporter; class QLocalServer; class QSettings; +class TagDatabase; class QWidget; class OracleWizard : public QWizard @@ -68,6 +70,8 @@ public: OracleImporter *importer; QSettings *settings; QNetworkAccessManager *nam; + /** @brief Tag data loaded by the Tags page, consumed by the importer. */ + std::shared_ptr tagDatabase; bool downloadedPlainXml = false; QByteArray xmlData; bool backgroundMode = false; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index f5c38be1c..cf23412c2 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -6,6 +6,7 @@ #include "oracleimporter.h" #include "oraclewizard.h" #include "pagetemplates.h" +#include "tag_database.h" #include "version_string.h" #include @@ -19,7 +20,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -36,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +76,7 @@ class QWidget; #define MTGJSON_V4_URL_COMPONENT "mtgjson.com/files/" #define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/api/v5/AllPrintings.json" #define MTGJSON_VERSION_URL "https://www.mtgjson.com/api/v5/Meta.json" +#define SCRYFALL_TAGS_URL "https://api.scryfall.com/bulk-data/oracle-tags" #ifdef HAS_LZMA #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.xz" @@ -707,6 +712,502 @@ void LoadSetsPage::importFinished() }); } +namespace +{ +/** + * @brief True when a URL points at a tag file rather than Scryfall's bulk metadata. + */ +bool looksLikeTagFileUrl(const QUrl &url) +{ + const QString path = url.path().toLower(); + return path.endsWith(".json") || path.endsWith(".jsonl") || path.endsWith(".gz") || path.endsWith(".xz") || + path.endsWith(".zip"); +} + +/** + * @brief Extends Qt's terse network error with a JSON error body. + * + * Scryfall explains a rejected request in the response body (for example a + * missing Accept header); QNetworkReply::errorString() only says "status code + * 400". Non-JSON bodies (CDN HTML pages) are ignored so the dialog stays short. + */ +QString networkErrorDetail(QNetworkReply *reply) +{ + QString detail = reply->errorString(); + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (body.startsWith('{') && body.size() <= 300) { + detail += QStringLiteral(" ") + body; + } + return detail; +} +} // namespace + +TagsPage::TagsPage(QWidget *parent) : OracleWizardPage(parent) +{ + enableCheckBox = new QCheckBox(this); + enableCheckBox->setChecked(true); + connect(enableCheckBox, &QCheckBox::toggled, this, &TagsPage::updateSourceEnabled); + + urlRadioButton = new QRadioButton(this); + fileRadioButton = new QRadioButton(this); + urlRadioButton->setChecked(true); + connect(urlRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + connect(fileRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + + urlLineEdit = new QLineEdit(this); + fileLineEdit = new QLineEdit(this); + + urlButton = new QPushButton(this); + connect(urlButton, &QPushButton::clicked, this, &TagsPage::actRestoreDefaultSource); + + fileButton = new QPushButton(this); + connect(fileButton, &QPushButton::clicked, this, &TagsPage::actChooseFile); + + parentsCheckBox = new QCheckBox(this); + + parentsExplainer = new QLabel(this); + parentsExplainer->setWordWrap(true); + // Indent under the checkbox and give the wrapped text room above and below, + // so it reads as an explanation of the option rather than a caption glued + // to the controls around it. + parentsExplainer->setContentsMargins(24, 4, 0, 10); + // A word-wrapped label reports a one-line sizeHint, so a short page squeezes + // it and clips the text top and bottom. Pinning the vertical policy makes + // the wrapped height part of the page's minimum size. + parentsExplainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + statusLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + progressLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressLabel->hide(); + progressBar->hide(); + + auto *layout = new QGridLayout(this); + layout->addWidget(enableCheckBox, 0, 0, 1, 2); + layout->addWidget(urlRadioButton, 1, 0); + layout->addWidget(urlLineEdit, 1, 1); + layout->addWidget(urlButton, 2, 1, Qt::AlignRight); + layout->addWidget(fileRadioButton, 3, 0); + layout->addWidget(fileLineEdit, 3, 1); + layout->addWidget(fileButton, 4, 1, Qt::AlignRight); + layout->addWidget(parentsCheckBox, 5, 0, 1, 2); + layout->addWidget(parentsExplainer, 6, 0, 1, 2); + layout->addWidget(statusLabel, 7, 0, 1, 2); + layout->addWidget(progressLabel, 8, 0); + layout->addWidget(progressBar, 8, 1); + layout->setRowStretch(9, 1); + setLayout(layout); + + connect(&watcher, &QFutureWatcher::finished, this, &TagsPage::actLoadFinished); + + updateSourceEnabled(); + retranslateUi(); +} + +void TagsPage::retranslateUi() +{ + setTitle(tr("Tag data")); + setSubTitle(tr("Card tags let you search and filter by tag. Oracle downloads Scryfall's Oracle Tags by " + "default; you can also point it at your own tag database.")); + + enableCheckBox->setText(tr("Download card tags")); + enableCheckBox->setToolTip( + tr("Apply tag slugs to the card database so cards can be searched and filtered by tag.")); + urlRadioButton->setText(tr("Download URL:")); + urlButton->setText(tr("Restore default URL")); + fileRadioButton->setText(tr("Local file:")); + fileButton->setText(tr("Choose file...")); + parentsCheckBox->setText(tr("Include parent tags")); + parentsCheckBox->setToolTip(tr("Also apply the broader tags that this tag belongs to.")); + parentsExplainer->setText( + tr("Tags form a hierarchy: \"tutor-creature-giant\" belongs to \"tutor-creature\", which belongs to " + "\"tutor\". With this on, a card tagged with the specific tag also matches searches for its broader " + "parent tags. It adds many more tags to each card.")); +} + +bool TagsPage::isComplete() const +{ + return !loadActive; +} + +void TagsPage::initializePage() +{ + enableCheckBox->setChecked(wizard()->settings->value("tagsenabled", true).toBool()); + parentsCheckBox->setChecked(wizard()->settings->value("tagsparents", false).toBool()); + + const QString source = wizard()->settings->value("tagssource").toString(); + if (source.isEmpty() || source.startsWith("http://") || source.startsWith("https://")) { + urlRadioButton->setChecked(true); + urlLineEdit->setText(source.isEmpty() ? QString(SCRYFALL_TAGS_URL) : source); + fileLineEdit->clear(); + } else { + fileRadioButton->setChecked(true); + fileLineEdit->setText(source); + urlLineEdit->setText(SCRYFALL_TAGS_URL); + } + + statusLabel->clear(); + progressLabel->hide(); + progressBar->hide(); + updateSourceEnabled(); + + if (wizard()->backgroundMode && isEnabled()) { + // In background mode runInBackground() calls initializePage() instead of + // next(), so a page that has nothing to do must advance itself. + if (validatePage()) { + emit readyToContinue(); + } + } +} + +QString TagsPage::selectedSource() const +{ + return urlRadioButton->isChecked() ? urlLineEdit->text().trimmed() : fileLineEdit->text().trimmed(); +} + +void TagsPage::updateSourceEnabled() +{ + const bool enabled = enableCheckBox->isChecked(); + const bool usingUrl = urlRadioButton->isChecked(); + const bool usingFile = fileRadioButton->isChecked(); + + urlRadioButton->setEnabled(enabled); + fileRadioButton->setEnabled(enabled); + urlLineEdit->setEnabled(enabled && usingUrl); + urlButton->setEnabled(enabled && usingUrl); + fileLineEdit->setEnabled(enabled && usingFile); + fileButton->setEnabled(enabled && usingFile); + parentsCheckBox->setEnabled(enabled); + parentsExplainer->setEnabled(enabled); +} + +void TagsPage::saveSettings() +{ + wizard()->settings->setValue("tagsenabled", enableCheckBox->isChecked()); + wizard()->settings->setValue("tagsparents", parentsCheckBox->isChecked()); + + QString source = selectedSource(); + if (urlRadioButton->isChecked() && source == SCRYFALL_TAGS_URL) { + source.clear(); + } + if (source.isEmpty()) { + wizard()->settings->remove("tagssource"); + } else { + wizard()->settings->setValue("tagssource", source); + } +} + +bool TagsPage::validatePage() +{ + if (!enableCheckBox->isChecked()) { + wizard()->tagDatabase.reset(); + saveSettings(); + return true; + } + + const auto rejectValidation = [this](const QString &message) { + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->tagDatabase.reset(); + emit readyToContinue(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; + + const QString source = selectedSource(); + const bool includeParents = parentsCheckBox->isChecked(); + + // Going back and forth through the wizard must not reload an unchanged source. + if (wizard()->tagDatabase && source == loadedSource && includeParents == loadedParents) { + return true; + } + + if (loadActive) { + return false; + } + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (!url.isValid() || url.isEmpty()) { + return rejectValidation(tr("The provided tag URL is not valid.")); + } + } else if (source.isEmpty() || !QFile::exists(source)) { + return rejectValidation(tr("Please choose an existing tag database file.")); + } + + saveSettings(); + loadedSource = source; + loadedParents = includeParents; + + wizard()->disableButtons(); + setEnabled(false); + loadActive = true; + emit completeChanged(); + + progressBar->setRange(0, 0); + progressBar->setValue(0); + progressLabel->setText(tr("Fetching tag data...")); + progressLabel->show(); + progressBar->show(); + statusLabel->clear(); + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (looksLikeTagFileUrl(url)) { + downloadTagFile(url); + } else { + downloadMetadata(url); + } + } else { + loadFromFile(source); + } + return false; +} + +void TagsPage::actChooseFile() +{ + QFileDialog dialog(this, tr("Load tag database")); + dialog.setFileMode(QFileDialog::ExistingFile); + + QString extensions = "*.json *.jsonl"; +#ifdef HAS_ZLIB + extensions += " *.gz *.zip"; +#endif +#ifdef HAS_LZMA + extensions += " *.xz"; +#endif + dialog.setNameFilter(tr("Tag database (%1)").arg(extensions)); + + if (!fileLineEdit->text().isEmpty() && QFile::exists(fileLineEdit->text())) { + dialog.selectFile(fileLineEdit->text()); + } + + if (!dialog.exec()) { + return; + } + + fileLineEdit->setText(dialog.selectedFiles().at(0)); + fileRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::actRestoreDefaultSource() +{ + urlLineEdit->setText(SCRYFALL_TAGS_URL); + urlRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::downloadMetadata(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + // api.scryfall.com rejects requests without an Accept header (HTTP 400); + // QNetworkAccessManager does not add one on its own. + request.setRawHeader("Accept", "application/json"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actMetadataFinished); +} + +void TagsPage::actMetadataFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad( + tr("Could not fetch tag metadata from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + failLoad(tr("The tag metadata could not be read.")); + return; + } + + const QJsonObject metadata = document.object(); + QString downloadUrl = metadata.value("jsonl_download_uri").toString(); + if (downloadUrl.isEmpty()) { + downloadUrl = metadata.value("download_uri").toString(); + } + if (downloadUrl.isEmpty()) { + failLoad(tr("The tag data source did not provide a download URL.")); + return; + } + + downloadTagFile(QUrl::fromUserInput(downloadUrl)); +} + +void TagsPage::downloadTagFile(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setRawHeader("Accept", "*/*"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actTagFileFinished); + connect(activeReply, &QNetworkReply::downloadProgress, this, &TagsPage::actDownloadProgress); +} + +void TagsPage::actDownloadProgress(qint64 received, qint64 total) +{ + emitProgress("download", received, total); + if (total > 0) { + progressBar->setRange(0, static_cast(total)); + progressBar->setValue(static_cast(received)); + } + progressLabel->setText(tr("Downloading tag data (%1 MB)").arg(received / (1024 * 1024))); +} + +void TagsPage::actTagFileFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad(tr("Could not download tag data from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + failLoad(tr("The tag download was empty.")); + return; + } + beginLoad(data); +} + +void TagsPage::loadFromFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + failLoad(tr("Cannot open file '%1'.").arg(path)); + return; + } + beginLoad(file.readAll()); +} + +void TagsPage::beginLoad(const QByteArray &data) +{ + progressBar->setRange(0, 0); + progressLabel->setText(tr("Reading tag data...")); + + const bool includeParents = parentsCheckBox->isChecked(); + future = QtConcurrent::run([data, includeParents]() { + LoadResult result; + result.database = TagDatabase::loadFromData(data, TagDatabase::LoadOptions{.includeParentTags = includeParents}, + &result.errorMessage); + return result; + }); + watcher.setFuture(future); +} + +void TagsPage::actLoadFinished() +{ + if (wizard()->wasCancelled()) { + return; + } + + const LoadResult result = watcher.result(); + + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + + if (!result.database) { + wizard()->tagDatabase.reset(); + failLoad(result.errorMessage.isEmpty() ? tr("The tag data could not be read.") : result.errorMessage); + return; + } + + wizard()->tagDatabase = result.database; + statusLabel->setText(tr("Loaded %1 tags for %2 cards.") + .arg(wizard()->tagDatabase->tagCount()) + .arg(wizard()->tagDatabase->taggedCardCount())); + qInfo() << "Loaded tag database:" << wizard()->tagDatabase->tagCount() << "tags for" + << wizard()->tagDatabase->taggedCardCount() << "cards"; + advance(); +} + +void TagsPage::failLoad(const QString &message) +{ + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + qWarning() << message; + + // The failed load leaves no usable database; never keep tags from a + // previous, different source. + wizard()->tagDatabase.reset(); + + if (wizard()->backgroundMode) { + statusLabel->setText(message); + emit readyToContinue(); + return; + } + + // Do not advance here. Advancing runs validatePage() again, which starts + // the same failing download and traps the user in an error loop. Staying + // on the page lets them fix the source, retry with Next, or opt out. + const QString guidance = tr("You can try again, choose another source, or untick \"Download card tags\" " + "to import without tags."); + statusLabel->setText(message + "\n\n" + guidance); + QMessageBox::warning(this, tr("Tags not loaded"), message + "\n\n" + guidance); +} + +void TagsPage::advance() +{ + if (wizard()->backgroundMode) { + emit readyToContinue(); + } else { + wizard()->next(); + } +} + +void TagsPage::cancelWork() +{ + if (activeReply) { + activeReply->abort(); + activeReply = nullptr; + } + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); + } +} + SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) { pathLabel = new QLabel(this); @@ -770,6 +1271,10 @@ void SaveSetsPage::initializePage() Qt::UniqueConnection); connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + // The Tags page has already loaded (or deliberately skipped) the tag data. + // The wizard keeps it alive past this page; the importer only borrows it. + wizard()->importer->setTagDatabase(wizard()->tagDatabase); + wizard()->disableButtons(); importActive = true; emit completeChanged(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index e29a8e84a..49331765d 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -6,21 +6,25 @@ #include #include #include +#include #include #include #include +#include #include class QCheckBox; class QComboBox; class QLabel; class QLineEdit; +class QNetworkReply; class QRadioButton; class QProgressBar; class QTextEdit; class QPushButton; class QUrl; class QWidget; +class TagDatabase; class IntroPage : public OracleWizardPage { @@ -109,6 +113,72 @@ private slots: void zipDownloadFailed(const QString &message); }; +/** + * @brief Optional wizard step that loads the tag database used by the importer. + * + * Reloading is skipped when the source and parent-tag setting are unchanged, so + * going back and forth through the wizard does not re-download the tag data. + */ +class TagsPage : public OracleWizardPage +{ + Q_OBJECT +public: + explicit TagsPage(QWidget *parent = nullptr); + void retranslateUi() override; + bool isComplete() const override; + +protected: + void initializePage() override; + bool validatePage() override; + void cancelWork() override; + +private: + struct LoadResult + { + std::shared_ptr database; + QString errorMessage; + }; + + QCheckBox *enableCheckBox; + QRadioButton *urlRadioButton; + QRadioButton *fileRadioButton; + QLineEdit *urlLineEdit; + QLineEdit *fileLineEdit; + QPushButton *urlButton; + QPushButton *fileButton; + QCheckBox *parentsCheckBox; + QLabel *parentsExplainer; + QLabel *statusLabel; + QLabel *progressLabel; + QProgressBar *progressBar; + + QFutureWatcher watcher; + QFuture future; + QPointer activeReply; + bool loadActive = false; + QString loadedSource; + bool loadedParents = false; + + [[nodiscard]] QString selectedSource() const; + void updateSourceEnabled(); + void saveSettings(); + void beginLoad(const QByteArray &data); + void loadFromFile(const QString &path); + void downloadMetadata(const QUrl &url); + void downloadTagFile(const QUrl &url); + void finishLoad(); + void failLoad(const QString &message); + void advance(); + +private slots: + void actChooseFile(); + void actRestoreDefaultSource(); + void actDownloadProgress(qint64 received, qint64 total); + void actMetadataFinished(); + void actTagFileFinished(); + void actLoadFinished(); +}; + class SaveSetsPage : public OracleWizardPage { Q_OBJECT 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/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 88058278f..8cea24950 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -203,6 +204,64 @@ TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) ASSERT_TRUE(germanQuery.check(localized)); } +CardInfoPtr taggedCard() +{ + return CardInfo::newInstance("Tagged Card", "text", false, {{"tags", "ramp removal"}}, {}, {}, {}, {}); +} + +TEST_F(CardQuery, TagsMatchWholeSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp").check(tagged)); + ASSERT_TRUE(FilterString("tags:removal").check(tagged)); + ASSERT_TRUE(FilterString("tags:RAMP").check(tagged)); + ASSERT_TRUE(FilterString("tag:ramp").check(tagged)); + ASSERT_FALSE(FilterString("tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryDoesNotMatchPartialSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ram").check(tagged)); + ASSERT_FALSE(FilterString("tags:mov").check(tagged)); +} + +TEST_F(CardQuery, TagQueryCombinesWithAnd) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp tags:removal").check(tagged)); + ASSERT_FALSE(FilterString("tags:ramp tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryTreatsCommasAsPartOfTheSlug) +{ + // Tag lists are not a thing: `tags:draw` and `tags:ramp` are separate terms. + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ramp,removal").check(tagged)); +} + +TEST_F(CardQuery, FilterTreeTagAttribute) +{ + const CardData tagged = taggedCard(); + + FilterTree matching; + matching.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ramp"); + ASSERT_TRUE(matching.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree partial; + partial.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ram"); + ASSERT_FALSE(partial.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree missing; + missing.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "squirrel"); + ASSERT_FALSE(missing.acceptsCard(tagged, CardSearchLanguage{})); +} + +TEST_F(CardQuery, TagQueryFalseWhenCardHasNoTags) +{ + ASSERT_FALSE(FilterString("tags:ramp").check(cat)); +} + } // namespace class SetQuery : public ::testing::Test diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 1cc1ad952..3f657412b 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,43 +41,30 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp ../../oracle/src/database_writer.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 @@ -54,12 +73,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 1b4e2d915..84995797d 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../../oracle/src/tag_database.h" #include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" @@ -546,6 +547,158 @@ TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) ASSERT_EQ(result->getProperty("cmc"), "3"); } +// ============================================================================ +// Scryfall Tagger tag tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsScryfallTags) +{ + QJsonObject card = makeCard("Ramp Card"); + card["tags"] = QJsonArray{"ramp", "removal"}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Ramp Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagsAreNormalizedDedupedAndSorted) +{ + QJsonObject card = makeCard("Messy Tags"); + card["tags"] = QJsonArray{"Ramp", " removal ", "ramp", ""}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Messy Tags"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, CardsWithoutTagsHaveNoTagsProperty) +{ + QJsonArray cards{makeCard("Untagged Card")}; + importer->importCardsFromSet(set, cards); + + auto result = importer->getCardList().value("Untagged Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_FALSE(result->hasProperty("tags")); +} + +TEST_F(OracleImporterTest, SplitCardTagsAreUnioned) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["tags"] = QJsonArray{"removal"}; + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["tags"] = QJsonArray{"card-advantage", "removal"}; + QJsonArray cards{face1, face2}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + 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
%1%2
%1%2
%1%2