Cockatrice/cockatrice/src/interface/deck_loader/deck_loader.cpp
tooomm 5d025ca0bd
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Use capitalized app names (#7255)
* use capitalized app name

* update urls

* app description

* Update main.cpp
2026-09-12 17:30:46 +02:00

645 lines
21 KiB
C++

#include "deck_loader.h"
#include <QApplication>
#include <QClipboard>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QPrinter>
#include <QRegularExpression>
#include <QSaveFile>
#include <QStringList>
#include <QTextCursor>
#include <QTextDocument>
#include <QTextStream>
#include <QTextTable>
#include <QtConcurrentRun>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
const QStringList DeckLoader::ACCEPTED_FILE_EXTENSIONS = {"*.cod", "*.dec", "*.dek", "*.txt", "*.mwDeck"};
const QStringList DeckLoader::FILE_NAME_FILTERS = {
tr("Common deck formats (%1)").arg(ACCEPTED_FILE_EXTENSIONS.join(" ")), tr("All files (*.*)")};
DeckLoader::DeckLoader(QObject *parent) : QObject(parent)
{
}
std::optional<LoadedDeck>
DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bool userRequest)
{
QFile file(fileName);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(DeckLoaderLog) << "File does not exist:" << fileName;
return std::nullopt;
}
bool result = false;
DeckList deckList;
switch (fmt) {
case DeckFileFormat::PlainText:
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
break;
case DeckFileFormat::Cockatrice: {
result = deckList.loadFromFile_Native(&file);
if (!result) {
qCInfo(DeckLoaderLog) << "Failed to load " << fileName
<< "as Cockatrice format; retrying as plain format";
file.seek(0);
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
fmt = DeckFileFormat::PlainText;
}
break;
}
default:
break;
}
if (!result) {
qCWarning(DeckLoaderLog) << "Failed to load " << fileName << "as" << fmt;
return std::nullopt;
}
LoadedDeck::LoadInfo lastLoadInfo = {
.fileName = fileName,
.fileFormat = fmt,
};
LoadedDeck loadedDeck = {deckList, lastLoadInfo};
if (userRequest) {
updateLastLoadedTimestamp(loadedDeck);
}
qCDebug(DeckLoaderLog) << "Loaded deck" << fileName << "with userRequest:" << userRequest;
return loadedDeck;
}
void DeckLoader::loadFromFileAsync(const QString &fileName, DeckFileFormat::Format fmt, bool userRequest)
{
QFuture<void> future = QtConcurrent::run([=, this] {
std::optional<LoadedDeck> deckOpt = loadFromFile(fileName, fmt, userRequest);
if (deckOpt) {
loadedDeck = deckOpt.value();
}
emit loadFinished(deckOpt.has_value());
});
}
bool DeckLoader::reload()
{
QString lastFileName = loadedDeck.lastLoadInfo.fileName;
if (lastFileName.isEmpty()) {
return false;
}
std::optional<LoadedDeck> deck = loadFromFile(lastFileName, loadedDeck.lastLoadInfo.fileFormat, false);
if (!deck) {
return false;
}
loadedDeck = *deck;
return true;
}
std::optional<LoadedDeck> DeckLoader::loadFromRemote(const QString &nativeString, int remoteDeckId)
{
DeckList deckList;
bool success = deckList.loadFromString_Native(nativeString);
if (!success) {
qCWarning(DeckLoaderLog) << "Failed to load remote deck with id" << remoteDeckId << ":" << nativeString;
return std::nullopt;
}
LoadedDeck::LoadInfo lastLoadInfo = {.remoteDeckId = remoteDeckId};
LoadedDeck loadedDeck = {deckList, lastLoadInfo};
qCDebug(DeckLoaderLog) << "Loaded remote deck with id" << remoteDeckId;
return loadedDeck;
}
std::optional<LoadedDeck::LoadInfo>
DeckLoader::saveToFile(const DeckList &deck, const QString &fileName, DeckFileFormat::Format fmt)
{
// Use QSaveFile so that a failed write (e.g. a full disk) leaves the existing deck untouched
// instead of truncating it to a 0-byte file. The target is only replaced once every byte has
// been flushed successfully in commit().
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qCWarning(DeckLoaderLog) << "Could not create or open file:" << fileName;
return std::nullopt;
}
bool success = false;
switch (fmt) {
case DeckFileFormat::PlainText:
success = deck.saveToFile_Plain(&file);
break;
case DeckFileFormat::Cockatrice:
success = deck.saveToFile_Native(&file);
break;
}
if (!success) {
file.cancelWriting();
qCWarning(DeckLoaderLog) << "Failed to serialize deck for file:" << fileName;
return std::nullopt;
}
if (!file.commit()) {
qCWarning(DeckLoaderLog) << "Failed to save deck to " << fileName << ":" << file.errorString();
return std::nullopt;
}
qCInfo(DeckLoaderLog) << "Saved deck to " << fileName << "with format" << fmt;
LoadedDeck::LoadInfo lastLoadInfo = {fileName, fmt};
return lastLoadInfo;
}
bool DeckLoader::saveToFile(const LoadedDeck &deck)
{
auto opt = saveToFile(deck.deckList, deck.lastLoadInfo.fileName, deck.lastLoadInfo.fileFormat);
return opt.has_value();
}
bool DeckLoader::saveToNewFile(LoadedDeck &deck, const QString &fileName, DeckFileFormat::Format fmt)
{
std::optional<LoadedDeck::LoadInfo> infoOpt = saveToFile(deck.deckList, fileName, fmt);
if (infoOpt) {
deck.lastLoadInfo = infoOpt.value();
}
return infoOpt.has_value();
}
/**
* @brief Updates the lastLoadedTimestamp field in the file corresponding to the deck, without changing the
* FileModificationTime of the file.
*/
bool DeckLoader::updateLastLoadedTimestamp(LoadedDeck &deck)
{
// text format doesn't support lastLoadedTimestamp, so there's no point in proceeding
if (deck.lastLoadInfo.fileFormat != DeckFileFormat::Cockatrice) {
return false;
}
QString fileName = deck.lastLoadInfo.fileName;
QFileInfo fileInfo(fileName);
if (!fileInfo.exists()) {
qCWarning(DeckLoaderLog) << "File does not exist:" << fileName;
return false;
}
QDateTime originalTimestamp = fileInfo.lastModified();
// Use QSaveFile so that a failed write (e.g. a full disk) cannot truncate an existing deck to a
// 0-byte file while merely bumping its timestamp.
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qCWarning(DeckLoaderLog) << "Failed to open file for writing:" << fileName;
return false;
}
// Perform file modifications
deck.deckList.setLastLoadedTimestamp(QDateTime::currentDateTime().toString());
if (!deck.deckList.saveToFile_Native(&file)) {
file.cancelWriting();
qCWarning(DeckLoaderLog) << "Failed to serialize deck for file:" << fileName;
return false;
}
if (!file.commit()) {
qCWarning(DeckLoaderLog) << "Failed to update timestamp for file:" << fileName << ":" << file.errorString();
return false;
}
// Re-open the file and restore the original timestamp, so that updating the lastLoadedTimestamp
// does not change the file's modification time.
QFile timestampFile(fileName);
if (!timestampFile.open(QIODevice::ReadWrite)) {
qCWarning(DeckLoaderLog) << "Failed to re-open file to set timestamp:" << fileName;
return false;
}
if (!timestampFile.setFileTime(originalTimestamp, QFileDevice::FileModificationTime)) {
qCWarning(DeckLoaderLog) << "Failed to set modification time for file:" << fileName;
timestampFile.close();
return false;
}
timestampFile.close();
return true;
}
static QString getDomainForWebsite(DeckLoader::DecklistWebsite website)
{
switch (website) {
case DeckLoader::DecklistOrg:
return "www.decklist.org";
case DeckLoader::DecklistXyz:
return "www.decklist.xyz";
default:
qCWarning(DeckLoaderLog) << "Invalid decklist website enum:" << website;
return "";
}
}
/**
* Converts the card to the String that represents it in the decklist export
*/
static QString toDecklistExportString(const DecklistCardNode *card)
{
QString cardString;
// Get the number of cards and add the card name
cardString += QString::number(card->getNumber());
// Add a space between card num and name
cardString += "%20";
// Add card name
cardString += card->getName();
if (!card->getCardSetShortName().isNull()) {
cardString += "%20";
cardString += "(" + card->getCardSetShortName() + ")";
}
if (!card->getCardCollectorNumber().isNull()) {
cardString += "%20";
cardString += card->getCardCollectorNumber();
}
// Add a return at the end of the card
cardString += "%0A";
return cardString;
}
/**
* Converts all cards in the list to their decklist export string and joins them into one string
*/
static QString toDecklistExportString(const QList<const DecklistCardNode *> &cardNodes)
{
QString result;
for (auto cardNode : cardNodes) {
result += toDecklistExportString(cardNode);
}
return result;
}
/**
* Export deck to decklist function, called to format the deck in a way to be sent to a server
*
* @param deckList The decklist to export
* @param website The website we're sending the deck to
*/
QString DeckLoader::exportDeckToDecklist(const DeckList &deckList, DecklistWebsite website)
{
// Add the base url
QString deckString = "https://" + getDomainForWebsite(website) + "/?";
// export all cards in zone
QString mainBoardCards = toDecklistExportString(deckList.getCardNodes({DECK_ZONE_MAIN}));
QString sideBoardCards = toDecklistExportString(deckList.getCardNodes({DECK_ZONE_SIDE}));
// Remove the extra return at the end of the last cards
mainBoardCards.chop(3);
sideBoardCards.chop(3);
// if after we've called it for each card, and the strings are empty, we know that
// there were no non-token cards in the deck, so show an error message.
if ((QString::compare(mainBoardCards, "", Qt::CaseInsensitive) == 0) &&
(QString::compare(sideBoardCards, "", Qt::CaseInsensitive) == 0)) {
return "";
}
// return a string with the url for decklist export
deckString += "deckmain=" + mainBoardCards + "&deckside=" + sideBoardCards;
return deckString;
}
void DeckLoader::saveToClipboard(const DeckList &deckList, bool addComments, bool addSetNameAndNumber)
{
QString buffer;
QTextStream stream(&buffer);
saveToStream_Plain(stream, deckList, addComments, addSetNameAndNumber);
QApplication::clipboard()->setText(buffer, QClipboard::Clipboard);
QApplication::clipboard()->setText(buffer, QClipboard::Selection);
}
bool DeckLoader::saveToStream_Plain(QTextStream &out,
const DeckList &deckList,
bool addComments,
bool addSetNameAndNumber)
{
if (addComments) {
saveToStream_DeckHeader(out, deckList);
}
// loop zones
for (auto zoneNode : deckList.getZoneNodes()) {
saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber);
// end of zone
out << "\n";
}
return true;
}
void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckList)
{
if (!deckList.getName().isEmpty()) {
out << "// " << deckList.getName() << "\n\n";
}
if (!deckList.getComments().isEmpty()) {
QStringList commentRows = deckList.getComments().split(QRegularExpression("\n|\r\n|\r"));
for (const QString &row : commentRows) {
out << "// " << row << "\n";
}
out << "\n";
}
}
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
const InnerDecklistNode *zoneNode,
bool addComments,
bool addSetNameAndNumber,
const QString &boardZoneName)
{
// Nested sub-zones keep their owning board's identity: the top-level call
// passes no board, so the zone's own name is used; recursive calls carry the
// owning board down so the sideboard marker survives sub-zone nesting.
const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName;
// group cards by card type and count the subtotals
QMultiMap<QString, DecklistCardNode *> cardsByType;
QMap<QString, int> cardTotalByType;
int cardTotal = 0;
QList<const InnerDecklistNode *> subZones;
for (int j = 0; j < zoneNode->size(); j++) {
auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j));
if (!card) {
// Cards collected in nested sub-zones are exported by recursion so
// they don't end up invisible in the plain text output. They are
// deferred until after this zone's own header and cards so they read
// as part of this zone's block.
if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zoneNode->at(j))) {
subZones.append(subZone);
}
continue;
}
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
QString cardType = info ? info->getMainCardType() : "unknown";
cardsByType.insert(cardType, card);
if (cardTotalByType.contains(cardType)) {
cardTotalByType[cardType] += card->getNumber();
} else {
cardTotalByType[cardType] = card->getNumber();
}
cardTotal += card->getNumber();
}
if (addComments) {
out << "// " << cardTotal << " " << zoneNode->getVisibleName() << "\n";
}
// print cards to stream
for (const QString &cardType : cardsByType.uniqueKeys()) {
if (addComments) {
out << "// " << cardTotalByType[cardType] << " " << cardType << "\n";
}
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName);
if (addComments) {
out << "\n";
}
}
// Nested sub-zones come last, after the parent's own header and cards.
for (const auto *subZone : subZones) {
saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName);
}
}
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out,
QList<DecklistCardNode *> cards,
bool addComments,
bool addSetNameAndNumber,
const QString &boardZoneName)
{
// QMultiMap sorts values in reverse order
for (int i = cards.size() - 1; i >= 0; --i) {
DecklistCardNode *card = cards[i];
if (boardZoneName == DECK_ZONE_SIDE && addComments) {
out << "SB: ";
}
if (card->getNumber()) {
out << card->getNumber();
}
if (!card->getName().isNull() && !card->getName().isEmpty()) {
out << " " << card->getName();
}
if (addSetNameAndNumber) {
if (!card->getCardSetShortName().isNull() && !card->getCardSetShortName().isEmpty()) {
out << " " << "(" << card->getCardSetShortName() << ")";
}
if (!card->getCardCollectorNumber().isNull()) {
out << " " << card->getCardCollectorNumber();
}
}
out << "\n";
}
}
bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
{
QString fileName = deck.lastLoadInfo.fileName;
if (fileName.isEmpty()) {
return false;
}
// Determine the format before touching any file, so an already-converted or
// unsupported deck never truncates or deletes anything.
switch (DeckFileFormat::getFormatFromName(fileName)) {
case DeckFileFormat::PlainText:
break;
case DeckFileFormat::Cockatrice:
qCInfo(DeckLoaderLog) << "File is already in Cockatrice format. No conversion needed.";
return true;
default:
qCWarning(DeckLoaderLog) << "Unsupported file format for conversion:" << fileName;
return false;
}
// Change the file extension to .cod
QFileInfo fileInfo(fileName);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
// Use QSaveFile so a failed write (e.g. a full disk) cannot leave a 0-byte .cod
// behind and then delete the original deck.
QSaveFile file(newFileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qCWarning(DeckLoaderLog) << "Failed to open file for writing:" << newFileName;
return false;
}
if (!deck.deckList.saveToFile_Native(&file)) {
file.cancelWriting();
qCWarning(DeckLoaderLog) << "Failed to serialize deck for file:" << newFileName;
return false;
}
if (!file.commit()) {
qCWarning(DeckLoaderLog) << "Failed to convert deck to " << newFileName << ":" << file.errorString();
return false;
}
// Conversion succeeded: delete the original file.
if (!QFile::remove(fileName)) {
qCWarning(DeckLoaderLog) << "Failed to delete original file:" << fileName;
} else {
qCInfo(DeckLoaderLog) << "Original file deleted successfully:" << fileName;
}
deck.lastLoadInfo = {
.fileName = newFileName,
.fileFormat = DeckFileFormat::Cockatrice,
};
return true;
}
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
{
if (!node || node->isEmpty()) {
return;
}
const int totalColumns = 2;
// Dispatch children by type instead of trusting a whole-node height: a deck
// node may hold direct cards and nested zones side by side (custom zones),
// and an empty node would previously crash on at(0).
QVector<const AbstractDecklistCardNode *> cards;
QVector<const InnerDecklistNode *> subZones;
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<const AbstractDecklistCardNode *>(node->at(i))) {
cards.append(card);
} else if (auto *zone = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
subZones.append(zone);
}
}
if (!cards.isEmpty()) {
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
charFormat.setFontPointSize(11);
charFormat.setFontWeight(QFont::Bold);
cursor->insertBlock(blockFormat, charFormat);
QTextTableFormat tableFormat;
tableFormat.setCellPadding(0);
tableFormat.setCellSpacing(0);
tableFormat.setBorder(0);
QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat);
for (int i = 0; i < cards.size(); i++) {
const AbstractDecklistCardNode *card = cards[i];
QTextCharFormat cellCharFormat;
cellCharFormat.setFontPointSize(9);
QTextTableCell cell = table->cellAt(i, 0);
cell.setFormat(cellCharFormat);
QTextCursor cellCursor = cell.firstCursorPosition();
cellCursor.insertText(QString("%1 ").arg(card->getNumber()));
cell = table->cellAt(i, 1);
cell.setFormat(cellCharFormat);
cellCursor = cell.firstCursorPosition();
cellCursor.insertText(card->getName());
}
}
for (const InnerDecklistNode *subZone : subZones) {
if (subZone->isEmpty()) {
continue;
}
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
charFormat.setFontPointSize(14);
charFormat.setFontWeight(QFont::Bold);
cursor->insertBlock(blockFormat, charFormat);
QTextTableFormat tableFormat;
tableFormat.setCellPadding(10);
tableFormat.setCellSpacing(0);
tableFormat.setBorder(0);
QVector<QTextLength> constraints;
for (int i = 0; i < totalColumns; i++) {
constraints << QTextLength(QTextLength::PercentageLength, 100.0 / totalColumns);
}
tableFormat.setColumnWidthConstraints(constraints);
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
printDeckListNode(&cellCursor, subZone);
}
cursor->movePosition(QTextCursor::End);
}
void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList)
{
QTextDocument doc;
QFont font("Serif");
font.setStyleHint(QFont::Serif);
doc.setDefaultFont(font);
QTextCursor cursor(&doc);
QTextBlockFormat headerBlockFormat;
QTextCharFormat headerCharFormat;
headerCharFormat.setFontPointSize(16);
headerCharFormat.setFontWeight(QFont::Bold);
cursor.insertBlock(headerBlockFormat, headerCharFormat);
cursor.insertText(deckList.getName());
headerCharFormat.setFontPointSize(12);
cursor.insertBlock(headerBlockFormat, headerCharFormat);
cursor.insertText(deckList.getComments());
cursor.insertBlock(headerBlockFormat, headerCharFormat);
for (auto zoneNode : deckList.getZoneNodes()) {
cursor.insertHtml("<br><img src=theme:hr.jpg>");
cursor.insertBlock(headerBlockFormat, headerCharFormat);
printDeckListNode(&cursor, zoneNode);
}
doc.print(printer);
}