mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
Merge c663de728d into 7a2492ac67
This commit is contained in:
commit
e09c68039c
20 changed files with 654 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1
cockatrice/resources/icons/tags.svg
Normal file
1
cockatrice/resources/icons/tags.svg
Normal 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 |
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ public:
|
|||
AttrMainType,
|
||||
AttrSubType,
|
||||
AttrFormat,
|
||||
AttrTag,
|
||||
AttrEnd,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
183
oracle/scripts/inject_scryfall_tags.py
Executable file
183
oracle/scripts/inject_scryfall_tags.py
Executable 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()
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue