This commit is contained in:
BruebachL 2026-09-21 13:22:25 +02:00 committed by GitHub
commit e09c68039c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 654 additions and 3 deletions

View file

@ -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

View file

@ -52,6 +52,7 @@
<file>resources/icons/swap.svg</file>
<file>resources/icons/sync.svg</file>
<file>resources/icons/tab_changed.svg</file>
<file>resources/icons/tags.svg</file>
<file>resources/icons/update.png</file>
<file>resources/icons/view.svg</file>

View file

@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
<dd>[t:basic](#t:basic) <small>(Any card with the type basic)</small></dd>
<dd>[t:arcane t:instant](#t:arcane t:instant) <small>(Any card with the types arcane and instant)</small></dd>
<dt>Tags:</dt>
<dd>[tags:ramp](#tags:ramp) <small>(Any card tagged "ramp" by the Scryfall Tagger community)</small></dd>
<dd>[tags:draw tags:ramp](#tags:draw tags:ramp) <small>(Any card tagged both "draw" and "ramp")</small></dd>
<dd>[tags:draw OR tags:ramp](#tags:draw OR tags:ramp) <small>(Any card tagged "draw" or "ramp")</small></dd>
<dt><u>C</u>olors:</dt>
<dd>[c:w](#c:w) <small>(Any card that is white)</small></dd>
<dd>[c:wu](#c:wu) <small>(Any card that is white or blue)</small></dd>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="#000000" d="M401.2 39.1L549.4 189.4c27.7 28.1 27.7 73.1 0 101.2L393 448.9c-9.3 9.4-24.5 9.5-33.9 .2s-9.5-24.5-.2-33.9L515.3 256.8c9.2-9.3 9.2-24.4 0-33.7L367 72.9c-9.3-9.4-9.2-24.6 .2-33.9s24.6-9.2 33.9 .2zM32.1 229.5L32.1 96c0-35.3 28.7-64 64-64l133.5 0c17 0 33.3 6.7 45.3 18.7l144 144c25 25 25 65.5 0 90.5L285.4 418.7c-25 25-65.5 25-90.5 0l-144-144c-12-12-18.7-28.3-18.7-45.3zm144-85.5a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"/></svg>

After

Width:  |  Height:  |  Size: 658 B

View file

@ -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<SettingsButtonWidget *> filterButtons = {
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget,
quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget,
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget,
quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget,
};
for (auto *btn : filterButtons) {

View file

@ -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);

View file

@ -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 <QLabel>
#include <QPushButton>
#include <QSpinBox>
#include <QTimer>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/filters/filter_tree.h>
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<int>(&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<QString> 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();
}

View file

@ -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 <QLabel>
#include <QLineEdit>
#include <QMap>
#include <QPushButton>
#include <QSpinBox>
#include <QVBoxLayout>
#include <QWidget>
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<QString, int> allTagsWithCount;
QVBoxLayout *layout;
QLineEdit *searchBox;
FlowWidget *flowWidget;
QLabel *thresholdLabel;
QSpinBox *spinBox;
QPushButton *toggleButton; // Mode switch button
QMap<QString, bool> activeTags; // Track active filters
QMap<QString, QPushButton *> tagButtons; // Store toggle buttons
bool exactMatchMode = true; // Toggle between "Exact Match" (all) and "Includes" (any)
};
#endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H

View file

@ -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<QString, int> 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<QString, int> CardDatabaseQuerier::getAllFormatsWithCount() const
{
if (formatsCountCache.isEmpty()) {

View file

@ -214,6 +214,16 @@ public:
* @return Map of subtype string to count.
*/
[[nodiscard]] QMap<QString, int> 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<QString, int> getAllTagsWithCount() const;
FormatRulesPtr getFormat(const QString &formatName) const;
QMap<QString, int> getAllFormatsWithCount() const;
@ -227,6 +237,7 @@ private:
mutable QMap<QString, int> mainCardTypeCountsCache;
mutable QMap<QString, int> subCardTypeCountsCache;
mutable QMap<QString, int> formatsCountCache;
mutable QMap<QString, int> tagCountsCache;
private slots:
void invalidateCaches();

View file

@ -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

View file

@ -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:

View file

@ -43,6 +43,7 @@ public:
AttrMainType,
AttrSubType,
AttrFormat,
AttrTag,
AttrEnd,
};

View file

@ -6,6 +6,7 @@
#include <QString>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/game_specific_terms.h>
#include <libcockatrice/utility/peglib.h>
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<StringMatcher>(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<QString>(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<StringMatcher>(sv[0]);

View file

@ -3,6 +3,7 @@
#include "filter_card.h"
#include <QList>
#include <libcockatrice/card/game_specific_terms.h>
template <class T> FilterTreeNode *FilterTreeBranch<T>::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:

View file

@ -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;
};

View file

@ -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 <input>.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()

View file

@ -505,6 +505,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, 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 &currentSet, 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);

View file

@ -3,6 +3,7 @@
#include "gtest/gtest.h"
#include <libcockatrice/filters/filter_string.h>
#include <libcockatrice/filters/filter_tree.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
@ -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)

View file

@ -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
// ============================================================================