mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-09 11:33:25 -08:00
* [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>
1537 lines
49 KiB
C++
1537 lines
49 KiB
C++
#include "pages.h"
|
|
|
|
#include "client/settings/cache_settings.h"
|
|
#include "database_writer.h"
|
|
#include "main.h"
|
|
#include "oracleimporter.h"
|
|
#include "oraclewizard.h"
|
|
#include "pagetemplates.h"
|
|
#include "tag_database.h"
|
|
#include "version_string.h"
|
|
|
|
#include <QBuffer>
|
|
#include <QCheckBox>
|
|
#include <QComboBox>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QGridLayout>
|
|
#include <QIODevice>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonParseError>
|
|
#include <QJsonValue>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QList>
|
|
#include <QMap>
|
|
#include <QMessageBox>
|
|
#include <QNetworkAccessManager>
|
|
#include <QNetworkReply>
|
|
#include <QNetworkRequest>
|
|
#include <QObject>
|
|
#include <QPointer>
|
|
#include <QProgressBar>
|
|
#include <QPushButton>
|
|
#include <QRadioButton>
|
|
#include <QScrollBar>
|
|
#include <QSettings>
|
|
#include <QSharedPointer>
|
|
#include <QSizePolicy>
|
|
#include <QTextEdit>
|
|
#include <QTextStream>
|
|
#include <QTimer>
|
|
#include <QUrl>
|
|
#include <QVariant>
|
|
#include <QWizard>
|
|
#include <QtConcurrentRun>
|
|
#include <QtGlobal>
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <functional>
|
|
#include <libcockatrice/settings/personal_settings.h>
|
|
#include <libcockatrice/utility/translation_loader.h>
|
|
#include <qlogging.h>
|
|
#include <qnamespace.h>
|
|
#include <qnumeric.h>
|
|
#include <stdlib.h>
|
|
#include <tuple>
|
|
#include <utility>
|
|
|
|
class QWidget;
|
|
|
|
#ifdef HAS_LZMA
|
|
#include "lzma/decompress.h"
|
|
#endif
|
|
|
|
#ifdef HAS_ZLIB
|
|
#include "zip/unzip.h"
|
|
#endif
|
|
|
|
#define ZIP_SIGNATURE "PK"
|
|
// Xz stream header: 0xFD + "7zXZ"
|
|
#define XZ_SIGNATURE "\xFD\x37\x7A\x58\x5A"
|
|
#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"
|
|
#elif defined(HAS_ZLIB)
|
|
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.zip"
|
|
#else
|
|
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json"
|
|
#endif
|
|
|
|
/**
|
|
* @brief Emits one machine-readable progress line to stdout.
|
|
*
|
|
* The hosting Cockatrice client parses these lines to drive a determinate
|
|
* progress bar. stderr stays reserved for human-readable log output.
|
|
*/
|
|
static void emitProgress(const char *stage, qint64 done, qint64 total)
|
|
{
|
|
QTextStream out(stdout);
|
|
out << "PROGRESS " << stage << ' ' << done << ' ' << total << '\n';
|
|
out.flush();
|
|
}
|
|
|
|
namespace
|
|
{
|
|
|
|
/**
|
|
* @brief Decompresses and dispatches a sets-file payload on a worker thread.
|
|
*
|
|
* Iteratively unwraps xz/zip compression, then either hands the JSON to the
|
|
* importer (which reports scan progress via dataReadProgress) or returns the raw
|
|
* XML for the plain-XML path. Must never touch the wizard or the page: the caller
|
|
* consumes the returned LoadSetsResult on the UI thread in importFinished().
|
|
*/
|
|
LoadSetsResult loadSetsData(const QPointer<OracleImporter> &importer, QByteArray data)
|
|
{
|
|
LoadSetsResult result;
|
|
|
|
while (true) {
|
|
if (data.startsWith(XZ_SIGNATURE)) {
|
|
#ifdef HAS_LZMA
|
|
QBuffer inBuffer(&data);
|
|
QByteArray decompressed;
|
|
QBuffer outBuffer(&decompressed);
|
|
inBuffer.open(QBuffer::ReadOnly);
|
|
outBuffer.open(QBuffer::WriteOnly);
|
|
XzDecompressor xz;
|
|
if (!xz.decompress(&inBuffer, &outBuffer)) {
|
|
result.errorMessage = LoadSetsPage::tr("Xz extraction failed.");
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
}
|
|
data = decompressed;
|
|
continue;
|
|
#else
|
|
result.errorMessage =
|
|
LoadSetsPage::tr("Sorry, this version of Oracle does not support xz compressed files.");
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
#endif
|
|
}
|
|
|
|
if (data.startsWith(ZIP_SIGNATURE)) {
|
|
#ifdef HAS_ZLIB
|
|
QBuffer inBuffer(&data);
|
|
UnZip uz;
|
|
const UnZip::ErrorCode openEc = uz.openArchive(&inBuffer);
|
|
if (openEc != UnZip::Ok) {
|
|
result.errorMessage = LoadSetsPage::tr("Failed to open Zip archive: %1.").arg(uz.formatError(openEc));
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
}
|
|
if (uz.fileList().size() != 1) {
|
|
result.errorMessage =
|
|
LoadSetsPage::tr("Zip extraction failed: the Zip archive doesn't contain exactly one file.");
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
}
|
|
const QString fileName = uz.fileList().at(0);
|
|
QByteArray decompressed;
|
|
QBuffer outBuffer(&decompressed);
|
|
outBuffer.open(QBuffer::ReadWrite);
|
|
const UnZip::ErrorCode ec = uz.extractFile(fileName, &outBuffer);
|
|
uz.closeArchive();
|
|
if (ec != UnZip::Ok) {
|
|
result.errorMessage = LoadSetsPage::tr("Zip extraction failed: %1.").arg(uz.formatError(ec));
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
}
|
|
data = decompressed;
|
|
continue;
|
|
#else
|
|
result.errorMessage = LoadSetsPage::tr("Sorry, this version of Oracle does not support zipped files.");
|
|
result.offerUncompressedFallback = true;
|
|
return result;
|
|
#endif
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Test the first non-space byte in place: trimmed() would duplicate the whole
|
|
// payload, which on the plain-XML path is the largest download we handle.
|
|
const auto firstNonSpace = std::find_if_not(data.cbegin(), data.cend(),
|
|
[](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; });
|
|
const bool looksLikeXml = firstNonSpace != data.cend() && *firstNonSpace == '<';
|
|
if (looksLikeXml) {
|
|
result.errorMessage = OracleDatabase::validateXml(data);
|
|
if (!result.errorMessage.isEmpty()) {
|
|
return result;
|
|
}
|
|
result.ok = true;
|
|
result.plainXml = true;
|
|
result.xmlData = std::move(data);
|
|
return result;
|
|
}
|
|
|
|
if (data.startsWith("{")) {
|
|
result.ok = importer && importer->readSetsFromByteArray(std::move(data));
|
|
return result;
|
|
}
|
|
|
|
result.errorMessage = LoadSetsPage::tr("Failed to interpret downloaded data.");
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
#define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml"
|
|
#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"
|
|
|
|
IntroPage::IntroPage(QWidget *parent) : OracleWizardPage(parent)
|
|
{
|
|
label = new QLabel(this);
|
|
label->setWordWrap(true);
|
|
|
|
languageLabel = new QLabel(this);
|
|
versionLabel = new QLabel(this);
|
|
languageBox = new QComboBox(this);
|
|
|
|
QStringList languageCodes = findQmFiles();
|
|
for (const QString &code : languageCodes) {
|
|
languageBox->addItem(TranslationLoader::languageDisplayName(code), code);
|
|
}
|
|
|
|
// Select the language the wizard runs with: its own entry, the base language for a variant without a
|
|
// file of its own (de_CH -> de), and the default language for an unset setting, which names no file.
|
|
const QString configuredLanguage = SettingsCache::instance().personal().getLang();
|
|
int index = languageBox->findData(configuredLanguage);
|
|
if (index < 0) {
|
|
index = languageBox->findData(configuredLanguage.section('_', 0, 0));
|
|
}
|
|
if (index < 0) {
|
|
index = languageBox->findData(TranslationLoader::defaultLanguageCode());
|
|
}
|
|
if (index >= 0) {
|
|
languageBox->setCurrentIndex(index);
|
|
} else {
|
|
qWarning() << "could not find language" << configuredLanguage;
|
|
}
|
|
|
|
connect(languageBox, qOverload<int>(&QComboBox::currentIndexChanged), this, &IntroPage::languageBoxChanged);
|
|
|
|
auto *layout = new QGridLayout(this);
|
|
layout->addWidget(label, 0, 0, 1, 2);
|
|
layout->addWidget(languageLabel, 1, 0);
|
|
layout->addWidget(languageBox, 1, 1);
|
|
layout->addWidget(versionLabel, 2, 0, 1, 2);
|
|
|
|
setLayout(layout);
|
|
}
|
|
|
|
void IntroPage::initializePage()
|
|
{
|
|
if (wizard()->backgroundMode) {
|
|
emit readyToContinue();
|
|
}
|
|
}
|
|
|
|
QStringList IntroPage::findQmFiles()
|
|
{
|
|
return TranslationLoader::availableLanguages(translationPrefix,
|
|
TranslationLoader::applicationTranslationPaths(translationPath));
|
|
}
|
|
|
|
void IntroPage::languageBoxChanged(int index)
|
|
{
|
|
SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString());
|
|
}
|
|
|
|
void IntroPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Introduction"));
|
|
label->setText(tr("This wizard will import the list of sets, cards, and tokens "
|
|
"that will be used by Cockatrice."));
|
|
languageLabel->setText(tr("Interface language:"));
|
|
versionLabel->setText(tr("Version:") + QString(" %1").arg(VERSION_STRING));
|
|
}
|
|
|
|
void OutroPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Finished"));
|
|
setSubTitle(tr("The wizard has finished.") + "<br>" +
|
|
tr("You can now start using Cockatrice with the newly updated cards.") + "<br><br>" +
|
|
tr("If the card databases don't reload automatically, restart the Cockatrice client."));
|
|
}
|
|
|
|
void OutroPage::initializePage()
|
|
{
|
|
if (wizard()->backgroundMode) {
|
|
wizard()->accept();
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
LoadSetsPage::LoadSetsPage(QWidget *parent) : OracleWizardPage(parent)
|
|
{
|
|
urlRadioButton = new QRadioButton(this);
|
|
fileRadioButton = new QRadioButton(this);
|
|
|
|
urlLineEdit = new QLineEdit(this);
|
|
fileLineEdit = new QLineEdit(this);
|
|
|
|
progressLabel = new QLabel(this);
|
|
progressBar = new QProgressBar(this);
|
|
|
|
urlRadioButton->setChecked(true);
|
|
|
|
urlButton = new QPushButton(this);
|
|
connect(urlButton, &QPushButton::clicked, this, &LoadSetsPage::actRestoreDefaultUrl);
|
|
|
|
fileButton = new QPushButton(this);
|
|
connect(fileButton, &QPushButton::clicked, this, &LoadSetsPage::actLoadSetsFile);
|
|
|
|
auto *layout = new QGridLayout(this);
|
|
layout->addWidget(urlRadioButton, 0, 0);
|
|
layout->addWidget(urlLineEdit, 0, 1);
|
|
layout->addWidget(urlButton, 1, 1, Qt::AlignRight);
|
|
layout->addWidget(fileRadioButton, 2, 0);
|
|
layout->addWidget(fileLineEdit, 2, 1);
|
|
layout->addWidget(fileButton, 3, 1, Qt::AlignRight);
|
|
layout->addWidget(progressLabel, 4, 0);
|
|
layout->addWidget(progressBar, 4, 1);
|
|
|
|
connect(&watcher, &QFutureWatcher<bool>::finished, this, &LoadSetsPage::importFinished);
|
|
|
|
setLayout(layout);
|
|
}
|
|
|
|
bool LoadSetsPage::isComplete() const
|
|
{
|
|
return !loadActive;
|
|
}
|
|
|
|
void LoadSetsPage::initializePage()
|
|
{
|
|
urlLineEdit->setText(wizard()->settings->value("allsetsurl", ALLSETS_URL).toString());
|
|
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
|
|
if (wizard()->backgroundMode) {
|
|
if (isEnabled()) {
|
|
validatePage();
|
|
}
|
|
}
|
|
}
|
|
|
|
void LoadSetsPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Source selection"));
|
|
setSubTitle(tr("Please specify a compatible source for the list of sets and cards. "
|
|
"You can specify a URL address that will be downloaded or "
|
|
"use an existing file from your computer."));
|
|
|
|
urlRadioButton->setText(tr("Download URL:"));
|
|
fileRadioButton->setText(tr("Local file:"));
|
|
urlButton->setText(tr("Restore default URL"));
|
|
fileButton->setText(tr("Choose file..."));
|
|
}
|
|
|
|
void LoadSetsPage::actRestoreDefaultUrl()
|
|
{
|
|
urlLineEdit->setText(ALLSETS_URL);
|
|
}
|
|
|
|
void LoadSetsPage::actLoadSetsFile()
|
|
{
|
|
QFileDialog dialog(this, tr("Load sets file"));
|
|
dialog.setFileMode(QFileDialog::ExistingFile);
|
|
|
|
QString extensions = "*.json *.xml";
|
|
#ifdef HAS_ZLIB
|
|
extensions += " *.zip";
|
|
#endif
|
|
#ifdef HAS_LZMA
|
|
extensions += " *.xz";
|
|
#endif
|
|
dialog.setNameFilter(tr("Sets file (%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));
|
|
}
|
|
|
|
bool LoadSetsPage::validatePage()
|
|
{
|
|
// once the import is finished, we call next(); skip validation
|
|
if (wizard()->downloadedPlainXml || wizard()->importer->getSets().count() > 0) {
|
|
return true;
|
|
}
|
|
|
|
// else, try to import sets
|
|
if (urlRadioButton->isChecked()) {
|
|
// If a user attempts to download from V4, redirect them to V5
|
|
if (urlLineEdit->text().contains(MTGJSON_V4_URL_COMPONENT)) {
|
|
actRestoreDefaultUrl();
|
|
}
|
|
|
|
const auto url = QUrl::fromUserInput(urlLineEdit->text());
|
|
|
|
if (!url.isValid()) {
|
|
QMessageBox::critical(this, tr("Error"), tr("The provided URL is not valid."));
|
|
return false;
|
|
}
|
|
|
|
progressLabel->setText(tr("Downloading (0 MB)"));
|
|
// show an infinite progressbar
|
|
progressBar->setMaximum(0);
|
|
progressBar->setMinimum(0);
|
|
progressBar->setValue(0);
|
|
progressLabel->show();
|
|
progressBar->show();
|
|
|
|
wizard()->disableButtons();
|
|
setEnabled(false);
|
|
|
|
downloadSetsFile(url);
|
|
} else if (fileRadioButton->isChecked()) {
|
|
QFile setsFile(fileLineEdit->text());
|
|
if (!setsFile.exists()) {
|
|
QMessageBox::critical(this, tr("Error"), tr("Please choose a file."));
|
|
return false;
|
|
}
|
|
|
|
wizard()->disableButtons();
|
|
setEnabled(false);
|
|
|
|
wizard()->setCardSourceUrl(setsFile.fileName());
|
|
wizard()->setCardSourceVersion("unknown");
|
|
|
|
readSetsFromFile(setsFile.fileName());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void LoadSetsPage::downloadSetsFile(const QUrl &url)
|
|
{
|
|
wizard()->setCardSourceVersion("unknown");
|
|
|
|
const auto urlString = url.toString();
|
|
if (urlString == ALLSETS_URL || urlString == ALLSETS_URL_FALLBACK) {
|
|
const auto versionUrl = QUrl::fromUserInput(MTGJSON_VERSION_URL);
|
|
QNetworkRequest request = QNetworkRequest(versionUrl);
|
|
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
|
|
auto *versionReply = wizard()->nam->get(request);
|
|
connect(versionReply, &QNetworkReply::finished, [this, versionReply]() {
|
|
if (versionReply->error() == QNetworkReply::NoError) {
|
|
auto data = versionReply->readAll();
|
|
QJsonParseError jsonError{};
|
|
auto jsonResponse = QJsonDocument::fromJson(data, &jsonError);
|
|
|
|
if (jsonError.error == QJsonParseError::NoError) {
|
|
const auto jsonMap = jsonResponse.toVariant().toMap();
|
|
|
|
auto versionString = jsonMap.value("meta").toMap().value("version").toString();
|
|
if (versionString.isEmpty()) {
|
|
versionString = "unknown";
|
|
}
|
|
wizard()->setCardSourceVersion(versionString);
|
|
}
|
|
}
|
|
|
|
versionReply->deleteLater();
|
|
});
|
|
}
|
|
|
|
wizard()->setCardSourceUrl(url.toString());
|
|
|
|
QNetworkRequest request = QNetworkRequest(url);
|
|
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
|
|
auto *reply = wizard()->nam->get(request);
|
|
|
|
connect(reply, &QNetworkReply::finished, this, &LoadSetsPage::actDownloadFinishedSetsFile);
|
|
connect(reply, &QNetworkReply::downloadProgress, this, &LoadSetsPage::actDownloadProgressSetsFile);
|
|
}
|
|
|
|
void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total)
|
|
{
|
|
emitProgress("download", received, total);
|
|
if (total > 0) {
|
|
progressBar->setMaximum(static_cast<int>(total));
|
|
progressBar->setValue(static_cast<int>(received));
|
|
}
|
|
progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024)));
|
|
}
|
|
|
|
void LoadSetsPage::actDownloadFinishedSetsFile()
|
|
{
|
|
// check for a reply
|
|
auto *reply = dynamic_cast<QNetworkReply *>(sender());
|
|
if (wizard()->wasCancelled()) {
|
|
reply->deleteLater();
|
|
return;
|
|
}
|
|
auto errorCode = reply->error();
|
|
if (errorCode != QNetworkReply::NoError) {
|
|
QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString()));
|
|
|
|
wizard()->enableButtons();
|
|
setEnabled(true);
|
|
|
|
reply->deleteLater();
|
|
return;
|
|
}
|
|
|
|
auto statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
if (statusCode == 301 || statusCode == 302) {
|
|
const auto redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl();
|
|
qDebug() << "following redirect url:" << redirectUrl.toString();
|
|
downloadSetsFile(redirectUrl);
|
|
reply->deleteLater();
|
|
return;
|
|
}
|
|
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
|
|
// save AllPrintings.json url, but only if the user customized it and download was successful
|
|
if (urlLineEdit->text() != QString(ALLSETS_URL)) {
|
|
wizard()->settings->setValue("allsetsurl", urlLineEdit->text());
|
|
} else {
|
|
wizard()->settings->remove("allsetsurl");
|
|
}
|
|
|
|
readSetsFromByteArray(reply->readAll());
|
|
reply->deleteLater();
|
|
}
|
|
|
|
void LoadSetsPage::updateParsingProgress(int bytesRead, int totalBytes)
|
|
{
|
|
if (totalBytes <= 0) {
|
|
return;
|
|
}
|
|
progressBar->setRange(0, totalBytes);
|
|
progressBar->setValue(bytesRead);
|
|
const int percent = static_cast<int>((100.0 * bytesRead) / totalBytes);
|
|
progressLabel->setText(tr("Parsing file (%1%)").arg(percent));
|
|
}
|
|
|
|
void LoadSetsPage::scanProgressToStdout(int bytesRead, int totalBytes)
|
|
{
|
|
emitProgress("scan", bytesRead, totalBytes);
|
|
}
|
|
|
|
void LoadSetsPage::beginLoadSets(bool compressedFile)
|
|
{
|
|
// Show an infinite progressbar while the worker decompresses; the scan
|
|
// steals the label via dataReadProgress as soon as it starts.
|
|
progressBar->setMaximum(0);
|
|
progressBar->setMinimum(0);
|
|
progressBar->setValue(0);
|
|
progressLabel->setText(compressedFile ? tr("Extracting file...") : tr("Parsing file"));
|
|
progressLabel->show();
|
|
progressBar->show();
|
|
|
|
// Keep Next disabled (via completeChanged) until the worker reports in;
|
|
// updateButtonStates() re-evaluates button state whenever we re-enable.
|
|
loadActive = true;
|
|
emit completeChanged();
|
|
|
|
wizard()->downloadedPlainXml = false;
|
|
wizard()->xmlData.clear();
|
|
|
|
if (wizard()->backgroundMode) {
|
|
connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::scanProgressToStdout,
|
|
Qt::UniqueConnection);
|
|
} else {
|
|
connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::updateParsingProgress,
|
|
Qt::UniqueConnection);
|
|
}
|
|
}
|
|
|
|
void LoadSetsPage::readSetsFromByteArray(QByteArray _data)
|
|
{
|
|
const bool compressed = _data.startsWith(XZ_SIGNATURE) || _data.startsWith(ZIP_SIGNATURE);
|
|
beginLoadSets(compressed);
|
|
|
|
// Decompress and scan off the UI thread so a large download can't freeze the window.
|
|
const QPointer<OracleImporter> importer = wizard()->importer;
|
|
future = QtConcurrent::run(
|
|
[importer, data = std::move(_data)]() mutable { return loadSetsData(importer, std::move(data)); });
|
|
watcher.setFuture(future);
|
|
}
|
|
|
|
void LoadSetsPage::readSetsFromFile(const QString &fileName)
|
|
{
|
|
// Peek at the header on the UI thread so the status text can distinguish
|
|
// "Extracting file..." from a plain JSON parse; the full read happens in the worker.
|
|
QFile headerFile(fileName);
|
|
bool compressed = false;
|
|
if (headerFile.open(QIODevice::ReadOnly)) {
|
|
const QByteArray header = headerFile.read(6);
|
|
compressed = header.startsWith(XZ_SIGNATURE) || header.startsWith(ZIP_SIGNATURE);
|
|
}
|
|
beginLoadSets(compressed);
|
|
|
|
// Read, decompress and scan off the UI thread (a plain JSON can be hundreds
|
|
// of MB, so even the read itself must not block the window).
|
|
const QPointer<OracleImporter> importer = wizard()->importer;
|
|
future = QtConcurrent::run([importer, fileName]() mutable -> LoadSetsResult {
|
|
QFile file(fileName);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
LoadSetsResult readError;
|
|
readError.errorMessage = LoadSetsPage::tr("Cannot open file '%1'.").arg(fileName);
|
|
return readError;
|
|
}
|
|
return loadSetsData(importer, file.readAll());
|
|
});
|
|
watcher.setFuture(future);
|
|
}
|
|
|
|
void LoadSetsPage::cancelWork()
|
|
{
|
|
// The scan is short-lived; just wait it out before the wizard (and its
|
|
// importer) can be torn down underneath the worker thread.
|
|
if (future.isRunning()) {
|
|
future.cancel();
|
|
watcher.cancel();
|
|
future.waitForFinished();
|
|
}
|
|
}
|
|
|
|
void LoadSetsPage::zipDownloadFailed(const QString &message)
|
|
{
|
|
wizard()->enableButtons();
|
|
setEnabled(true);
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
|
|
QMessageBox::StandardButton reply;
|
|
reply = static_cast<QMessageBox::StandardButton>(QMessageBox::question(
|
|
this, tr("Error"), message + "<br>" + tr("Do you want to download the uncompressed file instead?"),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes));
|
|
|
|
if (reply == QMessageBox::Yes) {
|
|
urlRadioButton->setChecked(true);
|
|
urlLineEdit->setText(ALLSETS_URL_FALLBACK);
|
|
|
|
wizard()->next();
|
|
}
|
|
}
|
|
|
|
void LoadSetsPage::importFinished()
|
|
{
|
|
loadActive = false;
|
|
emit completeChanged();
|
|
wizard()->enableButtons();
|
|
setEnabled(true);
|
|
|
|
const LoadSetsResult result = watcher.result();
|
|
|
|
if (result.plainXml) {
|
|
wizard()->downloadedPlainXml = true;
|
|
wizard()->xmlData = result.xmlData;
|
|
}
|
|
|
|
if (wizard()->backgroundMode) {
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
if (!result.errorMessage.isEmpty()) {
|
|
qWarning() << result.errorMessage;
|
|
} else if (!result.ok && !result.plainXml) {
|
|
qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data.");
|
|
}
|
|
if (!result.ok) {
|
|
wizard()->reject();
|
|
return;
|
|
}
|
|
emit readyToContinue();
|
|
return;
|
|
}
|
|
|
|
const auto fail = [this](const QString &message) {
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
QMessageBox::critical(this, tr("Error"), message);
|
|
};
|
|
|
|
if (!result.errorMessage.isEmpty()) {
|
|
if (result.offerUncompressedFallback) {
|
|
zipDownloadFailed(result.errorMessage);
|
|
return;
|
|
}
|
|
fail(result.errorMessage);
|
|
return;
|
|
}
|
|
|
|
if (!result.ok && !result.plainXml) {
|
|
fail(tr("The file was retrieved successfully, but it does not contain any sets data."));
|
|
return;
|
|
}
|
|
|
|
// Snap the bar to 100% and hold it there for a moment so the completed state
|
|
// is actually visible before the next page's own import progress takes over
|
|
// (a zero-length deferral can still fire before the repaint is delivered).
|
|
progressBar->setMaximum(1);
|
|
progressBar->setValue(1);
|
|
progressLabel->setText(tr("Parsing file (100%)"));
|
|
QTimer::singleShot(500, this, [this] {
|
|
if (wizard()->currentPage() == this) {
|
|
// Leave the page pristine: hide the completed load bar so a later
|
|
// Back from the save page doesn't show stale progress.
|
|
progressLabel->hide();
|
|
progressBar->hide();
|
|
wizard()->next();
|
|
}
|
|
});
|
|
}
|
|
|
|
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<LoadResult>::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<QNetworkReply *>(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<int>(total));
|
|
progressBar->setValue(static_cast<int>(received));
|
|
}
|
|
progressLabel->setText(tr("Downloading tag data (%1 MB)").arg(received / (1024 * 1024)));
|
|
}
|
|
|
|
void TagsPage::actTagFileFinished()
|
|
{
|
|
auto *reply = qobject_cast<QNetworkReply *>(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);
|
|
saveLabel = new QLabel(this);
|
|
|
|
progressBar = new QProgressBar(this);
|
|
progressBar->hide();
|
|
|
|
defaultPathCheckBox = new QCheckBox(this);
|
|
|
|
messageLog = new QTextEdit(this);
|
|
messageLog->setReadOnly(true);
|
|
|
|
auto *layout = new QGridLayout(this);
|
|
layout->addWidget(progressBar, 0, 0);
|
|
layout->addWidget(messageLog, 1, 0);
|
|
layout->addWidget(saveLabel, 2, 0);
|
|
layout->addWidget(pathLabel, 3, 0);
|
|
layout->addWidget(defaultPathCheckBox, 4, 0);
|
|
|
|
setLayout(layout);
|
|
}
|
|
|
|
bool SaveSetsPage::isComplete() const
|
|
{
|
|
return !importActive && importReady;
|
|
}
|
|
|
|
void SaveSetsPage::cleanupPage()
|
|
{
|
|
cancelWork();
|
|
disconnect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress);
|
|
disconnect(&importWatcher, &QFutureWatcher<int>::finished, this, &SaveSetsPage::importFinished);
|
|
wizard()->importer->clear();
|
|
}
|
|
|
|
void SaveSetsPage::initializePage()
|
|
{
|
|
importReady = false;
|
|
retranslateUi();
|
|
if (wizard()->downloadedPlainXml) {
|
|
importReady = true;
|
|
emit completeChanged();
|
|
messageLog->hide();
|
|
progressBar->hide();
|
|
if (wizard()->backgroundMode) {
|
|
emit readyToContinue();
|
|
}
|
|
return;
|
|
}
|
|
|
|
messageLog->clear();
|
|
messageLog->show();
|
|
progressBar->show();
|
|
|
|
totalSets = wizard()->importer->getSets().size();
|
|
progressBar->setRange(0, totalSets);
|
|
progressBar->setValue(0);
|
|
|
|
connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress,
|
|
Qt::UniqueConnection);
|
|
connect(&importWatcher, &QFutureWatcher<int>::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();
|
|
|
|
const QPointer<OracleImporter> importer = wizard()->importer;
|
|
if (auto *activeImporter = importer.data()) {
|
|
activeImporter->prepareImport();
|
|
}
|
|
importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; });
|
|
importWatcher.setFuture(importFuture);
|
|
}
|
|
|
|
void SaveSetsPage::cancelWork()
|
|
{
|
|
if (!importActive) {
|
|
return;
|
|
}
|
|
// Ask the worker to stop at the next set boundary, then wait it out so the
|
|
// wizard (and the importer it owns) is never torn down under a running thread.
|
|
importActive = false;
|
|
emit completeChanged();
|
|
wizard()->importer->cancelImport();
|
|
importFuture.cancel();
|
|
importWatcher.cancel();
|
|
importFuture.waitForFinished();
|
|
}
|
|
|
|
void SaveSetsPage::importFinished()
|
|
{
|
|
if (!importActive) {
|
|
return;
|
|
}
|
|
importActive = false;
|
|
emit completeChanged();
|
|
|
|
wizard()->enableButtons();
|
|
|
|
const int setsImported = importWatcher.result();
|
|
const QPointer<OracleImporter> importer = wizard()->importer;
|
|
importReady = importer && importer->hasSuccessfulImport() && setsImported == totalSets;
|
|
const QString error = importer ? importer->getImportError() : tr("The card database importer is unavailable.");
|
|
if (importer) {
|
|
importer->releaseSetData();
|
|
}
|
|
wizard()->button(QWizard::NextButton)->setEnabled(importReady);
|
|
emit completeChanged();
|
|
|
|
if (!importReady) {
|
|
const QString message = error.isEmpty() ? tr("No set has been imported.") : error;
|
|
if (wizard()->backgroundMode) {
|
|
qWarning() << message;
|
|
wizard()->reject();
|
|
} else {
|
|
QMessageBox::critical(this, tr("Error"), message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
progressBar->setValue(progressBar->maximum());
|
|
if (wizard()->backgroundMode) {
|
|
emit readyToContinue();
|
|
}
|
|
}
|
|
|
|
void SaveSetsPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Sets imported"));
|
|
if (wizard()->downloadedPlainXml) {
|
|
setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.")
|
|
.arg(qRound(wizard()->xmlData.size() / 1000000.0)));
|
|
} else {
|
|
setSubTitle(tr("The following sets have been found:"));
|
|
}
|
|
|
|
saveLabel->setText(tr("Press \"Save\" to store the imported cards in the Cockatrice database."));
|
|
pathLabel->setText(tr("The card database will be saved at the following location:") + "<br>" +
|
|
SettingsCache::instance().getCardDatabasePath());
|
|
defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)"));
|
|
|
|
setButtonText(QWizard::NextButton, tr("&Save"));
|
|
}
|
|
|
|
void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QString &setName)
|
|
{
|
|
if (!importActive) {
|
|
return;
|
|
}
|
|
if (setName.isEmpty()) {
|
|
progressBar->setValue(progressBar->maximum());
|
|
const int cardCount = wizard()->importer->getCardList().size();
|
|
if (wizard()->backgroundMode) {
|
|
qInfo() << tr("Import finished: %1 cards.").arg(cardCount);
|
|
} else {
|
|
messageLog->append("<b>" + tr("Import finished: %1 cards.").arg(cardCount) + "</b>");
|
|
}
|
|
emitProgress("import", totalSets, totalSets);
|
|
} else {
|
|
progressBar->setValue(setIndex);
|
|
if (wizard()->backgroundMode) {
|
|
qInfo() << tr("%1: %2 cards imported").arg(setName).arg(cardsImported);
|
|
} else {
|
|
messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported));
|
|
}
|
|
emitProgress("import", setIndex, totalSets);
|
|
}
|
|
|
|
messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum());
|
|
}
|
|
|
|
bool SaveSetsPage::validatePage()
|
|
{
|
|
if (!isComplete()) {
|
|
return false;
|
|
}
|
|
const auto failSave = [this](const QString &message) {
|
|
qWarning() << message;
|
|
if (wizard()->backgroundMode) {
|
|
wizard()->reject();
|
|
} else {
|
|
QMessageBox::critical(this, tr("Error"), message);
|
|
}
|
|
return false;
|
|
};
|
|
QString defaultPath = SettingsCache::instance().getCardDatabasePath();
|
|
QString windowName = tr("Save card database");
|
|
QString fileType = tr("XML; card database (*.xml)");
|
|
|
|
QString fileName;
|
|
if (defaultPathCheckBox->isChecked()) {
|
|
fileName = QFileDialog::getSaveFileName(this, windowName, defaultPath, fileType);
|
|
} else {
|
|
fileName = defaultPath;
|
|
}
|
|
|
|
if (fileName.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
QFileInfo fi(fileName);
|
|
QDir fileDir(fi.path());
|
|
if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
|
|
return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath()));
|
|
}
|
|
|
|
if (wizard()->downloadedPlainXml) {
|
|
QString error;
|
|
if (!OracleDatabase::saveXml(fileName, wizard()->xmlData, &error)) {
|
|
return failSave(tr("The file could not be saved to %1: %2").arg(fileName, error));
|
|
}
|
|
wizard()->xmlData.clear();
|
|
} else if (!wizard()->importer->saveToFile(fileName, wizard()->getCardSourceUrl(),
|
|
wizard()->getCardSourceVersion())) {
|
|
// The importer refuses to write a database whose import did not finish, and
|
|
// reports the reason there; do not dress an import failure up as a disk failure.
|
|
const QString importError = wizard()->importer->getImportError();
|
|
return failSave(importError.isEmpty() ? tr("The file could not be saved to %1").arg(fileName) : importError);
|
|
}
|
|
|
|
wizard()->markDatabaseSaved();
|
|
return true;
|
|
}
|
|
|
|
void LoadTokensPage::initializePage()
|
|
{
|
|
SimpleDownloadFilePage::initializePage();
|
|
|
|
if (wizard()->backgroundMode) {
|
|
emit readyToContinue();
|
|
}
|
|
}
|
|
|
|
QString LoadTokensPage::getDefaultUrl()
|
|
{
|
|
return TOKENS_URL;
|
|
}
|
|
|
|
QString LoadTokensPage::getCustomUrlSettingsKey()
|
|
{
|
|
return "tokensurl";
|
|
}
|
|
|
|
QString LoadTokensPage::getDefaultSavePath()
|
|
{
|
|
return SettingsCache::instance().getTokenDatabasePath();
|
|
}
|
|
|
|
QString LoadTokensPage::getWindowTitle()
|
|
{
|
|
return tr("Save token database");
|
|
}
|
|
|
|
QString LoadTokensPage::getFileType()
|
|
{
|
|
return tr("XML; token database (*.xml)");
|
|
}
|
|
|
|
QString LoadTokensPage::getFilePromptName()
|
|
{
|
|
return tr("tokens");
|
|
}
|
|
|
|
void LoadTokensPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Tokens import"));
|
|
setSubTitle(tr("Please specify a compatible source for token data."));
|
|
|
|
urlRadioButton->setText(tr("Download URL:"));
|
|
fileRadioButton->setText(tr("Local file:"));
|
|
urlButton->setText(tr("Restore default URL"));
|
|
fileButton->setText(tr("Choose file..."));
|
|
|
|
pathLabel->setText(tr("The token database will be saved at the following location:") + "<br>" +
|
|
SettingsCache::instance().getTokenDatabasePath());
|
|
defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)"));
|
|
}
|
|
|
|
QString LoadSpoilersPage::getDefaultUrl()
|
|
{
|
|
return SPOILERS_URL;
|
|
}
|
|
|
|
QString LoadSpoilersPage::getCustomUrlSettingsKey()
|
|
{
|
|
return "spoilersurl";
|
|
}
|
|
|
|
QString LoadSpoilersPage::getDefaultSavePath()
|
|
{
|
|
return SettingsCache::instance().getTokenDatabasePath();
|
|
}
|
|
|
|
QString LoadSpoilersPage::getWindowTitle()
|
|
{
|
|
return tr("Save spoiler database");
|
|
}
|
|
|
|
QString LoadSpoilersPage::getFileType()
|
|
{
|
|
return tr("XML; spoiler database (*.xml)");
|
|
}
|
|
|
|
QString LoadSpoilersPage::getFilePromptName()
|
|
{
|
|
return tr("spoiler");
|
|
}
|
|
|
|
void LoadSpoilersPage::retranslateUi()
|
|
{
|
|
setTitle(tr("Spoilers import"));
|
|
setSubTitle(tr("Please specify a compatible source for spoiler data."));
|
|
|
|
urlRadioButton->setText(tr("Download URL:"));
|
|
fileRadioButton->setText(tr("Local file:"));
|
|
urlButton->setText(tr("Restore default URL"));
|
|
fileButton->setText(tr("Choose file..."));
|
|
|
|
pathLabel->setText(tr("The spoiler database will be saved at the following location:") + "<br>" +
|
|
SettingsCache::instance().getSpoilerCardDatabasePath());
|
|
defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)"));
|
|
}
|