[Card] Add Scryfall Tagger tag search and filtering (#7337)

* [Card] Import Scryfall Tagger tags from MTGJson

* [Card] Search cards by Scryfall Tagger tag

* [Card] Add a Tag attribute to the card filter tree

* [Card] Add a Tags filter to the visual card database

* [Oracle] Add a script to inject Scryfall tags into AllPrintings

* [Card] Elide long tag lists in the card info and drop the Scryfall label from the tag filter tooltip

* [Oracle] Load card tags from a tag database during import

Add a TagDatabase loader that understands Scryfall's Oracle Tags bulk data and user-supplied tag files (tag objects, flat oracle-id or name maps, optional manifest, JSON or JSONL, gzip/xz/zip). The importer joins the slugs on identifiers.scryfallOracleId while the identifiers are still available, falls back to the card name, and unions them with any tags the source already provides. Split-card faces merge through the existing tag union path.

This replaces oracle/scripts/inject_scryfall_tags.py, which patched AllPrintings.json before import. Loading during import keeps tags on by default for every source and lets users pair Oracle with any tag database.

* [Oracle] Add a tag data step to the import wizard

Insert a Tags page between set selection and the import. It downloads Scryfall's Oracle Tags by default (or a user-supplied file/URL), can include parent tags, and persists tagsenabled, tagssource and tagsparents in oracle.ini. The page loads asynchronously and soft-fails in background mode, so a tag download or parse error never aborts the card database import.

* [Help] Describe tag search without naming Scryfall

Tags can come from Scryfall's Oracle Tags or any user-supplied tag database, so the search help should not claim a single source.

* [Oracle] Fix Scryfall tag requests and the retry loop

api.scryfall.com rejects requests that lack an Accept header with HTTP 400, and QNetworkAccessManager does not add one. Send application/json for the bulk metadata request and */* for the tag file, and surface the URL plus Scryfall's JSON error body when a request fails.

A failed tag load used to call next(), which re-ran validatePage() and started the same failing download again, trapping the user in an error loop. Stay on the page instead, drop the unusable database, and tell the user they can retry, change the source, or untick the option to import without tags. Also explain parent tags inline with a real Scryfall hierarchy example.

* [Oracle] Give the parent-tag explainer room to breathe

The wrapped explainer sat flush under the checkbox with no separation from the status label, which made the paragraph look cramped. Indent it under the checkbox and add a little vertical margin.

* [Oracle] Stop the parent-tag explainer from being clipped

A word-wrapped QLabel reports a one-line sizeHint, so the wizard page reserved too little height and the explanation lost its top and bottom lines. Pin the vertical size policy to Minimum on the explainer and on the status label, so the wrapped height becomes part of the page minimum. Verified with a layout probe: the label went from 28 px tall (needed 56) to its full height.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-11-08 20:57:45 +01:00 • committed by GitHub
parent c7e8422808
commit 6138fbd0a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2242 additions and 37 deletions

View file

@ -23,12 +23,25 @@
#include <qnamespace.h>
#include <qobjectdefs.h>
namespace
{
constexpr int TAGS_PREVIEW_LIMIT = 3;
const QString SHOW_ALL_TAGS_LINK = QStringLiteral("cockatrice://show-all-tags");
} // namespace
CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent)
{
propsLabel = new QLabel;
propsLabel->setOpenExternalLinks(false);
propsLabel->setWordWrap(true);
connect(propsLabel, SIGNAL(linkActivated(const QString &)), this, SIGNAL(linkActivated(const QString &)));
connect(propsLabel, &QLabel::linkActivated, this, [this](const QString &link) {
if (link == SHOW_ALL_TAGS_LINK) {
showAllTags = true;
setCard(currentCard);
return;
}
emit linkActivated(link);
});
textLabel = new QTextEdit();
textLabel->setReadOnly(true);
@ -75,6 +88,12 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
return;
}
// Expanding the tag list re-renders the same card, so only reset the
// collapsed state when a genuinely different card is shown.
if (exactCard != currentCard) {
showAllTags = false;
}
QString text = "<table width=\"100%\" border=0 cellspacing=0 cellpadding=0>";
text += QString("<tr><td>%1</td><td width=\"5\"></td><td>%2</td></tr>")
.arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped());
@ -94,6 +113,27 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard)
continue;
}
QString keyText = Mtg::getNicePropertyName(key).toHtmlEscaped() + ":";
if (key == Mtg::Tags) {
const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts);
QString tagsText;
if (!showAllTags && tags.size() > TAGS_PREVIEW_LIMIT) {
const QStringList preview = tags.mid(0, TAGS_PREVIEW_LIMIT);
const int remaining = tags.size() - preview.size();
// Join with non-breaking spaces so "… and N more" never wraps mid-phrase.
const QString moreText =
tr("and %n more", nullptr, remaining).toHtmlEscaped().split(" ").join("&nbsp;");
tagsText = preview.join(", ").toHtmlEscaped() +
QString(" <a href=\"%1\">…&nbsp;%2</a>").arg(SHOW_ALL_TAGS_LINK, moreText);
} else {
tagsText = tags.join(", ").toHtmlEscaped();
}
text += QString("<tr><td>%1</td><td></td><td>%2</td></tr>").arg(keyText, tagsText);
continue;
}
text +=
QString("<tr><td>%1</td><td></td><td>%2</td></tr>").arg(keyText, card->getProperty(key).toHtmlEscaped());
}

View file

@ -26,7 +26,8 @@ private:
QLabel *propsLabel;
QScrollArea *propsScroll;
QTextEdit *textLabel;
ExactCard currentCard; ///< Last card set, re-rendered when the card language changes.
ExactCard currentCard; ///< Last card set, re-rendered when the card language changes.
bool showAllTags = false; ///< Whether the potentially long tag list is fully expanded.
void setTexts(const QString &propsText, const QString &textText);
public:

View file

@ -10,6 +10,7 @@
#include "visual_database_display_name_filter_widget.h"
#include "visual_database_display_set_filter_widget.h"
#include "visual_database_display_sub_type_filter_widget.h"
#include "visual_database_display_tag_filter_widget.h"
#include "visual_database_display_widget.h"
#include <QAbstractItemView>
@ -106,6 +107,9 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg
quickFilterFormatLegalityWidget = new SettingsButtonWidget(this);
quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales")));
quickFilterTagWidget = new SettingsButtonWidget(this);
quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags")));
retranslateUi();
}
@ -126,6 +130,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel);
subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel);
setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel);
tagFilterWidget = new VisualDatabaseDisplayTagFilterWidget(this, filterModel);
quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget);
quickFilterNameWidget->addSettingsWidget(nameFilterWidget);
@ -133,6 +138,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget);
quickFilterSetWidget->addSettingsWidget(setFilterWidget);
quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget);
quickFilterTagWidget->addSettingsWidget(tagFilterWidget);
// fill groupbox layouts
sortLayout->addWidget(sortByLabel);
@ -145,6 +151,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
filterLayout->addWidget(quickFilterSubTypeWidget);
filterLayout->addWidget(quickFilterSetWidget);
filterLayout->addWidget(quickFilterFormatLegalityWidget);
filterLayout->addWidget(quickFilterTagWidget);
// put everything into main layout
addWidget(sortGroupBox);
@ -172,6 +179,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi()
quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type"));
quickFilterSetWidget->setToolTip(tr("Filter by set"));
quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality"));
quickFilterTagWidget->setToolTip(tr("Filter by card tag"));
quickFilterSaveLoadWidget->setButtonText(tr("Save/Load"));
quickFilterNameWidget->setButtonText(tr("Name"));
@ -179,6 +187,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi()
quickFilterSubTypeWidget->setButtonText(tr("Sub Type"));
quickFilterSetWidget->setButtonText(tr("Sets"));
quickFilterFormatLegalityWidget->setButtonText(tr("Formats"));
quickFilterTagWidget->setButtonText(tr("Tags"));
}
void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event)
@ -192,8 +201,8 @@ void VisualDatabaseDisplayFilterToolbarWidget::updateCompactMode(int availableWi
const bool compact = availableWidth < fullWidthHint;
const QList<SettingsButtonWidget *> filterButtons = {
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget,
quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget,
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget,
quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget,
};
for (auto *btn : filterButtons) {

View file

@ -17,6 +17,7 @@ class VisualDatabaseDisplayMainTypeFilterWidget;
class VisualDatabaseDisplayNameFilterWidget;
class VisualDatabaseDisplaySetFilterWidget;
class VisualDatabaseDisplaySubTypeFilterWidget;
class VisualDatabaseDisplayTagFilterWidget;
class VisualDatabaseDisplayFilterToolbarWidget : public FlowWidget
{
@ -54,6 +55,8 @@ private:
VisualDatabaseDisplaySetFilterWidget *setFilterWidget;
SettingsButtonWidget *quickFilterFormatLegalityWidget;
VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget;
SettingsButtonWidget *quickFilterTagWidget;
VisualDatabaseDisplayTagFilterWidget *tagFilterWidget;
int fullWidthHint = 0;
void updateCompactMode(int availableWidth);

View file

@ -0,0 +1,198 @@
#include "visual_database_display_tag_filter_widget.h"
#include "../../../filters/filter_tree_model.h"
#include "visual_database_display_filter_button.h"
#include <QLabel>
#include <QPushButton>
#include <QSpinBox>
#include <QTimer>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/database/card_database_querier.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