diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index 9b31310e6..e84bce9eb 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -311,6 +311,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 14cf15b2f..ba35ec16a 100644
--- a/cockatrice/cockatrice.qrc
+++ b/cockatrice/cockatrice.qrc
@@ -52,6 +52,7 @@
resources/icons/swap.svg
resources/icons/sync.svg
resources/icons/tab_changed.svg
+ resources/icons/tags.svg
resources/icons/update.png
resources/icons/view.svg
diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md
index fd0a12507..2d35b7b25 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" by the Scryfall Tagger community)
+[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..d29dc1e01
--- /dev/null
+++ b/cockatrice/resources/icons/tags.svg
@@ -0,0 +1 @@
+
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 a59a068dd..97a4ab74e 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
@@ -78,6 +78,9 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg
quickFilterFormatLegalityWidget = new SettingsButtonWidget(this);
quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced")));
+ quickFilterTagWidget = new SettingsButtonWidget(this);
+ quickFilterTagWidget->setButtonIcon(themePixmap(QStringLiteral("icons/tags")));
+
retranslateUi();
}
@@ -102,6 +105,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);
@@ -109,6 +113,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget);
quickFilterSetWidget->addSettingsWidget(setFilterWidget);
quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget);
+ quickFilterTagWidget->addSettingsWidget(tagFilterWidget);
// fill groupbox layouts
sortLayout->addWidget(sortByLabel);
@@ -121,6 +126,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
filterLayout->addWidget(quickFilterSubTypeWidget);
filterLayout->addWidget(quickFilterSetWidget);
filterLayout->addWidget(quickFilterFormatLegalityWidget);
+ filterLayout->addWidget(quickFilterTagWidget);
// put everything into main layout
addWidget(sortGroupBox);
@@ -148,6 +154,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 Scryfall Tagger tag"));
quickFilterSaveLoadWidget->setButtonText(tr("Save/Load"));
quickFilterNameWidget->setButtonText(tr("Name"));
@@ -155,6 +162,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)
@@ -168,8 +176,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 8a3555455..74cea3603 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
@@ -7,6 +7,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"
class VisualDatabaseDisplayWidget;
@@ -46,6 +47,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..eb7168b84
--- /dev/null
+++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp
@@ -0,0 +1,197 @@
+#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
+
+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 5b9c5a4b5..7455ad53c 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"
@@ -23,6 +24,7 @@ void CardDatabaseQuerier::invalidateCaches()
mainCardTypeCountsCache.clear();
subCardTypeCountsCache.clear();
formatsCountCache.clear();
+ tagCountsCache.clear();
}
/**
@@ -359,6 +361,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 f195a8170..74d540157 100644
--- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h
+++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h
@@ -214,6 +214,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;
@@ -227,6 +237,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 5fdce7ae0..409182b5f 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp
+++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp
@@ -84,6 +84,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 bb1e0ef53..2eab5508c 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_card.h
+++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h
@@ -43,6 +43,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 1b63ca122..f9febbbbe 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp
+++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#include
static peg::parser search(R"(
@@ -17,7 +18,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') SetExpression / ([:] FlexStringValue)
@@ -38,6 +39,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted'
TypeQuery <- [tT] 'ype'? [:] StringValue
+TagQuery <- [tT]('ags'/'ag') [:] String
+
Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? >
ColorEx <- Color / [mc]
@@ -133,6 +136,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["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
if (sv.choice() == 1) {
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 a5d91d9d3..1c0d488e1 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp
+++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp
@@ -3,6 +3,7 @@
#include "filter_card.h"
#include
+#include
template FilterTreeNode *FilterTreeBranch::nodeAt(int i) const
{
@@ -341,6 +342,16 @@ bool FilterItem::acceptFormat(const CardInfoPtr info) const
return info->getLegalityProp(term.toLower()) == "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
{
if (info->getLoyalty().isEmpty()) {
@@ -495,6 +506,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 dd47a1ebc..3f9f0f8a4 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h
+++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h
@@ -228,6 +228,7 @@ public:
[[nodiscard]] bool
acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptFormat(CardInfoPtr info) const;
+ [[nodiscard]] bool acceptTag(CardInfoPtr info) const;
[[nodiscard]] bool relationCheck(int cardInfo) const;
};
diff --git a/oracle/scripts/inject_scryfall_tags.py b/oracle/scripts/inject_scryfall_tags.py
new file mode 100755
index 000000000..14a2d9c2a
--- /dev/null
+++ b/oracle/scripts/inject_scryfall_tags.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Inject Scryfall Tagger tags into an MTGJson AllPrintings file.
+
+MTGJson does not ship Scryfall Tagger tags yet. Until it does, this script
+fakes the future `tags` card property so Cockatrice's tag search and tag
+filter can be exercised end to end:
+
+ 1. It downloads (or reuses) Scryfall's official "Oracle Tags" bulk data.
+ 2. It builds an `oracle_id -> [tag slug, ...]` map from the taggings.
+ 3. It walks every card of an AllPrintings file, matches it by
+ `identifiers.scryfallOracleId`, and adds a `tags` array of slugs.
+
+The output is a regular MTGJson AllPrintings file that Oracle can import
+through its "local file" option. The `tags` array it writes is exactly what
+`oracleimporter.cpp` reads once MTGJson starts emitting it, so nothing about
+the importer has to change later.
+
+Usage:
+ # Patch a downloaded AllPrintings.json.xz into AllPrintings.tagged.json
+ ./inject_scryfall_tags.py AllPrintings.json.xz AllPrintings.tagged.json
+
+ # Reuse an already downloaded tags file and write compressed output
+ ./inject_scryfall_tags.py AllPrintings.json --oracle-tags oracle-tags.jsonl.gz \
+ -o AllPrintings.tagged.json.gz
+
+This is a development stop-gap: it holds the whole input in memory, so expect
+a few GB of RAM for a full AllPrintings file.
+"""
+
+import argparse
+import bz2
+import gzip
+import json
+import lzma
+import sys
+import urllib.request
+import zipfile
+from pathlib import Path
+
+USER_AGENT = "CockatriceTagMock/0.1 (+https://github.com/Cockatrice/Cockatrice)"
+DEFAULT_API_URL = "https://api.scryfall.com/bulk-data/oracle-tags"
+CACHE_FILE = "oracle-tags.jsonl.gz"
+
+
+def fetch_json(url):
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
+ with urllib.request.urlopen(request) as response:
+ return json.load(response)
+
+
+def download(url, destination):
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ with urllib.request.urlopen(request) as response, open(destination, "wb") as handle:
+ while chunk := response.read(1 << 20):
+ handle.write(chunk)
+
+
+def open_compressed(path, mode):
+ """Open a JSON/JSONL file, transparently decompressing by suffix."""
+ path = Path(path)
+ suffix = path.suffix.lower()
+ if suffix == ".gz":
+ return gzip.open(path, mode)
+ if suffix == ".bz2":
+ return bz2.open(path, mode)
+ if suffix == ".xz":
+ return lzma.open(path, mode)
+ if suffix == ".zip":
+ archive = zipfile.ZipFile(path)
+ name = archive.namelist()[0]
+ return archive.open(name, "r" if "r" in mode else "w")
+ return open(path, mode)
+
+
+def load_or_download_tags(args):
+ if args.oracle_tags:
+ return Path(args.oracle_tags)
+
+ cache = Path(args.cache_dir) / CACHE_FILE
+ if cache.exists() and not args.force_download:
+ print(f"Using cached Scryfall Oracle Tags: {cache}", file=sys.stderr)
+ return cache
+
+ print(f"Fetching {args.api_url}", file=sys.stderr)
+ metadata = fetch_json(args.api_url)
+ download_uri = metadata.get("jsonl_download_uri") or metadata.get("download_uri")
+ if not download_uri:
+ raise SystemExit("Scryfall bulk data did not provide a download URI.")
+
+ print(f"Downloading {download_uri}", file=sys.stderr)
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ download(download_uri, cache)
+ return cache
+
+
+def build_oracle_id_to_tags(tags_path):
+ """Return {oracle_id: sorted list of tag slugs} from Scryfall tag objects.
+
+ Scryfall serves the tags as gzipped JSONL (one tag object per line); plain
+ JSON arrays are accepted too so a hand-saved file keeps working.
+ """
+ mapping = {}
+ with open_compressed(tags_path, "rt") as handle:
+ head = handle.read(1)
+ handle.seek(0)
+ if head == "[":
+ for tag in json.load(handle):
+ add_tag_object(mapping, tag)
+ else:
+ for line in handle:
+ line = line.strip()
+ if line:
+ add_tag_object(mapping, json.loads(line))
+
+ return {oracle_id: sorted(slugs) for oracle_id, slugs in mapping.items()}
+
+
+def add_tag_object(mapping, tag):
+ slug = tag.get("slug")
+ if not slug:
+ return
+ for tagging in tag.get("taggings") or []:
+ oracle_id = tagging.get("oracle_id")
+ if oracle_id:
+ mapping.setdefault(oracle_id, set()).add(slug)
+
+
+def inject_tags(all_printings, oracle_id_to_tags):
+ sets = all_printings.get("data", {})
+ tagged_cards = 0
+ total_cards = 0
+
+ for card_set in sets.values():
+ for card in card_set.get("cards", []):
+ total_cards += 1
+ oracle_id = (card.get("identifiers") or {}).get("scryfallOracleId")
+ slugs = oracle_id_to_tags.get(oracle_id) if oracle_id else None
+ if not slugs:
+ continue
+
+ existing = set(card.get("tags") or [])
+ existing.update(slugs)
+ card["tags"] = sorted(existing)
+ tagged_cards += 1
+
+ return total_cards, tagged_cards
+
+
+def write_json(path, payload):
+ with open_compressed(path, "wt") as handle:
+ json.dump(payload, handle, separators=(",", ":"))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("input", help="MTGJson AllPrintings file (.json/.gz/.xz/.zip/.bz2)")
+ parser.add_argument("-o", "--output", help="Output file; defaults to .tagged.json")
+ parser.add_argument("--oracle-tags", help="Pre-downloaded Scryfall Oracle Tags file (JSONL/JSON, optionally gz)")
+ parser.add_argument("--cache-dir", default=".", help="Where to cache the downloaded tags file")
+ parser.add_argument("--api-url", default=DEFAULT_API_URL, help="Scryfall bulk-data endpoint for oracle tags")
+ parser.add_argument("--force-download", action="store_true", help="Ignore a cached tags file and re-download")
+ args = parser.parse_args()
+
+ output = args.output or f"{args.input}.tagged.json"
+
+ tags_path = load_or_download_tags(args)
+ oracle_id_to_tags = build_oracle_id_to_tags(tags_path)
+ print(f"Loaded tags for {len(oracle_id_to_tags)} oracle ids", file=sys.stderr)
+
+ print(f"Reading {args.input}", file=sys.stderr)
+ with open_compressed(args.input, "rt") as handle:
+ all_printings = json.load(handle)
+
+ total_cards, tagged_cards = inject_tags(all_printings, oracle_id_to_tags)
+ print(f"Tagged {tagged_cards}/{total_cards} cards", file=sys.stderr)
+
+ print(f"Writing {output}", file=sys.stderr)
+ write_json(output, all_printings)
+ print("Done.", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp
index 88b522197..4b72817dc 100644
--- a/oracle/src/oracleimporter.cpp
+++ b/oracle/src/oracleimporter.cpp
@@ -505,6 +505,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson
properties.insert("coloridentity", colorIdentity);
}
+ // Scryfall Tagger tags (resolved by MTGJSON into the `tags` array).
+ // Stored space-separated so the `tags:` search can match each slug as a
+ // discrete token; slugs never contain whitespace.
+ 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 (!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;
@@ -651,6 +666,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/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp
index 2b92c65df..5a3e6156d 100644
--- a/tests/carddatabase/filter_string_test.cpp
+++ b/tests/carddatabase/filter_string_test.cpp
@@ -3,6 +3,7 @@
#include "gtest/gtest.h"
#include
+#include
#include
#include
@@ -124,6 +125,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
int main(int argc, char **argv)
diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp
index f66616e37..2b902a10e 100644
--- a/tests/oracle/oracle_importer_test.cpp
+++ b/tests/oracle/oracle_importer_test.cpp
@@ -523,6 +523,64 @@ 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");
+}
+
// ============================================================================
// Card deduplication tests
// ============================================================================