#include "deck_loader.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 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 future = QtConcurrent::run([=, this] { std::optional 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 deck = loadFromFile(lastFileName, loadedDeck.lastLoadInfo.fileFormat, false); if (!deck) { return false; } loadedDeck = *deck; return true; } std::optional 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 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 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 &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 cardsByType; QMap cardTotalByType; int cardTotal = 0; QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(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(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 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 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 cards; QVector subZones; for (int i = 0; i < node->size(); i++) { if (auto *card = dynamic_cast(node->at(i))) { cards.append(card); } else if (auto *zone = dynamic_cast(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 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("
"); cursor.insertBlock(headerBlockFormat, headerCharFormat); printDeckListNode(&cursor, zoneNode); } doc.print(printer); }