mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-23 10: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_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_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_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_display_widget.cpp
|
||||||
src/interface/widgets/visual_database_display/visual_database_filter_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
|
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/swap.svg</file>
|
||||||
<file>resources/icons/sync.svg</file>
|
<file>resources/icons/sync.svg</file>
|
||||||
<file>resources/icons/tab_changed.svg</file>
|
<file>resources/icons/tab_changed.svg</file>
|
||||||
|
<file>resources/icons/tags.svg</file>
|
||||||
<file>resources/icons/update.png</file>
|
<file>resources/icons/update.png</file>
|
||||||
<file>resources/icons/view.svg</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: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>
|
<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>
|
<dt><u>C</u>olors:</dt>
|
||||||
<dd>[c:w](#c:w) <small>(Any card that is white)</small></dd>
|
<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>
|
<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 = new SettingsButtonWidget(this);
|
||||||
quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced")));
|
quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced")));
|
||||||
|
|
||||||
|
quickFilterTagWidget = new SettingsButtonWidget(this);
|
||||||
|
quickFilterTagWidget->setButtonIcon(themePixmap(QStringLiteral("icons/tags")));
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +105,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
|
||||||
formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel);
|
formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel);
|
||||||
subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel);
|
subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel);
|
||||||
setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel);
|
setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel);
|
||||||
|
tagFilterWidget = new VisualDatabaseDisplayTagFilterWidget(this, filterModel);
|
||||||
|
|
||||||
quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget);
|
quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget);
|
||||||
quickFilterNameWidget->addSettingsWidget(nameFilterWidget);
|
quickFilterNameWidget->addSettingsWidget(nameFilterWidget);
|
||||||
|
|
@ -109,6 +113,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
|
||||||
quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget);
|
quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget);
|
||||||
quickFilterSetWidget->addSettingsWidget(setFilterWidget);
|
quickFilterSetWidget->addSettingsWidget(setFilterWidget);
|
||||||
quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget);
|
quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget);
|
||||||
|
quickFilterTagWidget->addSettingsWidget(tagFilterWidget);
|
||||||
|
|
||||||
// fill groupbox layouts
|
// fill groupbox layouts
|
||||||
sortLayout->addWidget(sortByLabel);
|
sortLayout->addWidget(sortByLabel);
|
||||||
|
|
@ -121,6 +126,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize()
|
||||||
filterLayout->addWidget(quickFilterSubTypeWidget);
|
filterLayout->addWidget(quickFilterSubTypeWidget);
|
||||||
filterLayout->addWidget(quickFilterSetWidget);
|
filterLayout->addWidget(quickFilterSetWidget);
|
||||||
filterLayout->addWidget(quickFilterFormatLegalityWidget);
|
filterLayout->addWidget(quickFilterFormatLegalityWidget);
|
||||||
|
filterLayout->addWidget(quickFilterTagWidget);
|
||||||
|
|
||||||
// put everything into main layout
|
// put everything into main layout
|
||||||
addWidget(sortGroupBox);
|
addWidget(sortGroupBox);
|
||||||
|
|
@ -148,6 +154,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi()
|
||||||
quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type"));
|
quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type"));
|
||||||
quickFilterSetWidget->setToolTip(tr("Filter by set"));
|
quickFilterSetWidget->setToolTip(tr("Filter by set"));
|
||||||
quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality"));
|
quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality"));
|
||||||
|
quickFilterTagWidget->setToolTip(tr("Filter by Scryfall Tagger tag"));
|
||||||
|
|
||||||
quickFilterSaveLoadWidget->setButtonText(tr("Save/Load"));
|
quickFilterSaveLoadWidget->setButtonText(tr("Save/Load"));
|
||||||
quickFilterNameWidget->setButtonText(tr("Name"));
|
quickFilterNameWidget->setButtonText(tr("Name"));
|
||||||
|
|
@ -155,6 +162,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi()
|
||||||
quickFilterSubTypeWidget->setButtonText(tr("Sub Type"));
|
quickFilterSubTypeWidget->setButtonText(tr("Sub Type"));
|
||||||
quickFilterSetWidget->setButtonText(tr("Sets"));
|
quickFilterSetWidget->setButtonText(tr("Sets"));
|
||||||
quickFilterFormatLegalityWidget->setButtonText(tr("Formats"));
|
quickFilterFormatLegalityWidget->setButtonText(tr("Formats"));
|
||||||
|
quickFilterTagWidget->setButtonText(tr("Tags"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event)
|
void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event)
|
||||||
|
|
@ -168,8 +176,8 @@ void VisualDatabaseDisplayFilterToolbarWidget::updateCompactMode(int availableWi
|
||||||
const bool compact = availableWidth < fullWidthHint;
|
const bool compact = availableWidth < fullWidthHint;
|
||||||
|
|
||||||
const QList<SettingsButtonWidget *> filterButtons = {
|
const QList<SettingsButtonWidget *> filterButtons = {
|
||||||
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget,
|
quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget,
|
||||||
quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget,
|
quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (auto *btn : filterButtons) {
|
for (auto *btn : filterButtons) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "visual_database_display_name_filter_widget.h"
|
#include "visual_database_display_name_filter_widget.h"
|
||||||
#include "visual_database_display_set_filter_widget.h"
|
#include "visual_database_display_set_filter_widget.h"
|
||||||
#include "visual_database_display_sub_type_filter_widget.h"
|
#include "visual_database_display_sub_type_filter_widget.h"
|
||||||
|
#include "visual_database_display_tag_filter_widget.h"
|
||||||
|
|
||||||
class VisualDatabaseDisplayWidget;
|
class VisualDatabaseDisplayWidget;
|
||||||
|
|
||||||
|
|
@ -46,6 +47,8 @@ private:
|
||||||
VisualDatabaseDisplaySetFilterWidget *setFilterWidget;
|
VisualDatabaseDisplaySetFilterWidget *setFilterWidget;
|
||||||
SettingsButtonWidget *quickFilterFormatLegalityWidget;
|
SettingsButtonWidget *quickFilterFormatLegalityWidget;
|
||||||
VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget;
|
VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget;
|
||||||
|
SettingsButtonWidget *quickFilterTagWidget;
|
||||||
|
VisualDatabaseDisplayTagFilterWidget *tagFilterWidget;
|
||||||
|
|
||||||
int fullWidthHint = 0;
|
int fullWidthHint = 0;
|
||||||
void updateCompactMode(int availableWidth);
|
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_database_querier.h"
|
||||||
|
|
||||||
#include "../card_info.h"
|
#include "../card_info.h"
|
||||||
|
#include "../game_specific_terms.h"
|
||||||
#include "../printing/exact_card.h"
|
#include "../printing/exact_card.h"
|
||||||
#include "../set/card_set_comparator.h"
|
#include "../set/card_set_comparator.h"
|
||||||
#include "card_database.h"
|
#include "card_database.h"
|
||||||
|
|
@ -23,6 +24,7 @@ void CardDatabaseQuerier::invalidateCaches()
|
||||||
mainCardTypeCountsCache.clear();
|
mainCardTypeCountsCache.clear();
|
||||||
subCardTypeCountsCache.clear();
|
subCardTypeCountsCache.clear();
|
||||||
formatsCountCache.clear();
|
formatsCountCache.clear();
|
||||||
|
tagCountsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -359,6 +361,21 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const
|
||||||
return db->formats.value(formatName.toLower());
|
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
|
QMap<QString, int> CardDatabaseQuerier::getAllFormatsWithCount() const
|
||||||
{
|
{
|
||||||
if (formatsCountCache.isEmpty()) {
|
if (formatsCountCache.isEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,16 @@ public:
|
||||||
* @return Map of subtype string to count.
|
* @return Map of subtype string to count.
|
||||||
*/
|
*/
|
||||||
[[nodiscard]] QMap<QString, int> getAllSubCardTypesWithCount() const;
|
[[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;
|
FormatRulesPtr getFormat(const QString &formatName) const;
|
||||||
QMap<QString, int> getAllFormatsWithCount() const;
|
QMap<QString, int> getAllFormatsWithCount() const;
|
||||||
|
|
||||||
|
|
@ -227,6 +237,7 @@ private:
|
||||||
mutable QMap<QString, int> mainCardTypeCountsCache;
|
mutable QMap<QString, int> mainCardTypeCountsCache;
|
||||||
mutable QMap<QString, int> subCardTypeCountsCache;
|
mutable QMap<QString, int> subCardTypeCountsCache;
|
||||||
mutable QMap<QString, int> formatsCountCache;
|
mutable QMap<QString, int> formatsCountCache;
|
||||||
|
mutable QMap<QString, int> tagCountsCache;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void invalidateCaches();
|
void invalidateCaches();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ QString const PowTough("pt");
|
||||||
QString const Side("side");
|
QString const Side("side");
|
||||||
QString const Layout("layout");
|
QString const Layout("layout");
|
||||||
QString const ColorIdentity("coloridentity");
|
QString const ColorIdentity("coloridentity");
|
||||||
|
QString const Tags("tags");
|
||||||
|
|
||||||
inline static const QString getNicePropertyName(QString key)
|
inline static const QString getNicePropertyName(QString key)
|
||||||
{
|
{
|
||||||
|
|
@ -61,6 +62,9 @@ inline static const QString getNicePropertyName(QString key)
|
||||||
if (key == ColorIdentity) {
|
if (key == ColorIdentity) {
|
||||||
return QCoreApplication::translate("Mtg", "Color Identity");
|
return QCoreApplication::translate("Mtg", "Color Identity");
|
||||||
}
|
}
|
||||||
|
if (key == Tags) {
|
||||||
|
return QCoreApplication::translate("Mtg", "Tags");
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
} // namespace Mtg
|
} // namespace Mtg
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ const QString CardFilter::attrName(Attr a)
|
||||||
return tr("Loyalty");
|
return tr("Loyalty");
|
||||||
case AttrFormat:
|
case AttrFormat:
|
||||||
return tr("Format");
|
return tr("Format");
|
||||||
|
case AttrTag:
|
||||||
|
return tr("Tag");
|
||||||
case AttrMainType:
|
case AttrMainType:
|
||||||
return tr("Main Type");
|
return tr("Main Type");
|
||||||
case AttrSubType:
|
case AttrSubType:
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ public:
|
||||||
AttrMainType,
|
AttrMainType,
|
||||||
AttrSubType,
|
AttrSubType,
|
||||||
AttrFormat,
|
AttrFormat,
|
||||||
|
AttrTag,
|
||||||
AttrEnd,
|
AttrEnd,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
|
#include <libcockatrice/card/game_specific_terms.h>
|
||||||
#include <libcockatrice/utility/peglib.h>
|
#include <libcockatrice/utility/peglib.h>
|
||||||
|
|
||||||
static peg::parser search(R"(
|
static peg::parser search(R"(
|
||||||
|
|
@ -17,7 +18,7 @@ ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / Somew
|
||||||
|
|
||||||
SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
|
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
|
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
|
||||||
SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
|
SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
|
||||||
|
|
@ -38,6 +39,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted'
|
||||||
|
|
||||||
TypeQuery <- [tT] 'ype'? [:] StringValue
|
TypeQuery <- [tT] 'ype'? [:] StringValue
|
||||||
|
|
||||||
|
TagQuery <- [tT]('ags'/'ag') [:] String
|
||||||
|
|
||||||
Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? >
|
Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? >
|
||||||
ColorEx <- Color / [mc]
|
ColorEx <- Color / [mc]
|
||||||
|
|
||||||
|
|
@ -133,6 +136,19 @@ static void setupParserRules()
|
||||||
const auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
const auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||||
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
|
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 {
|
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
|
||||||
if (sv.choice() == 1) {
|
if (sv.choice() == 1) {
|
||||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include "filter_card.h"
|
#include "filter_card.h"
|
||||||
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <libcockatrice/card/game_specific_terms.h>
|
||||||
|
|
||||||
template <class T> FilterTreeNode *FilterTreeBranch<T>::nodeAt(int i) const
|
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";
|
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
|
bool FilterItem::acceptLoyalty(const CardInfoPtr info) const
|
||||||
{
|
{
|
||||||
if (info->getLoyalty().isEmpty()) {
|
if (info->getLoyalty().isEmpty()) {
|
||||||
|
|
@ -495,6 +506,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr info,
|
||||||
return acceptLoyalty(info);
|
return acceptLoyalty(info);
|
||||||
case CardFilter::AttrFormat:
|
case CardFilter::AttrFormat:
|
||||||
return acceptFormat(info);
|
return acceptFormat(info);
|
||||||
|
case CardFilter::AttrTag:
|
||||||
|
return acceptTag(info);
|
||||||
case CardFilter::AttrMainType:
|
case CardFilter::AttrMainType:
|
||||||
return acceptMainType(info);
|
return acceptMainType(info);
|
||||||
case CardFilter::AttrSubType:
|
case CardFilter::AttrSubType:
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ public:
|
||||||
[[nodiscard]] bool
|
[[nodiscard]] bool
|
||||||
acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
|
acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const;
|
||||||
[[nodiscard]] bool acceptFormat(CardInfoPtr info) const;
|
[[nodiscard]] bool acceptFormat(CardInfoPtr info) const;
|
||||||
|
[[nodiscard]] bool acceptTag(CardInfoPtr info) const;
|
||||||
[[nodiscard]] bool relationCheck(int cardInfo) 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);
|
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());
|
const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList());
|
||||||
if (mainCardType.isEmpty()) {
|
if (mainCardType.isEmpty()) {
|
||||||
qDebug() << "warning: no mainCardType for card:" << name;
|
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)) {
|
if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) {
|
||||||
properties.insert(prop, thisCardPropertyValue);
|
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 {
|
} else {
|
||||||
properties.insert(prop,
|
properties.insert(prop,
|
||||||
originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue);
|
originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
#include "gtest/gtest.h"
|
#include "gtest/gtest.h"
|
||||||
#include <libcockatrice/filters/filter_string.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_preference_provider.h>
|
||||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||||
|
|
||||||
|
|
@ -124,6 +125,64 @@ TEST_F(CardQuery, SearchLanguageIsBoundPerInstance)
|
||||||
ASSERT_TRUE(germanQuery.check(localized));
|
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
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
int main(int argc, char **argv)
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,64 @@ TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc)
|
||||||
ASSERT_EQ(result->getProperty("cmc"), "3");
|
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
|
// Card deduplication tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue