diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cc6debdf0..a09a81ac9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -317,6 +317,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp + src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_widget.cpp src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 229cdd125..e398033e9 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -49,6 +49,7 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg + resources/icons/tags.svg resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..f29e57bec 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
[t:basic](#t:basic) (Any card with the type basic)
[t:arcane t:instant](#t:arcane t:instant) (Any card with the types arcane and instant)
+
Tags:
+
[tags:ramp](#tags:ramp) (Any card tagged "ramp")
+
[tags:draw tags:ramp](#tags:draw tags:ramp) (Any card tagged both "draw" and "ramp")
+
[tags:draw OR tags:ramp](#tags:draw OR tags:ramp) (Any card tagged "draw" or "ramp")
+
Colors:
[c:w](#c:w) (Any card that is white)
[c:wu](#c:wu) (Any card that is white or blue)
diff --git a/cockatrice/resources/icons/tags.svg b/cockatrice/resources/icons/tags.svg new file mode 100644 index 000000000..e3defcd0d --- /dev/null +++ b/cockatrice/resources/icons/tags.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index 218d0e964..a94f01085 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -23,12 +23,25 @@ #include #include +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 = ""; text += QString("") .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(" "); + tagsText = preview.join(", ").toHtmlEscaped() + + QString(" … %2").arg(SHOW_ALL_TAGS_LINK, moreText); + } else { + tagsText = tags.join(", ").toHtmlEscaped(); + } + + text += QString("").arg(keyText, tagsText); + continue; + } + text += QString("").arg(keyText, card->getProperty(key).toHtmlEscaped()); } diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index 8223093fe..eae2fb8e3 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -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: diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index ecb949b67..f109b25c1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -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 @@ -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 filterButtons = { - quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, - quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget, + quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget, + quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget, }; for (auto *btn : filterButtons) { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f97310c1b..9f36f3cd1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -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); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp new file mode 100644 index 000000000..ab7bb27a4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +VisualDatabaseDisplayTagFilterWidget::VisualDatabaseDisplayTagFilterWidget(QWidget *parent, + FilterTreeModel *_filterModel) + : QWidget(parent), filterModel(_filterModel) +{ + allTagsWithCount = CardDatabaseManager::query()->getAllTagsWithCount(); + + setMinimumWidth(300); + setMaximumHeight(300); + + layout = new QVBoxLayout(this); + setLayout(layout); + layout->setContentsMargins(0, 1, 0, 1); + layout->setSpacing(1); + layout->setAlignment(Qt::AlignTop); + + // Create search box + searchBox = new QLineEdit(this); + layout->addWidget(searchBox); + connect(searchBox, &QLineEdit::textChanged, this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + layout->addWidget(flowWidget); + + // Create a container for the threshold control + auto *thresholdLayout = new QHBoxLayout(); + thresholdLayout->setContentsMargins(0, 0, 0, 0); + + thresholdLabel = new QLabel(this); + thresholdLayout->addWidget(thresholdLabel); + + // Create the spinbox + spinBox = new QSpinBox(this); + spinBox->setMinimum(1); + spinBox->setMaximum(getMaxTagCount()); + spinBox->setValue(150); + thresholdLayout->addWidget(spinBox); + thresholdLayout->addStretch(); + + layout->addLayout(thresholdLayout); + + connect(spinBox, qOverload(&QSpinBox::valueChanged), this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + // Create the toggle button for Exact Match/Includes mode + toggleButton = new QPushButton(this); + layout->addWidget(toggleButton); + connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayTagFilterWidget::updateFilterMode); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel); }); + + createTagButtons(); // Populate buttons initially + updateFilterMode(); // Initialize toggle button text + + retranslateUi(); +} + +void VisualDatabaseDisplayTagFilterWidget::retranslateUi() +{ + searchBox->setPlaceholderText(tr("Search tags...")); + thresholdLabel->setText(tr("Show tags with at least:")); + spinBox->setSuffix(tr(" cards")); + spinBox->setToolTip(tr("Do not display tags with less than this amount of cards in the database")); + toggleButton->setToolTip(tr("Filter mode (require all selected tags, or any of them)")); +} + +void VisualDatabaseDisplayTagFilterWidget::createTagButtons() +{ + // Iterate through tags and create buttons + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + auto *button = new QPushButton(it.key(), flowWidget); + button->setCheckable(true); + button->setStyleSheet(visualDatabaseDisplayFilterButtonStyle); + + flowWidget->addWidget(button); + tagButtons[it.key()] = button; + + // Connect toggle signal for each button + connect(button, &QPushButton::toggled, this, + [this, tag = it.key()](bool checked) { handleTagToggled(tag, checked); }); + } + updateTagButtonsVisibility(); // Ensure visibility is updated initially +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility() +{ + const int threshold = spinBox->value(); + const QString filterText = searchBox->text().trimmed().toLower(); + + // Iterate through buttons and hide/disable those below the threshold. An + // active search overrides the threshold so rare tags stay reachable. + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + const bool isActive = activeTags.value(it.key(), false); + const bool visible = isActive || (filterText.isEmpty() ? allTagsWithCount[it.key()] >= threshold + : it.key().toLower().contains(filterText)); + + it.value()->setVisible(visible); + it.value()->setEnabled(visible); + } +} + +int VisualDatabaseDisplayTagFilterWidget::getMaxTagCount() const +{ + int maxCount = 1; + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + maxCount = qMax(maxCount, it.value()); + } + return maxCount; +} + +void VisualDatabaseDisplayTagFilterWidget::handleTagToggled(const QString &tag, bool active) +{ + activeTags[tag] = active; + + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(active); + } + + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagFilter() +{ + filterModel->blockSignals(true); + filterModel->filterTree()->blockSignals(true); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrTag); + + for (const auto &activeTag : activeTags.keys()) { + if (activeTags[activeTag]) { + QString tag = activeTag; + filterModel->addFilter(new CardFilter( + tag, exactMatchMode ? CardFilter::Type::TypeAnd : CardFilter::Type::TypeOr, CardFilter::Attr::AttrTag)); + } + } + + filterModel->blockSignals(false); + filterModel->filterTree()->blockSignals(false); + + emit filterModel->filterTree()->changed(); + emit filterModel->layoutChanged(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateFilterMode() +{ + exactMatchMode = !exactMatchMode; + toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel() +{ + // Temporarily block signals for each button to prevent toggling while updating button states + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(true); + } + + // Uncheck all buttons + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->setChecked(false); + } + + // Get active filters for tags + QSet activeTypes; + for (const auto &filter : filterModel->getFiltersOfType(CardFilter::AttrTag)) { + if (filter->type() == CardFilter::Type::TypeAnd || filter->type() == CardFilter::Type::TypeOr) { + activeTypes.insert(filter->term()); + } + } + + // Check the buttons for active tags + for (const auto &tag : activeTypes) { + activeTags[tag] = true; + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(true); + } + } + + // Re-enable signal emissions for each button + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(false); + } + + // Update the visibility of buttons + updateTagButtonsVisibility(); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h new file mode 100644 index 000000000..092a10587 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H +#define COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H + +#include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +class VisualDatabaseDisplayTagFilterWidget : public QWidget +{ + Q_OBJECT +public: + explicit VisualDatabaseDisplayTagFilterWidget(QWidget *parent, FilterTreeModel *filterModel); + void retranslateUi(); + void createTagButtons(); + void updateTagButtonsVisibility(); + [[nodiscard]] int getMaxTagCount() const; + + void handleTagToggled(const QString &tag, bool active); + void updateTagFilter(); + void updateFilterMode(); + void syncWithFilterModel(); + +private: + FilterTreeModel *filterModel; + QMap allTagsWithCount; + + QVBoxLayout *layout; + QLineEdit *searchBox; + FlowWidget *flowWidget; + QLabel *thresholdLabel; + QSpinBox *spinBox; + QPushButton *toggleButton; // Mode switch button + + QMap activeTags; // Track active filters + QMap tagButtons; // Store toggle buttons + + bool exactMatchMode = true; // Toggle between "Exact Match" (all) and "Includes" (any) +}; + +#endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 8df0d2e20..fb8542808 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -1,6 +1,7 @@ #include "card_database_querier.h" #include "../card_info.h" +#include "../game_specific_terms.h" #include "../printing/exact_card.h" #include "../set/card_set_comparator.h" #include "card_database.h" @@ -31,6 +32,7 @@ void CardDatabaseQuerier::invalidateCaches() mainCardTypeCountsCache.clear(); subCardTypeCountsCache.clear(); formatsCountCache.clear(); + tagCountsCache.clear(); } /** @@ -381,6 +383,21 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const return db->formats.value(formatName.toLower()); } +QMap CardDatabaseQuerier::getAllTagsWithCount() const +{ + if (tagCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + for (const QString &tag : tags) { + tagCountsCache[tag]++; + } + } + } + + return tagCountsCache; +} + QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index a1409b3cc..8508e4245 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: * @return Map of subtype string to count. */ [[nodiscard]] QMap getAllSubCardTypesWithCount() const; + + /** + * @brief Returns a mapping of Scryfall Tagger tag slugs to their occurrence counts. + * + * Tags are stored space-separated in the `tags` card property, so a card + * tagged "ramp" and "removal" contributes one to each slug's count. + * + * @return Map of tag slug to count. + */ + [[nodiscard]] QMap getAllTagsWithCount() const; FormatRulesPtr getFormat(const QString &formatName) const; QMap getAllFormatsWithCount() const; @@ -233,6 +243,7 @@ private: mutable QMap mainCardTypeCountsCache; mutable QMap subCardTypeCountsCache; mutable QMap formatsCountCache; + mutable QMap tagCountsCache; private slots: void invalidateCaches(); diff --git a/libcockatrice_card/libcockatrice/card/game_specific_terms.h b/libcockatrice_card/libcockatrice/card/game_specific_terms.h index e9160e514..5be8ec6d5 100644 --- a/libcockatrice_card/libcockatrice/card/game_specific_terms.h +++ b/libcockatrice_card/libcockatrice/card/game_specific_terms.h @@ -28,6 +28,7 @@ QString const PowTough("pt"); QString const Side("side"); QString const Layout("layout"); QString const ColorIdentity("coloridentity"); +QString const Tags("tags"); inline static const QString getNicePropertyName(QString key) { @@ -61,6 +62,9 @@ inline static const QString getNicePropertyName(QString key) if (key == ColorIdentity) { return QCoreApplication::translate("Mtg", "Color Identity"); } + if (key == Tags) { + return QCoreApplication::translate("Mtg", "Tags"); + } return key; } } // namespace Mtg diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 8af39c098..041ab9c0c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -89,6 +89,8 @@ const QString CardFilter::attrName(Attr a) return tr("Loyalty"); case AttrFormat: return tr("Format"); + case AttrTag: + return tr("Tag"); case AttrMainType: return tr("Main Type"); case AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index bc613fc9c..dee9e14bc 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -46,6 +46,7 @@ public: AttrMainType, AttrSubType, AttrFormat, + AttrTag, AttrEnd, }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index f7ca82344..e91817967 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +42,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') ws? SetQueryValue @@ -63,6 +64,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue +TagQuery <- [tT]('ags'/'ag') [:] String + Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] @@ -158,6 +161,19 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; + search["TagQuery"] = [](const peg::SemanticValues &sv) -> Filter { + // Tags are stored space-separated, so matching whole tokens keeps + // `tags:ram` from matching `ramp`. Combine tags with AND: + // `tags:draw tags:ramp`. + const auto tag = std::any_cast(sv[0]).trimmed(); + return [=](const CardData &x) -> bool { + const QString stored = x->getProperty(Mtg::Tags); + if (stored.isEmpty() || tag.isEmpty()) { + return false; + } + return stored.split(" ", Qt::SkipEmptyParts).contains(tag, Qt::CaseInsensitive); + }; + }; search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 817c7298d..13c78e1f7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -2,6 +2,7 @@ #include "filter_card.h" #include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/game_specific_terms.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" @@ -477,6 +478,16 @@ bool FilterItem::acceptFormat(const CardInfoPtr &info) const return info->getLegalityProp(lowercaseTerm) == "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 { const QString loyaltyString = info->getLoyalty(); @@ -576,6 +587,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptLoyalty(info); case CardFilter::AttrFormat: return acceptFormat(info); + case CardFilter::AttrTag: + return acceptTag(info); case CardFilter::AttrMainType: return acceptMainType(info); case CardFilter::AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index f1a5803e5..1b899fed7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -255,6 +255,7 @@ public: [[nodiscard]] bool acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptTag(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index b500f79ab..6c61a7e27 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -25,6 +25,7 @@ set(oracle_SOURCES src/pagetemplates.cpp src/parsehelpers.cpp src/raw_json_scanner.cpp + src/tag_database.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index c83c962e3..4657bf055 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -6,6 +6,7 @@ #include "libcockatrice/card/relation/card_relation_type.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "tag_database.h" #include #include @@ -521,6 +522,31 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert("coloridentity", colorIdentity); } + // Card tags: the source's own `tags` array is used when present (MTGJson + // may ship one eventually), then the configured tag database is joined in + // while the card's identifiers are still available. Stored space-separated + // so `tags:` 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 (tagDatabase) { + const QString oracleId = getJsonString(identifiers, "scryfallOracleId"); + for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) { + if (!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; @@ -667,6 +693,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { properties.insert(prop, thisCardPropertyValue); } + } else if (prop == "tags") { + // Tags are oracle-level: union both faces instead of + // concatenating them with the split-card separator. + QStringList merged = + (originalPropertyValue + " " + thisCardPropertyValue).split(" ", Qt::SkipEmptyParts); + merged.removeDuplicates(); + merged.sort(); + properties.insert(prop, merged.join(" ")); } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 5ebe9cf36..694babf4b 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -17,12 +17,14 @@ #include #include #include +#include #include #include #include class CardRelation; class QRegularExpression; +class TagDatabase; // many users prefer not to see these sets with non english arts // they will given priority PriorityLowest @@ -215,6 +217,12 @@ private: */ bool localizationEnabled = false; + /** + * Optional tag lookup applied to every imported card. Owned by the wizard + * so it outlives the import worker; null when tags are disabled. + */ + std::shared_ptr tagDatabase; + /** * Localized name/text collected per imported card key while parsing sets, * applied to the CardInfo objects by applyLocalizedData() once all @@ -269,6 +277,16 @@ public: { progressReporting = enabled; } + /** + * @brief Supplies the tag database applied to every imported card. + * + * Pass nullptr to import without tags. The wizard keeps the database alive + * for the whole import, so the worker sees a stable pointer. + */ + void setTagDatabase(std::shared_ptr database) + { + tagDatabase = std::move(database); + } /** * Selects the ISO-639 language code whose foreignData is imported. * English (the default) and unsupported codes disable localization. diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index dc8c7ecb3..43e6f30ca 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -57,7 +57,8 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) QList pages; if (!isSpoilersOnly) { - pages << new IntroPage << new LoadSetsPage << new SaveSetsPage << new LoadTokensPage << new OutroPage; + pages << new IntroPage << new LoadSetsPage << new TagsPage << new SaveSetsPage << new LoadTokensPage + << new OutroPage; } else { pages << new LoadSpoilersPage << new OutroPage; } diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index b1dc5eab2..121acd577 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ class QNetworkAccessManager; class OracleImporter; class QLocalServer; class QSettings; +class TagDatabase; class QWidget; class OracleWizard : public QWizard @@ -68,6 +70,8 @@ public: OracleImporter *importer; QSettings *settings; QNetworkAccessManager *nam; + /** @brief Tag data loaded by the Tags page, consumed by the importer. */ + std::shared_ptr tagDatabase; bool downloadedPlainXml = false; QByteArray xmlData; bool backgroundMode = false; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index f5c38be1c..cf23412c2 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -6,6 +6,7 @@ #include "oracleimporter.h" #include "oraclewizard.h" #include "pagetemplates.h" +#include "tag_database.h" #include "version_string.h" #include @@ -19,7 +20,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -36,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +76,7 @@ class QWidget; #define MTGJSON_V4_URL_COMPONENT "mtgjson.com/files/" #define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/api/v5/AllPrintings.json" #define MTGJSON_VERSION_URL "https://www.mtgjson.com/api/v5/Meta.json" +#define SCRYFALL_TAGS_URL "https://api.scryfall.com/bulk-data/oracle-tags" #ifdef HAS_LZMA #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.xz" @@ -707,6 +712,502 @@ void LoadSetsPage::importFinished() }); } +namespace +{ +/** + * @brief True when a URL points at a tag file rather than Scryfall's bulk metadata. + */ +bool looksLikeTagFileUrl(const QUrl &url) +{ + const QString path = url.path().toLower(); + return path.endsWith(".json") || path.endsWith(".jsonl") || path.endsWith(".gz") || path.endsWith(".xz") || + path.endsWith(".zip"); +} + +/** + * @brief Extends Qt's terse network error with a JSON error body. + * + * Scryfall explains a rejected request in the response body (for example a + * missing Accept header); QNetworkReply::errorString() only says "status code + * 400". Non-JSON bodies (CDN HTML pages) are ignored so the dialog stays short. + */ +QString networkErrorDetail(QNetworkReply *reply) +{ + QString detail = reply->errorString(); + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (body.startsWith('{') && body.size() <= 300) { + detail += QStringLiteral(" ") + body; + } + return detail; +} +} // namespace + +TagsPage::TagsPage(QWidget *parent) : OracleWizardPage(parent) +{ + enableCheckBox = new QCheckBox(this); + enableCheckBox->setChecked(true); + connect(enableCheckBox, &QCheckBox::toggled, this, &TagsPage::updateSourceEnabled); + + urlRadioButton = new QRadioButton(this); + fileRadioButton = new QRadioButton(this); + urlRadioButton->setChecked(true); + connect(urlRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + connect(fileRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + + urlLineEdit = new QLineEdit(this); + fileLineEdit = new QLineEdit(this); + + urlButton = new QPushButton(this); + connect(urlButton, &QPushButton::clicked, this, &TagsPage::actRestoreDefaultSource); + + fileButton = new QPushButton(this); + connect(fileButton, &QPushButton::clicked, this, &TagsPage::actChooseFile); + + parentsCheckBox = new QCheckBox(this); + + parentsExplainer = new QLabel(this); + parentsExplainer->setWordWrap(true); + // Indent under the checkbox and give the wrapped text room above and below, + // so it reads as an explanation of the option rather than a caption glued + // to the controls around it. + parentsExplainer->setContentsMargins(24, 4, 0, 10); + // A word-wrapped label reports a one-line sizeHint, so a short page squeezes + // it and clips the text top and bottom. Pinning the vertical policy makes + // the wrapped height part of the page's minimum size. + parentsExplainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + statusLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + progressLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressLabel->hide(); + progressBar->hide(); + + auto *layout = new QGridLayout(this); + layout->addWidget(enableCheckBox, 0, 0, 1, 2); + layout->addWidget(urlRadioButton, 1, 0); + layout->addWidget(urlLineEdit, 1, 1); + layout->addWidget(urlButton, 2, 1, Qt::AlignRight); + layout->addWidget(fileRadioButton, 3, 0); + layout->addWidget(fileLineEdit, 3, 1); + layout->addWidget(fileButton, 4, 1, Qt::AlignRight); + layout->addWidget(parentsCheckBox, 5, 0, 1, 2); + layout->addWidget(parentsExplainer, 6, 0, 1, 2); + layout->addWidget(statusLabel, 7, 0, 1, 2); + layout->addWidget(progressLabel, 8, 0); + layout->addWidget(progressBar, 8, 1); + layout->setRowStretch(9, 1); + setLayout(layout); + + connect(&watcher, &QFutureWatcher::finished, this, &TagsPage::actLoadFinished); + + updateSourceEnabled(); + retranslateUi(); +} + +void TagsPage::retranslateUi() +{ + setTitle(tr("Tag data")); + setSubTitle(tr("Card tags let you search and filter by tag. Oracle downloads Scryfall's Oracle Tags by " + "default; you can also point it at your own tag database.")); + + enableCheckBox->setText(tr("Download card tags")); + enableCheckBox->setToolTip( + tr("Apply tag slugs to the card database so cards can be searched and filtered by tag.")); + urlRadioButton->setText(tr("Download URL:")); + urlButton->setText(tr("Restore default URL")); + fileRadioButton->setText(tr("Local file:")); + fileButton->setText(tr("Choose file...")); + parentsCheckBox->setText(tr("Include parent tags")); + parentsCheckBox->setToolTip(tr("Also apply the broader tags that this tag belongs to.")); + parentsExplainer->setText( + tr("Tags form a hierarchy: \"tutor-creature-giant\" belongs to \"tutor-creature\", which belongs to " + "\"tutor\". With this on, a card tagged with the specific tag also matches searches for its broader " + "parent tags. It adds many more tags to each card.")); +} + +bool TagsPage::isComplete() const +{ + return !loadActive; +} + +void TagsPage::initializePage() +{ + enableCheckBox->setChecked(wizard()->settings->value("tagsenabled", true).toBool()); + parentsCheckBox->setChecked(wizard()->settings->value("tagsparents", false).toBool()); + + const QString source = wizard()->settings->value("tagssource").toString(); + if (source.isEmpty() || source.startsWith("http://") || source.startsWith("https://")) { + urlRadioButton->setChecked(true); + urlLineEdit->setText(source.isEmpty() ? QString(SCRYFALL_TAGS_URL) : source); + fileLineEdit->clear(); + } else { + fileRadioButton->setChecked(true); + fileLineEdit->setText(source); + urlLineEdit->setText(SCRYFALL_TAGS_URL); + } + + statusLabel->clear(); + progressLabel->hide(); + progressBar->hide(); + updateSourceEnabled(); + + if (wizard()->backgroundMode && isEnabled()) { + // In background mode runInBackground() calls initializePage() instead of + // next(), so a page that has nothing to do must advance itself. + if (validatePage()) { + emit readyToContinue(); + } + } +} + +QString TagsPage::selectedSource() const +{ + return urlRadioButton->isChecked() ? urlLineEdit->text().trimmed() : fileLineEdit->text().trimmed(); +} + +void TagsPage::updateSourceEnabled() +{ + const bool enabled = enableCheckBox->isChecked(); + const bool usingUrl = urlRadioButton->isChecked(); + const bool usingFile = fileRadioButton->isChecked(); + + urlRadioButton->setEnabled(enabled); + fileRadioButton->setEnabled(enabled); + urlLineEdit->setEnabled(enabled && usingUrl); + urlButton->setEnabled(enabled && usingUrl); + fileLineEdit->setEnabled(enabled && usingFile); + fileButton->setEnabled(enabled && usingFile); + parentsCheckBox->setEnabled(enabled); + parentsExplainer->setEnabled(enabled); +} + +void TagsPage::saveSettings() +{ + wizard()->settings->setValue("tagsenabled", enableCheckBox->isChecked()); + wizard()->settings->setValue("tagsparents", parentsCheckBox->isChecked()); + + QString source = selectedSource(); + if (urlRadioButton->isChecked() && source == SCRYFALL_TAGS_URL) { + source.clear(); + } + if (source.isEmpty()) { + wizard()->settings->remove("tagssource"); + } else { + wizard()->settings->setValue("tagssource", source); + } +} + +bool TagsPage::validatePage() +{ + if (!enableCheckBox->isChecked()) { + wizard()->tagDatabase.reset(); + saveSettings(); + return true; + } + + const auto rejectValidation = [this](const QString &message) { + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->tagDatabase.reset(); + emit readyToContinue(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; + + const QString source = selectedSource(); + const bool includeParents = parentsCheckBox->isChecked(); + + // Going back and forth through the wizard must not reload an unchanged source. + if (wizard()->tagDatabase && source == loadedSource && includeParents == loadedParents) { + return true; + } + + if (loadActive) { + return false; + } + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (!url.isValid() || url.isEmpty()) { + return rejectValidation(tr("The provided tag URL is not valid.")); + } + } else if (source.isEmpty() || !QFile::exists(source)) { + return rejectValidation(tr("Please choose an existing tag database file.")); + } + + saveSettings(); + loadedSource = source; + loadedParents = includeParents; + + wizard()->disableButtons(); + setEnabled(false); + loadActive = true; + emit completeChanged(); + + progressBar->setRange(0, 0); + progressBar->setValue(0); + progressLabel->setText(tr("Fetching tag data...")); + progressLabel->show(); + progressBar->show(); + statusLabel->clear(); + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (looksLikeTagFileUrl(url)) { + downloadTagFile(url); + } else { + downloadMetadata(url); + } + } else { + loadFromFile(source); + } + return false; +} + +void TagsPage::actChooseFile() +{ + QFileDialog dialog(this, tr("Load tag database")); + dialog.setFileMode(QFileDialog::ExistingFile); + + QString extensions = "*.json *.jsonl"; +#ifdef HAS_ZLIB + extensions += " *.gz *.zip"; +#endif +#ifdef HAS_LZMA + extensions += " *.xz"; +#endif + dialog.setNameFilter(tr("Tag database (%1)").arg(extensions)); + + if (!fileLineEdit->text().isEmpty() && QFile::exists(fileLineEdit->text())) { + dialog.selectFile(fileLineEdit->text()); + } + + if (!dialog.exec()) { + return; + } + + fileLineEdit->setText(dialog.selectedFiles().at(0)); + fileRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::actRestoreDefaultSource() +{ + urlLineEdit->setText(SCRYFALL_TAGS_URL); + urlRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::downloadMetadata(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + // api.scryfall.com rejects requests without an Accept header (HTTP 400); + // QNetworkAccessManager does not add one on its own. + request.setRawHeader("Accept", "application/json"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actMetadataFinished); +} + +void TagsPage::actMetadataFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad( + tr("Could not fetch tag metadata from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + failLoad(tr("The tag metadata could not be read.")); + return; + } + + const QJsonObject metadata = document.object(); + QString downloadUrl = metadata.value("jsonl_download_uri").toString(); + if (downloadUrl.isEmpty()) { + downloadUrl = metadata.value("download_uri").toString(); + } + if (downloadUrl.isEmpty()) { + failLoad(tr("The tag data source did not provide a download URL.")); + return; + } + + downloadTagFile(QUrl::fromUserInput(downloadUrl)); +} + +void TagsPage::downloadTagFile(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setRawHeader("Accept", "*/*"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actTagFileFinished); + connect(activeReply, &QNetworkReply::downloadProgress, this, &TagsPage::actDownloadProgress); +} + +void TagsPage::actDownloadProgress(qint64 received, qint64 total) +{ + emitProgress("download", received, total); + if (total > 0) { + progressBar->setRange(0, static_cast(total)); + progressBar->setValue(static_cast(received)); + } + progressLabel->setText(tr("Downloading tag data (%1 MB)").arg(received / (1024 * 1024))); +} + +void TagsPage::actTagFileFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad(tr("Could not download tag data from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + failLoad(tr("The tag download was empty.")); + return; + } + beginLoad(data); +} + +void TagsPage::loadFromFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + failLoad(tr("Cannot open file '%1'.").arg(path)); + return; + } + beginLoad(file.readAll()); +} + +void TagsPage::beginLoad(const QByteArray &data) +{ + progressBar->setRange(0, 0); + progressLabel->setText(tr("Reading tag data...")); + + const bool includeParents = parentsCheckBox->isChecked(); + future = QtConcurrent::run([data, includeParents]() { + LoadResult result; + result.database = TagDatabase::loadFromData(data, TagDatabase::LoadOptions{.includeParentTags = includeParents}, + &result.errorMessage); + return result; + }); + watcher.setFuture(future); +} + +void TagsPage::actLoadFinished() +{ + if (wizard()->wasCancelled()) { + return; + } + + const LoadResult result = watcher.result(); + + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + + if (!result.database) { + wizard()->tagDatabase.reset(); + failLoad(result.errorMessage.isEmpty() ? tr("The tag data could not be read.") : result.errorMessage); + return; + } + + wizard()->tagDatabase = result.database; + statusLabel->setText(tr("Loaded %1 tags for %2 cards.") + .arg(wizard()->tagDatabase->tagCount()) + .arg(wizard()->tagDatabase->taggedCardCount())); + qInfo() << "Loaded tag database:" << wizard()->tagDatabase->tagCount() << "tags for" + << wizard()->tagDatabase->taggedCardCount() << "cards"; + advance(); +} + +void TagsPage::failLoad(const QString &message) +{ + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + qWarning() << message; + + // The failed load leaves no usable database; never keep tags from a + // previous, different source. + wizard()->tagDatabase.reset(); + + if (wizard()->backgroundMode) { + statusLabel->setText(message); + emit readyToContinue(); + return; + } + + // Do not advance here. Advancing runs validatePage() again, which starts + // the same failing download and traps the user in an error loop. Staying + // on the page lets them fix the source, retry with Next, or opt out. + const QString guidance = tr("You can try again, choose another source, or untick \"Download card tags\" " + "to import without tags."); + statusLabel->setText(message + "\n\n" + guidance); + QMessageBox::warning(this, tr("Tags not loaded"), message + "\n\n" + guidance); +} + +void TagsPage::advance() +{ + if (wizard()->backgroundMode) { + emit readyToContinue(); + } else { + wizard()->next(); + } +} + +void TagsPage::cancelWork() +{ + if (activeReply) { + activeReply->abort(); + activeReply = nullptr; + } + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); + } +} + SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) { pathLabel = new QLabel(this); @@ -770,6 +1271,10 @@ void SaveSetsPage::initializePage() Qt::UniqueConnection); connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + // The Tags page has already loaded (or deliberately skipped) the tag data. + // The wizard keeps it alive past this page; the importer only borrows it. + wizard()->importer->setTagDatabase(wizard()->tagDatabase); + wizard()->disableButtons(); importActive = true; emit completeChanged(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index e29a8e84a..49331765d 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -6,21 +6,25 @@ #include #include #include +#include #include #include #include +#include #include class QCheckBox; class QComboBox; class QLabel; class QLineEdit; +class QNetworkReply; class QRadioButton; class QProgressBar; class QTextEdit; class QPushButton; class QUrl; class QWidget; +class TagDatabase; class IntroPage : public OracleWizardPage { @@ -109,6 +113,72 @@ private slots: void zipDownloadFailed(const QString &message); }; +/** + * @brief Optional wizard step that loads the tag database used by the importer. + * + * Reloading is skipped when the source and parent-tag setting are unchanged, so + * going back and forth through the wizard does not re-download the tag data. + */ +class TagsPage : public OracleWizardPage +{ + Q_OBJECT +public: + explicit TagsPage(QWidget *parent = nullptr); + void retranslateUi() override; + bool isComplete() const override; + +protected: + void initializePage() override; + bool validatePage() override; + void cancelWork() override; + +private: + struct LoadResult + { + std::shared_ptr database; + QString errorMessage; + }; + + QCheckBox *enableCheckBox; + QRadioButton *urlRadioButton; + QRadioButton *fileRadioButton; + QLineEdit *urlLineEdit; + QLineEdit *fileLineEdit; + QPushButton *urlButton; + QPushButton *fileButton; + QCheckBox *parentsCheckBox; + QLabel *parentsExplainer; + QLabel *statusLabel; + QLabel *progressLabel; + QProgressBar *progressBar; + + QFutureWatcher watcher; + QFuture future; + QPointer activeReply; + bool loadActive = false; + QString loadedSource; + bool loadedParents = false; + + [[nodiscard]] QString selectedSource() const; + void updateSourceEnabled(); + void saveSettings(); + void beginLoad(const QByteArray &data); + void loadFromFile(const QString &path); + void downloadMetadata(const QUrl &url); + void downloadTagFile(const QUrl &url); + void finishLoad(); + void failLoad(const QString &message); + void advance(); + +private slots: + void actChooseFile(); + void actRestoreDefaultSource(); + void actDownloadProgress(qint64 received, qint64 total); + void actMetadataFinished(); + void actTagFileFinished(); + void actLoadFinished(); +}; + class SaveSetsPage : public OracleWizardPage { Q_OBJECT diff --git a/oracle/src/tag_database.cpp b/oracle/src/tag_database.cpp new file mode 100644 index 000000000..b9c57a969 --- /dev/null +++ b/oracle/src/tag_database.cpp @@ -0,0 +1,643 @@ +#include "tag_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAS_LZMA +#include "lzma/decompress.h" +#endif + +#ifdef HAS_ZLIB +#include "zip/unzip.h" + +#include +#endif + +#include + +namespace +{ +using TagSets = QHash>; + +struct ParsedTag +{ + QString slug; + QStringList parentIds; + QList oracleIds; +}; + +struct ParseContext +{ + bool includeParentTags = false; + QHash tagsById; + TagSets oracleTags; + TagSets nameTags; +}; + +const QByteArray &gzipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("1f8b"); + return magic; +} + +const QByteArray &xzMagic() +{ + static const QByteArray magic = QByteArray::fromHex("fd377a585a00"); + return magic; +} + +const QByteArray &zipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("504b0304"); + return magic; +} + +QString normalizedSlug(const QString &slug) +{ + return slug.trimmed().toLower(); +} + +/** + * @brief Rejects slugs that cannot survive the space-separated `tags` property. + */ +bool isValidSlug(const QString &slug) +{ + if (slug.isEmpty()) { + return false; + } + for (const QChar &character : slug) { + if (character.isSpace()) { + return false; + } + } + return true; +} + +QString normalizedName(const QString &name) +{ + return name.trimmed().toLower(); +} + +/** + * @brief True when a map key has the shape of a Scryfall oracle id. + * + * A manifest with an explicit `key` removes the ambiguity for card names that + * happen to look like UUIDs. + */ +bool looksLikeOracleId(const QString &key) +{ + return !QUuid::fromString(key.trimmed()).isNull(); +} + +void addTags(TagSets &target, const QString &key, const QStringList &slugs) +{ + // Both key spaces are case-insensitive: Scryfall UUIDs are hex, and card + // names must match regardless of the source's capitalization. + const QString normalizedKey = key.trimmed().toLower(); + if (normalizedKey.isEmpty()) { + return; + } + + QSet &tags = target[normalizedKey]; + for (const QString &rawSlug : slugs) { + const QString slug = normalizedSlug(rawSlug); + if (isValidSlug(slug)) { + tags.insert(slug); + } + } + if (tags.isEmpty()) { + target.remove(normalizedKey); + } +} + +#ifdef HAS_ZLIB +bool decompressGzip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + z_stream stream{}; + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip support could not be initialized."); + } + return false; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + int result = Z_OK; + char buffer[64 * 1024]; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + + const uInt produced = static_cast(sizeof(buffer)) - stream.avail_out; + if (produced > 0) { + output.append(buffer, static_cast(produced)); + } + + if (result != Z_OK && result != Z_STREAM_END) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip decompression failed."); + } + return false; + } + if (produced == 0 && stream.avail_in == 0 && result == Z_OK) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The gzip stream is truncated."); + } + return false; + } + } + + inflateEnd(&stream); + return result == Z_STREAM_END; +} +#endif + +#ifdef HAS_LZMA +bool decompressXz(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + QBuffer outputBuffer(&output); + inputBuffer.open(QBuffer::ReadOnly); + outputBuffer.open(QBuffer::WriteOnly); + + XzDecompressor decompressor; + if (!decompressor.decompress(&inputBuffer, &outputBuffer)) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Xz decompression failed."); + } + return false; + } + return true; +} +#endif + +#ifdef HAS_ZLIB +bool decompressZip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + UnZip archive; + const UnZip::ErrorCode openError = archive.openArchive(&inputBuffer); + if (openError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Failed to open the Zip archive: %1.").arg(archive.formatError(openError)); + } + return false; + } + + const QStringList files = archive.fileList(); + if (files.size() != 1) { + archive.closeArchive(); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The Zip archive must contain exactly one tag database file."); + } + return false; + } + + QBuffer outputBuffer(&output); + outputBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode extractError = archive.extractFile(files.first(), &outputBuffer); + archive.closeArchive(); + if (extractError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Zip extraction failed: %1.").arg(archive.formatError(extractError)); + } + return false; + } + return true; +} +#endif + +/** + * @brief Unwraps gzip/xz/zip payloads, including nested archives. + */ +bool decompressData(QByteArray &data, QString *errorMessage) +{ + // Nested compression is legitimate (a zip around a gzip), but a handful of + // layers is enough; the cap keeps a hostile file from looping forever. + for (int layer = 0; layer < 4; ++layer) { + if (data.startsWith(gzipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressGzip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support gzip compressed files."); + } + return false; +#endif + } + + if (data.startsWith(xzMagic())) { +#ifdef HAS_LZMA + QByteArray decompressed; + if (!decompressXz(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support xz compressed files."); + } + return false; +#endif + } + + if (data.startsWith(zipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressZip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support zipped files."); + } + return false; +#endif + } + + return true; + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database is compressed in too many layers."); + } + return false; +} + +bool parseKeyedMap(const QJsonObject &map, const QString &explicitKey, ParseContext &context, QString *errorMessage) +{ + if (map.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + if (!it.value().isArray()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag list for \"%1\" must be an array of slugs.").arg(it.key()); + } + return false; + } + + QStringList slugs; + for (const QJsonValue &value : it.value().toArray()) { + if (!value.isString()) { + if (errorMessage) { + *errorMessage = + TagDatabase::tr("The tag list for \"%1\" must contain only slug strings.").arg(it.key()); + } + return false; + } + slugs.append(value.toString()); + } + + const bool oracleKey = explicitKey.isEmpty() ? looksLikeOracleId(it.key()) + : (explicitKey == "oracle_id" || explicitKey == "oracleid"); + if (oracleKey) { + addTags(context.oracleTags, it.key().trimmed(), slugs); + } else { + addTags(context.nameTags, normalizedName(it.key()), slugs); + } + } + return true; +} + +bool parseTagRow(const QJsonObject &object, ParseContext &context) +{ + QStringList slugs; + for (const QJsonValue &value : object.value("tags").toArray()) { + if (value.isString()) { + slugs.append(value.toString()); + } + } + + const QString oracleId = object.value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty()) { + addTags(context.oracleTags, oracleId, slugs); + } + + const QString name = object.value("name").toString().trimmed(); + if (!name.isEmpty()) { + addTags(context.nameTags, normalizedName(name), slugs); + } + return true; +} + +bool parseTagObject(const QJsonObject &object, ParseContext &context) +{ + // Art and illustration tags describe a printing's artwork, not the oracle + // card, so they never belong in the card-level tag property. + const QString type = object.value("type").toString().trimmed().toLower(); + if (!type.isEmpty() && type != "oracle") { + return true; + } + + const QString slug = normalizedSlug(object.value("slug").toString()); + if (!isValidSlug(slug)) { + return true; + } + + QList oracleIds; + for (const QJsonValue &value : object.value("taggings").toArray()) { + const QString oracleId = value.toObject().value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty() && !oracleIds.contains(oracleId)) { + oracleIds.append(oracleId); + } + } + + const QString id = object.value("id").toString().trimmed(); + if (!context.includeParentTags || id.isEmpty()) { + for (const QString &oracleId : oracleIds) { + addTags(context.oracleTags, oracleId, QStringList{slug}); + } + return true; + } + + ParsedTag &tag = context.tagsById[id]; + if (tag.slug.isEmpty()) { + tag.slug = slug; + } + for (const QJsonValue &parent : object.value("parent_ids").toArray()) { + const QString parentId = parent.toString().trimmed(); + if (!parentId.isEmpty() && !tag.parentIds.contains(parentId)) { + tag.parentIds.append(parentId); + } + } + for (const QString &oracleId : oracleIds) { + if (!tag.oracleIds.contains(oracleId)) { + tag.oracleIds.append(oracleId); + } + } + return true; +} + +bool handleObject(const QJsonObject &object, ParseContext &context, QString *errorMessage) +{ + if (object.contains("slug")) { + return parseTagObject(object, context); + } + + const QJsonValue tagsValue = object.value("tags"); + if (tagsValue.isArray() && (object.contains("oracle_id") || object.contains("name"))) { + return parseTagRow(object, context); + } + if (tagsValue.isObject()) { + const QString key = object.value("key").toString().trimmed().toLower(); + return parseKeyedMap(tagsValue.toObject(), key, context, errorMessage); + } + + bool allArrays = !object.isEmpty(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (!it.value().isArray()) { + allArrays = false; + break; + } + } + if (allArrays) { + return parseKeyedMap(object, QString(), context, errorMessage); + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("Unrecognized tag database format."); + } + return false; +} + +bool parseJsonArray(const QJsonArray &array, ParseContext &context, QString *errorMessage) +{ + if (array.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (const QJsonValue &value : array) { + if (!value.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database array must contain only objects."); + } + return false; + } + if (!handleObject(value.toObject(), context, errorMessage)) { + return false; + } + } + return true; +} + +bool parseJsonLines(const QByteArray &payload, ParseContext &context, QString *errorMessage) +{ + const QList lines = payload.split('\n'); + bool parsedAny = false; + + for (int index = 0; index < lines.size(); ++index) { + const QByteArray line = lines.at(index).trimmed(); + if (line.isEmpty()) { + continue; + } + + QJsonParseError lineError{}; + const QJsonDocument lineDocument = QJsonDocument::fromJson(line, &lineError); + if (lineError.error != QJsonParseError::NoError || !lineDocument.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Malformed tag database at line %1: %2") + .arg(index + 1) + .arg(lineError.errorString()); + } + return false; + } + if (!handleObject(lineDocument.object(), context, errorMessage)) { + return false; + } + parsedAny = true; + } + + if (!parsedAny) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + return true; +} + +QHash sortedLists(const TagSets &sets) +{ + QHash result; + result.reserve(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + if (it.value().isEmpty()) { + continue; + } + QStringList slugs = it.value().values(); + slugs.sort(); + result.insert(it.key(), slugs); + } + return result; +} + +} // namespace + +std::shared_ptr +TagDatabase::loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage) +{ + if (errorMessage) { + errorMessage->clear(); + } + + if (data.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + QByteArray payload = data; + if (!decompressData(payload, errorMessage)) { + return nullptr; + } + if (payload.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + ParseContext context; + context.includeParentTags = options.includeParentTags; + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error == QJsonParseError::NoError) { + if (document.isArray()) { + if (!parseJsonArray(document.array(), context, errorMessage)) { + return nullptr; + } + } else if (document.isObject()) { + if (!handleObject(document.object(), context, errorMessage)) { + return nullptr; + } + } else { + if (errorMessage) { + *errorMessage = tr("The tag database is not a JSON object or array."); + } + return nullptr; + } + } else if (payload.trimmed().startsWith('[')) { + // A broken array is not JSONL; report the parser error instead of + // blaming an arbitrary line. + if (errorMessage) { + *errorMessage = tr("Malformed tag database: %1").arg(parseError.errorString()); + } + return nullptr; + } else if (!parseJsonLines(payload, context, errorMessage)) { + return nullptr; + } + + if (context.includeParentTags) { + // Expand upward only after every tag is known: a parent object may + // appear after its children in the file. + for (auto it = context.tagsById.constBegin(); it != context.tagsById.constEnd(); ++it) { + QStringList slugs{it->slug}; + QSet visited; + QList pending = it->parentIds; + + while (!pending.isEmpty()) { + const QString parentId = pending.takeLast(); + if (visited.contains(parentId)) { + continue; + } + visited.insert(parentId); + + const auto parent = context.tagsById.constFind(parentId); + if (parent == context.tagsById.constEnd()) { + continue; + } + slugs.append(parent->slug); + pending.append(parent->parentIds); + } + + for (const QString &oracleId : it->oracleIds) { + addTags(context.oracleTags, oracleId, slugs); + } + } + } + + auto database = std::make_shared(); + database->tagsByOracleId = sortedLists(context.oracleTags); + database->tagsByName = sortedLists(context.nameTags); + + QSet distinctSlugs; + for (const QStringList &slugs : database->tagsByOracleId) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + for (const QStringList &slugs : database->tagsByName) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + database->distinctTagCount = distinctSlugs.size(); + + if (database->isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database contains no usable tags."); + } + return nullptr; + } + return database; +} + +QStringList TagDatabase::tagsForCard(const QString &oracleId, const QString &name) const +{ + QStringList result; + if (!oracleId.isEmpty()) { + result = tagsByOracleId.value(oracleId.trimmed().toLower()); + } + + const QString normalizedCardName = normalizedName(name); + if (!normalizedCardName.isEmpty()) { + const QStringList byName = tagsByName.value(normalizedCardName); + for (const QString &slug : byName) { + if (!result.contains(slug)) { + result.append(slug); + } + } + } + + if (!result.isEmpty()) { + result.sort(); + } + return result; +} + +bool TagDatabase::isEmpty() const +{ + return tagsByOracleId.isEmpty() && tagsByName.isEmpty(); +} diff --git a/oracle/src/tag_database.h b/oracle/src/tag_database.h new file mode 100644 index 000000000..5874000f7 --- /dev/null +++ b/oracle/src/tag_database.h @@ -0,0 +1,69 @@ +#ifndef TAG_DATABASE_H +#define TAG_DATABASE_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Tag slugs keyed by Scryfall oracle id and/or card name. + * + * Loaded from Scryfall's Oracle Tags bulk data or from a user-supplied tag + * database. The data is import-time only: Oracle applies the slugs to the + * `tags` card property before writing cards.xml, so the client needs no + * tag-specific code. + * + * Accepted inputs, auto-detected: + * - Scryfall tag objects, either a JSON array or one object per JSONL line; + * - a flat map of `oracle_id` (or card name) to an array of slugs; + * - a manifest object `{"key": "oracle_id"|"name", "tags": { ... }}`; + * - JSONL rows `{"oracle_id": "...", "tags": [...]}` or the `name` equivalent. + * + * Gzip, xz and zip inputs are decompressed transparently when the build has + * the matching library. Scryfall serves its bulk data as gzipped JSONL. + */ +class TagDatabase +{ + Q_DECLARE_TR_FUNCTIONS(TagDatabase) +public: + struct LoadOptions + { + bool includeParentTags = false; + }; + + /** + * @brief Parses and decompresses a tag database. + * @return The database, or nullptr with @p errorMessage set on failure. + */ + static std::shared_ptr + loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage = nullptr); + + /** + * @brief Returns the sorted slugs for one card. + * + * Oracle ids are the primary key. A name-keyed database is consulted as a + * fallback, case-insensitively. An empty list means the card has no tags. + */ + [[nodiscard]] QStringList tagsForCard(const QString &oracleId, const QString &name) const; + [[nodiscard]] bool isEmpty() const; + /** @brief Number of distinct slugs in the database. */ + [[nodiscard]] int tagCount() const + { + return distinctTagCount; + } + /** @brief Number of cards that received at least one tag. */ + [[nodiscard]] int taggedCardCount() const + { + return tagsByOracleId.size() + tagsByName.size(); + } + +private: + QHash tagsByOracleId; + QHash tagsByName; ///< keys are lowercased + int distinctTagCount = 0; +}; + +#endif // TAG_DATABASE_H diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 88058278f..8cea24950 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -203,6 +204,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 class SetQuery : public ::testing::Test diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 1cc1ad952..3f657412b 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,3 +1,35 @@ +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the tag database +# loader can decompress whatever format the tag source serves, and the benchmark can +# download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA +# reach every test target below regardless of where they are declared. +set(_ORACLE_COMPRESSION_SOURCES) +set(_ORACLE_COMPRESSION_LIBRARIES) + +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_COMPRESSION_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_COMPRESSION_SOURCES}) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; gzip/zip tag databases and the zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz tag databases and the xz download benchmark disabled") +endif() + cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) # Oracle importer unit tests @@ -9,43 +41,30 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp ../../oracle/src/database_writer.cpp oracle_importer_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} +) + +# Tag database format detection, parsing and parent expansion +cockatrice_add_test( + NAME + tag_database_test + SOURCES + ../../oracle/src/tag_database.cpp + tag_database_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} ) # Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also -# reach the two unit tests above. That is harmless: those macros gate code only in -# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - cockatrice_add_manual_test( NAME oracle_importer_benchmark_test @@ -54,12 +73,13 @@ cockatrice_add_manual_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp oracle_importer_benchmark_test.cpp - ${_ORACLE_BENCH_EXTRA_SOURCES} + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces EXTRA_LIBS - ${_ORACLE_BENCH_EXTRA_LIBRARIES} + ${_ORACLE_COMPRESSION_LIBRARIES} MANUAL ) diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 1b4e2d915..84995797d 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../../oracle/src/tag_database.h" #include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" @@ -546,6 +547,158 @@ 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"); +} + +// ============================================================================ +// Tag database join tests +// ============================================================================ + +TEST_F(OracleImporterTest, AppliesTagDatabaseByOracleId) +{ + QJsonObject card = makeCard("Tagged By Id"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Tagged By Id"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, AppliesTagDatabaseByNameWhenOracleIdIsMissing) +{ + QJsonObject card = makeCard("Lightning Bolt"); + + QString error; + const auto database = TagDatabase::loadFromData(R"json({"Lightning Bolt": ["burn", "removal"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "burn removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseUnionsWithSourceTags) +{ + QJsonObject card = makeCard("Union Card"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + card["tags"] = QJsonArray{"ramp"}; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Union Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseSplitFacesUnionTheirTags) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + QJsonObject identifiers1 = face1["identifiers"].toObject(); + identifiers1["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + face1["identifiers"] = identifiers1; + + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + QJsonObject identifiers2 = face2["identifiers"].toObject(); + identifiers2["scryfallOracleId"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + face2["identifiers"] = identifiers2; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": ["card-advantage"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{face1, face2}); + + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "card-advantage removal"); +} + // ============================================================================ // Card deduplication tests // ============================================================================ diff --git a/tests/oracle/tag_database_test.cpp b/tests/oracle/tag_database_test.cpp new file mode 100644 index 000000000..92b4013a2 --- /dev/null +++ b/tests/oracle/tag_database_test.cpp @@ -0,0 +1,254 @@ +#include "../../oracle/src/tag_database.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include + +#ifdef HAS_ZLIB +#include +#endif + +namespace +{ +const QString ORACLE_A = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +const QString ORACLE_B = QStringLiteral("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + +std::shared_ptr load(const QByteArray &data, + const TagDatabase::LoadOptions &options = TagDatabase::LoadOptions{}, + QString *errorMessage = nullptr) +{ + return TagDatabase::loadFromData(data, options, errorMessage); +} + +QString keyedByOracleId(const QString &oracleId, const QString &slugs) +{ + return QStringLiteral("{\"%1\": [%2]}").arg(oracleId, slugs); +} + +#ifdef HAS_ZLIB +QByteArray gzipCompress(const QByteArray &input) +{ + z_stream stream{}; + if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) { + return {}; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + QByteArray output; + char buffer[4096]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = deflate(&stream, Z_FINISH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + + deflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray(); +} +#endif +} // namespace + +TEST(TagDatabaseTest, ParsesScryfallJsonLines) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"ramp","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"},{"oracle_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","weight":"strong"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagCount(), 2); + EXPECT_EQ(database->taggedCardCount(), 2); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList({"ramp"})); +} + +TEST(TagDatabaseTest, ParsesScryfallJsonArray) +{ + const QByteArray data = R"json([ +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +])json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, IgnoresNonOracleTagTypes) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"art-only","type":"art","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, ParsesFlatOracleIdMap) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("Removal", "removal", " ramp ")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Mixed case, duplicates and surrounding whitespace are normalized. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A.toUpper(), QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList()); +} + +TEST(TagDatabaseTest, ParsesFlatNameMap) +{ + const QByteArray data = R"json({"Lightning Bolt": ["removal", "burn"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Name lookups are case-insensitive; an absent oracle id does not block them. + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("lightning bolt")), QStringList({"burn", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, ParsesManifestWithNameKey) +{ + const QByteArray data = + R"json({"version": 2, "key": "name", "tags": {"Lightning Bolt": ["removal"], "zzz-not-a-uuid": ["burn"]}})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("ZZZ-not-a-uuid")), QStringList({"burn"})); +} + +TEST(TagDatabaseTest, ParsesJsonLinesRows) +{ + const QByteArray data = R"json( +{"oracle_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tags": ["removal"]} +{"name": "Lightning Bolt", "tags": ["burn", "removal"]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, MergesOracleIdAndNameMatches) +{ + const QByteArray data = + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "Lightning Bolt": ["burn", "removal"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, DropsSlugsContainingWhitespace) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("two words", "valid")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // A slug with a space would corrupt the space-separated `tags` property. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"valid"})); +} + +TEST(TagDatabaseTest, ParentTagsAreOffByDefault) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, ParentTagsExpandTransitivelyWhenEnabled) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{.includeParentTags = true}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // The child precedes its parents in the file; expansion must not depend on order. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), + QStringList({"tutor", "tutor-creature", "tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, RejectsEmptyData) +{ + QString error; + EXPECT_FALSE(load(QByteArray(), TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsMalformedJson) +{ + QString error; + EXPECT_FALSE(load("not a tag database", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsTagListsThatAreNotArrays) +{ + QString error; + EXPECT_FALSE(load(R"json({"tags": {"Lightning Bolt": "removal"}})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsDatabaseWithoutUsableTags) +{ + QString error; + EXPECT_FALSE(load(R"json({"Lightning Bolt": []})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +#ifdef HAS_ZLIB +TEST(TagDatabaseTest, DecompressesGzipPayloads) +{ + const QByteArray plain = keyedByOracleId(ORACLE_A, R"("removal")").toUtf8(); + const QByteArray compressed = gzipCompress(plain); + ASSERT_FALSE(compressed.isEmpty()); + ASSERT_EQ(compressed.left(2), QByteArray::fromHex("1f8b")); + + QString error; + const auto database = load(compressed, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} +#endif
%1%2
%1%2
%1%2