From 1803fce56c0819f670fd04fa627ddf4e7871afe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:20:25 +0200 Subject: [PATCH 1/5] [Card] Import Scryfall Tagger tags from MTGJson --- .../libcockatrice/card/game_specific_terms.h | 4 ++ oracle/src/oracleimporter.cpp | 23 ++++++++ tests/oracle/oracle_importer_test.cpp | 58 +++++++++++++++++++ 3 files changed, 85 insertions(+) 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/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/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 // ============================================================================ From d65d23ffceb77a5deaa895619be9b74230c03e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:20:52 +0200 Subject: [PATCH 2/5] [Card] Search cards by Scryfall Tagger tag --- cockatrice/resources/help/search.md | 5 +++ .../libcockatrice/filters/filter_string.cpp | 18 +++++++- tests/carddatabase/filter_string_test.cpp | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) 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/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/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 2b92c65df..400b67caa 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -124,6 +124,47 @@ 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, TagQueryFalseWhenCardHasNoTags) +{ + ASSERT_FALSE(FilterString("tags:ramp").check(cat)); +} + } // namespace int main(int argc, char **argv) From 0ea0e231bf71a113d51fa9ba1147b245536ba3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:21:00 +0200 Subject: [PATCH 3/5] [Card] Add a Tag attribute to the card filter tree --- .../libcockatrice/filters/filter_card.cpp | 2 ++ .../libcockatrice/filters/filter_card.h | 1 + .../libcockatrice/filters/filter_tree.cpp | 13 +++++++++++++ .../libcockatrice/filters/filter_tree.h | 1 + tests/carddatabase/filter_string_test.cpp | 18 ++++++++++++++++++ 5 files changed, 35 insertions(+) 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_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/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 400b67caa..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 @@ -160,6 +161,23 @@ TEST_F(CardQuery, TagQueryTreatsCommasAsPartOfTheSlug) 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)); From ea38af774af3e04e58bb6f7e706bb49a1b29f580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:21:09 +0200 Subject: [PATCH 4/5] [Card] Add a Tags filter to the visual card database --- cockatrice/CMakeLists.txt | 1 + cockatrice/cockatrice.qrc | 1 + cockatrice/resources/icons/tags.svg | 1 + ...database_display_filter_toolbar_widget.cpp | 12 +- ...l_database_display_filter_toolbar_widget.h | 3 + ...ual_database_display_tag_filter_widget.cpp | 197 ++++++++++++++++++ ...isual_database_display_tag_filter_widget.h | 47 +++++ .../card/database/card_database_querier.cpp | 17 ++ .../card/database/card_database_querier.h | 11 + 9 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 cockatrice/resources/icons/tags.svg create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h 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/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(); From c663de728d189bd61f51566b08b4c5a150fad4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 13:21:16 +0200 Subject: [PATCH 5/5] [Oracle] Add a script to inject Scryfall tags into AllPrintings --- oracle/scripts/inject_scryfall_tags.py | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 oracle/scripts/inject_scryfall_tags.py 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()