[Client] Add option to load decks from ManaBox (#7642)

* [Client] Add option to load decks from ManaBox

* [Client] Fix PR comments

* [Client] Fix UI and transaltion requests

* [Client] Fix for UI and parser not doing what expected when loading maybe board

* Add ManaBox support to docs

As pointed out in review comments

* [Client] Fix final PR comments

---------

Co-authored-by: tooomm <tooomm@users.noreply.github.com>
This commit is contained in:
PILIX123 2026-11-03 04:33:21 -05:00 • committed by GitHub
parent c5b776ca46
commit 381dd21e8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 126 additions and 14 deletions

View file

@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json";
static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/"; static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/";
static const QString MOXFIELD_SUFFIX = "/"; static const QString MOXFIELD_SUFFIX = "/";
static const QString DECKSTATS_BASE = "https://deckstats.net/decks/";
static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1"; static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1";
static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/";
bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
{ {
static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)"); static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)");
static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)"); static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)");
static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)"); static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)");
static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)"); static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)");
static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)");
QRegularExpressionMatch match; QRegularExpressionMatch match;
@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
return true; return true;
} else if ((match = rxDeckstats.match(url)).hasMatch()) { } else if ((match = rxDeckstats.match(url)).hasMatch()) {
QString deckPath = match.captured(1); QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/", outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE,
.deckID = deckPath, .deckID = deckPath,
.fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX, .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX,
.provider = DeckProvider::Deckstats}; .provider = DeckProvider::Deckstats};
return true; return true;
} else if ((match = rxManabox.match(url)).hasMatch()) {
QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE,
.deckID = deckPath,
.fullUrl = MANABOX_BASE + deckPath,
.provider = DeckProvider::Manabox};
return true;
} }
return false; return false;

View file

@ -15,6 +15,7 @@ enum class DeckProvider
Archidekt, Archidekt,
Moxfield, Moxfield,
Deckstats, Deckstats,
Manabox,
Unknown Unknown
}; };

View file

@ -8,12 +8,19 @@
#define INTERFACE_JSON_DECK_PARSER_H #define INTERFACE_JSON_DECK_PARSER_H
#include "../../../interface/deck_loader/card_node_function.h" #include "../../../interface/deck_loader/card_node_function.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QTextStream>
#include <libcockatrice/card/import/card_name_normalizer.h> #include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser");
class IJsonDeckParser class IJsonDeckParser
{ {
public: public:
@ -32,9 +39,6 @@ public:
QString deckName = obj.value("name").toString(); QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString(); QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText; QString outputText;
QTextStream outStream(&outputText); QTextStream outStream(&outputText);
@ -52,6 +56,8 @@ public:
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
return deckList; return deckList;
} }
@ -67,9 +73,6 @@ public:
QString deckName = obj.value("name").toString(); QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString(); QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText; QString outputText;
QTextStream outStream(&outputText); QTextStream outStream(&outputText);
@ -99,6 +102,8 @@ public:
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
QJsonObject commandersObj = obj.value("commanders").toObject(); QJsonObject commandersObj = obj.value("commanders").toObject();
if (!commandersObj.isEmpty()) { if (!commandersObj.isEmpty()) {
@ -118,4 +123,93 @@ public:
} }
}; };
class ManaboxJsonParser : public IJsonDeckParser
{
enum BoardType : quint32
{
MainBoard = 0,
SideBoard = 1,
Commander = 2,
MaybeBoard = 3,
};
public:
DeckList parse(const QJsonObject &obj) override
{
DeckList deckList;
QString deckName = obj.value("name").toString();
QString outputText;
QString mainboardText;
QString sideboardText;
QTextStream mainboardStream(&mainboardText);
QTextStream sideboardStream(&sideboardText);
QTextStream outStream(&outputText);
auto commanderList = QList<QJsonObject>();
auto maybeboardList = QList<QJsonObject>();
for (auto entry : obj.value("cards").toArray()) {
auto card = entry.toObject();
auto collectorNumber = card.value("collectorNumber").toString();
auto cardName = card.value("name").toString();
auto boardType = static_cast<BoardType>(card.value("boardCategory").toInt());
auto setName = card.value("setId").toString();
auto quantity = card.value("quantity").toInt();
auto providerId = card.value("scryfallId").toString();
switch (boardType) {
case MainBoard:
mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber
<< '\n';
break;
case SideBoard:
sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") "
<< collectorNumber << '\n';
break;
case MaybeBoard:
maybeboardList.append(card);
break;
case Commander:
commanderList.append(card);
break;
default:
qCWarning(InterfaceJsonDeckParserLog)
<< "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName;
break;
}
}
outStream << mainboardText << sideboardText;
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
for (const auto &maybeboardCard : maybeboardList) {
QString cardName = maybeboardCard.value("name").toString();
QString setName = maybeboardCard.value("setId").toString().toUpper();
QString collectorNumber = maybeboardCard.value("collectorNumber").toString();
QString providerId = maybeboardCard.value("scryfallId").toString();
for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) {
deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId);
}
}
for (const auto &commanderCard : commanderList) {
QString commanderName = commanderCard.value("name").toString();
QString setName = commanderCard.value("setId").toString().toUpper();
QString collectorNumber = commanderCard.value("collectorNumber").toString();
QString providerId = commanderCard.value("scryfallId").toString();
deckList.setBannerCard({commanderName, providerId});
deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId);
}
return deckList;
}
};
#endif // INTERFACE_JSON_DECK_PARSER_H #endif // INTERFACE_JSON_DECK_PARSER_H

