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("| %1 | | %2 |
")
.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("| %1 | | %2 |
").arg(keyText, tagsText);
+ continue;
+ }
+
text +=
QString("| %1 | | %2 |
").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