Cockatrice/oracle/src/oraclewizard.cpp
BruebachL 6138fbd0a0
[Card] Add Scryfall Tagger tag search and filtering (#7337)
* [Card] Import Scryfall Tagger tags from MTGJson

* [Card] Search cards by Scryfall Tagger tag

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

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

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

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

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

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

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

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

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

* [Help] Describe tag search without naming Scryfall

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

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

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

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

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

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

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

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

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-08 20:57:45 +01:00

230 lines
6.5 KiB
C++

#include "oraclewizard.h"
#include "client/settings/cache_settings.h"
#include "database_writer.h"
#include "main.h"
#include "oracleimporter.h"
#include "pages.h"
#include "pagetemplates.h"
#include <QAbstractButton>
#include <QApplication>
#include <QDebug>
#include <QDialog>
#include <QEvent>
#include <QFile>
#include <QIODevice>
#include <QList>
#include <QLocalServer>
#include <QLocalSocket>
#include <QNetworkAccessManager>
#include <QSettings>
#include <QVariant>
#include <QWindow>
#include <QWizardPage>
#include <QtGlobal>
#include <libcockatrice/network/network_manager.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/local_server_name.h>
#include <qlogging.h>
class QWidget;
OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent)
{
#ifdef Q_OS_WIN
setWizardStyle(QWizard::ModernStyle);
#endif
QString oracleSettingsFile = SettingsCache::instance().getSettingsPath() + "oracle.ini";
settings = new QSettings(oracleSettingsFile, QSettings::IniFormat, this);
// We moved the oracle-specific settings from global.ini to a separate oracle.ini after 2.10
if (!QFile::exists(oracleSettingsFile)) {
migrateOracleSettings();
}
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage);
importer = new OracleImporter(this);
// Import card text in the language the client displays, if supported:
// foreignData for any other language is never imported.
importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang());
nam = NetworkManagerFactory::create(this);
QList<OracleWizardPage *> pages;
if (!isSpoilersOnly) {
pages << new IntroPage << new LoadSetsPage << new TagsPage << new SaveSetsPage << new LoadTokensPage
<< new OutroPage;
} else {
pages << new LoadSpoilersPage << new OutroPage;
}
for (OracleWizardPage *page : pages) {
addPage(page);
// Connect background auto-advance
connect(page, &OracleWizardPage::readyToContinue, this, [this]() {
if (backgroundMode) {
next();
}
});
}
retranslateUi();
startRaiseServer();
}
/**
* Migrates the oracle-specific settings from global.ini to oracle.ini
*/
void OracleWizard::migrateOracleSettings()
{
QString filePath = SettingsCache::instance().getSettingsPath() + "global.ini";
auto globalSettings = QSettings(filePath, QSettings::IniFormat, this);
auto tryMigrateValue = [this, &globalSettings](const QString &name) {
QVariant variant = globalSettings.value(name);
if (variant.isValid()) {
settings->setValue(name, variant.toString());
}
};
tryMigrateValue("allsetsurl");
tryMigrateValue("tokensurl");
tryMigrateValue("spoilersurl");
}
void OracleWizard::updateLanguage()
{
qApp->removeTranslator(translator);
installNewTranslator();
}
void OracleWizard::changeEvent(QEvent *event)
{
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
QDialog::changeEvent(event);
}
void OracleWizard::retranslateUi()
{
setWindowTitle(tr("Oracle Importer"));
for (int i = 0; i < pageIds().count(); i++) {
dynamic_cast<OracleWizardPage *>(page(i))->retranslateUi();
}
}
void OracleWizard::accept()
{
QDialog::accept();
}
void OracleWizard::reject()
{
cancelled = true;
// The wizard is being closed while a page may still run a worker on the
// importer. Ask it to stop before the wizard (and the importer child) is
// destroyed, so the worker thread never touches freed memory.
if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) {
active->cancelWork();
}
const auto replies = nam->findChildren<QNetworkReply *>();
for (QNetworkReply *reply : replies) {
reply->abort();
}
QWizard::reject();
// A hidden wizard does not close the application's last visible window.
// Stop background refreshes explicitly so the hosting client receives a verdict,
// using the same rule as main(): a reject after the database was written is still
// a successful update, only a reject that wrote nothing counts as a failure.
if (backgroundMode) {
QCoreApplication::exit(hasSavedDatabase() ? 0 : 1);
}
}
void OracleWizard::runInBackground()
{
backgroundMode = true;
hide();
currentPage()->initializePage();
}
void OracleWizard::startRaiseServer()
{
// Scope the socket name to the current user, mirroring the hosting client.
raiseServer = new QLocalServer(this);
connect(raiseServer, &QLocalServer::newConnection, this, &OracleWizard::handleRaiseRequest);
const QString serverName = scopedLocalServerName(QStringLiteral("CockatriceOracleRaise"));
if (raiseServer->listen(serverName)) {
return;
}
// A stale socket left behind by a crashed run holds the name: take it back.
QLocalServer::removeServer(serverName);
if (!raiseServer->listen(serverName)) {
qWarning() << "Oracle: could not listen for raise requests on" << serverName;
delete raiseServer;
raiseServer = nullptr;
}
}
void OracleWizard::handleRaiseRequest()
{
while (raiseServer && raiseServer->hasPendingConnections()) {
QLocalSocket *socket = raiseServer->nextPendingConnection();
connect(socket, &QLocalSocket::readyRead, this, [this, socket]() {
socket->readAll();
socket->deleteLater();
raiseWizard();
});
connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater);
}
raiseWizard();
}
void OracleWizard::raiseWizard()
{
// An ongoing background run is hidden; the user asked to see it, so promote it to a window.
if (backgroundMode && !isVisible()) {
show();
}
if (isMinimized()) {
showNormal();
}
raise();
activateWindow();
if (windowHandle()) {
windowHandle()->requestActivate();
}
}
void OracleWizard::enableButtons()
{
button(QWizard::NextButton)->setDisabled(false);
button(QWizard::BackButton)->setDisabled(false);
}
void OracleWizard::disableButtons()
{
button(QWizard::NextButton)->setDisabled(true);
button(QWizard::BackButton)->setDisabled(true);
}
bool OracleWizard::saveTokensToFile(const QString &fileName)
{
QString error;
if (!OracleDatabase::saveXml(fileName, tokensData, &error)) {
qWarning() << error;
return false;
}
return true;
}