View file

@ -22,6 +22,7 @@
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QString> #include <QString>
#include <QStringList>
#include <QTextStream> #include <QTextStream>
#include <QUrl> #include <QUrl>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -33,6 +34,7 @@
class QWidget; class QWidget;
static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"};
DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent)
{ {
nam = NetworkManagerFactory::create(this); nam = NetworkManagerFactory::create(this);
@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
setLayout(layout); setLayout(layout);
instructionLabel = new QLabel(this); instructionLabel = new QLabel(this);
instructionLabel->setWordWrap(true);
layout->addWidget(instructionLabel); layout->addWidget(instructionLabel);
urlEdit = new QLineEdit(this); urlEdit = new QLineEdit(this);
@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
void DlgLoadDeckFromWebsite::retranslateUi() void DlgLoadDeckFromWebsite::retranslateUi()
{ {
instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, " instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" +
"and TappedOut are supported.)")); tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
} }
bool DlgLoadDeckFromWebsite::testValidUrl() bool DlgLoadDeckFromWebsite::testValidUrl()
@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept()
if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) { if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) {
qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider"; qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider";
QMessageBox::warning(this, tr("Load Deck from Website"), QMessageBox::warning(this, tr("Load Deck from Website"),
tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, " tr("No parser available for this deck provider.") + "\n" +
"and TappedOut are supported.)")); tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
QDialog::reject(); QDialog::reject();
return; return;
} }
@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept()
"https://archidekt.com/decks/9999999\n" "https://archidekt.com/decks/9999999\n"
"https://deckstats.net/decks/99999/9999999-your-deck-name/en\n" "https://deckstats.net/decks/99999/9999999-your-deck-name/en\n"
"https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n" "https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n"
"https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n"
"https://tappedout.net/mtg-decks/your-deck-name/")); "https://tappedout.net/mtg-decks/your-deck-name/"));
QDialog::reject(); QDialog::reject();
} }
@ -164,6 +168,8 @@ QSharedPointer<IJsonDeckParser> DlgLoadDeckFromWebsite::createParserForProvider(
return QSharedPointer<IJsonDeckParser>(new ArchidektJsonParser()); return QSharedPointer<IJsonDeckParser>(new ArchidektJsonParser());
case DeckProvider::Moxfield: case DeckProvider::Moxfield:
return QSharedPointer<IJsonDeckParser>(new MoxfieldJsonParser()); return QSharedPointer<IJsonDeckParser>(new MoxfieldJsonParser());
case DeckProvider::Manabox:
return QSharedPointer<IJsonDeckParser>(new ManaboxJsonParser());
default: default:
return QSharedPointer<IJsonDeckParser>(nullptr); return QSharedPointer<IJsonDeckParser>(nullptr);
} }

View file

@ -70,4 +70,4 @@ confirm.
The action will automatically import the deck from the online service without any other required user action. The action will automatically import the deck from the online service without any other required user action.
Currently supported services are Archidekt, Deckstats, Moxfield, and TappedOut. Currently supported services are Archidekt, Deckstats, ManaBox, Moxfield, and TappedOut.