mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-27 08:24:39 -07:00
Compare commits
17 commits
90e11e0d63
...
812f2a88ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
812f2a88ea | ||
|
|
0bfa89b5d3 | ||
|
|
ad6d33c1e2 | ||
|
|
71febfa21d | ||
|
|
d18e28a65b | ||
|
|
048fe247f4 | ||
|
|
0f003eabf9 | ||
|
|
ada774f5cc | ||
|
|
b0e566ed54 | ||
|
|
0d09e633e3 | ||
|
|
9677fad342 | ||
|
|
e8ec28572f | ||
|
|
0c725f9a03 | ||
|
|
c011ea7ceb | ||
|
|
1dc54617ba | ||
|
|
aa96d81e4b | ||
|
|
14ecfff700 |
113 changed files with 5601 additions and 288 deletions
|
|
@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
|
|||
fi
|
||||
if [[ $USE_CCACHE ]]; then
|
||||
flags+=("-DUSE_CCACHE=1")
|
||||
# PCH-aware caching is required or ccache refuses to cache any TU that
|
||||
# consumes a precompiled header, silently recompiling everything on every run.
|
||||
ccache --set-config sloppiness=pch_defines,time_macros
|
||||
if [[ $CCACHE_SIZE ]]; then
|
||||
# note, this setting persists after running the script
|
||||
ccache --max-size "$CCACHE_SIZE"
|
||||
|
|
|
|||
8
.github/workflows/desktop-build.yml
vendored
8
.github/workflows/desktop-build.yml
vendored
|
|
@ -176,8 +176,12 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
source .ci/docker.sh
|
||||
RUN --server --debug --test --ccache "$CCACHE_SIZE" \
|
||||
--cmake-generator "$CMAKE_GENERATOR"
|
||||
args=()
|
||||
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
|
||||
args+=(--ccache "$CCACHE_SIZE")
|
||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
||||
|
||||
RUN --server --debug --test "${args[@]}"
|
||||
|
||||
- name: "Build release package"
|
||||
id: build
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Use compiler cache (ccache)
|
||||
option(USE_CCACHE "Cache the build results with ccache" OFF)
|
||||
option(USE_CCACHE "Cache the build results with ccache" ON)
|
||||
# Treat warnings as errors (Debug builds only)
|
||||
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
|
||||
# Check for translation updates
|
||||
|
|
@ -39,13 +39,24 @@ else()
|
|||
)
|
||||
endif()
|
||||
|
||||
if(USE_CCACHE)
|
||||
# ccache does not support MSVC and must not auto-engage on Windows
|
||||
# (it is installed unintentionally on the Windows CI runner).
|
||||
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
|
||||
# also opts out of ccache even though the GNUCXX branch below supports it.
|
||||
if(USE_CCACHE AND NOT WIN32)
|
||||
find_program(CCACHE_PROGRAM ccache)
|
||||
if(CCACHE_PROGRAM)
|
||||
# Support Unix Makefiles and Ninja
|
||||
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
|
||||
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
|
||||
# to cache any TU that consumes a precompiled header, so every PCH-backed
|
||||
# target recompiles from scratch on each build.
|
||||
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
|
||||
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
|
||||
endif()
|
||||
elseif(USE_CCACHE AND WIN32)
|
||||
# An explicit opt-in must not disappear silently on Windows.
|
||||
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
|
||||
endif()
|
||||
|
||||
if(WIN32 OR USE_VCPKG)
|
||||
|
|
|
|||
24
cmake/pch/qtcore_pch.h
Normal file
24
cmake/pch/qtcore_pch.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** @file qtcore_pch.h
|
||||
* @brief Precompiled header for all Qt targets (Qt Core only).
|
||||
*
|
||||
* Safe for every target that links Qt Core, including the headless
|
||||
* Servatrice binary. Keep this header free of any widget/gui types.
|
||||
*/
|
||||
|
||||
#include <QBasicTimer>
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QMetaObject>
|
||||
#include <QObject>
|
||||
#include <QRandomGenerator>
|
||||
#include <QSharedPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVariant>
|
||||
30
cmake/pch/qtwidgets_pch.h
Normal file
30
cmake/pch/qtwidgets_pch.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/** @file qtwidgets_pch.h
|
||||
* @brief Precompiled header for GUI targets (Cockatrice client, Oracle).
|
||||
*
|
||||
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
|
||||
* Network layers that virtually every client translation unit re-parses.
|
||||
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
|
||||
*/
|
||||
|
||||
#include "qtcore_pch.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QFrame>
|
||||
#include <QGraphicsItem>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsView>
|
||||
#include <QImage>
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QMainWindow>
|
||||
#include <QMenu>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QTabWidget>
|
||||
#include <QToolBar>
|
||||
#include <QTreeWidget>
|
||||
#include <QWidget>
|
||||
|
|
@ -50,6 +50,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_register.cpp
|
||||
src/interface/widgets/dialogs/dlg_report_user.cpp
|
||||
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
|
||||
src/interface/widgets/dialogs/dlg_share_deck.cpp
|
||||
src/interface/widgets/dialogs/dlg_settings.cpp
|
||||
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
|
||||
src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp
|
||||
|
|
@ -57,6 +58,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||
src/interface/widgets/dialogs/tip_of_the_day.cpp
|
||||
src/interface/widgets/deck_share/deck_share_utils.cpp
|
||||
src/interface/widgets/deck_share/share_bar_widget.cpp
|
||||
src/filters/deck_filter_string.cpp
|
||||
src/filters/filter_builder.cpp
|
||||
src/filters/filter_tree_model.cpp
|
||||
|
|
@ -163,6 +166,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/palette_editor/palette_grid_widget.cpp
|
||||
src/interface/palette_editor/palette_editor_dialog.cpp
|
||||
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
|
||||
src/interface/widgets/cards/additional_info/deck_color_identity.cpp
|
||||
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
|
||||
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
|
||||
src/interface/widgets/cards/art_crop_attribution.cpp
|
||||
|
|
@ -214,6 +218,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
|
||||
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
|
||||
src/interface/widgets/deck_editor/deck_state_manager.cpp
|
||||
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
|
||||
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
|
||||
src/interface/widgets/general/background_sources.cpp
|
||||
src/interface/widgets/general/display/background_plate_widget.cpp
|
||||
|
|
@ -516,6 +521,8 @@ qt6_add_executable(
|
|||
MANUAL_FINALIZATION
|
||||
)
|
||||
|
||||
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
|
||||
|
||||
qt6_add_shaders(
|
||||
cockatrice
|
||||
"onboarding_shaders"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
|
|||
<dt><u>E</u>dition:</dt>
|
||||
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
|
||||
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
|
||||
<dd>[e<8ED](#e<8ED) <small>(Cards that appear before 8th edition)</small></dd>
|
||||
|
||||
<dt>Negate:</dt>
|
||||
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
#include <algorithm>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
|
||||
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
|
||||
|
|
@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree()
|
|||
addItem(container);
|
||||
}
|
||||
|
||||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
if (!currentCard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cards in custom zones nested under a board are regular board cards in-game.
|
||||
// They are collected recursively (like every other consumer) and reported with
|
||||
// the top-level board zone as their origin, so that sideboard plans keep working.
|
||||
for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) {
|
||||
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
||||
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
||||
container->addCard(newCard);
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ TallyMenu::TallyMenu()
|
|||
aTallyNone = createTallyAction(TallyType::None);
|
||||
aTallySubtypes = createTallyAction(TallyType::Subtypes);
|
||||
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
|
||||
aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
|
||||
|
||||
addAction(aTallyNone);
|
||||
addSeparator();
|
||||
addAction(aTallySubtypes);
|
||||
addAction(aTallyTotalPower);
|
||||
addAction(aTallyTotalToughness);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
|
@ -54,4 +56,5 @@ void TallyMenu::retranslateUi()
|
|||
aTallyNone->setText(tr("None"));
|
||||
aTallySubtypes->setText(tr("Subtypes"));
|
||||
aTallyTotalPower->setText(tr("Total Power"));
|
||||
aTallyTotalToughness->setText(tr("Total Toughness"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ private:
|
|||
QAction *aTallyNone = nullptr;
|
||||
QAction *aTallySubtypes = nullptr;
|
||||
QAction *aTallyTotalPower = nullptr;
|
||||
QAction *aTallyTotalToughness = nullptr;
|
||||
|
||||
QAction *createTallyAction(TallyType tallyType);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,3 +34,31 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
|
|||
QString name = QCoreApplication::translate("StatsTally", "Total Power");
|
||||
return {TallyRow{name, QString::number(total)}};
|
||||
}
|
||||
|
||||
static int sumToughness(const QList<CardItem *> &cards)
|
||||
{
|
||||
int total = 0;
|
||||
for (auto card : cards) {
|
||||
QVariantList parsed = CardItem::parsePT(card->getPT());
|
||||
if (parsed.size() == 2) {
|
||||
int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
|
||||
total += qMax(toughness, 0);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
QList<TallyRow> StatsTally::computeTotalToughness(const QList<CardItem *> &cards)
|
||||
{
|
||||
// don't bother if none of the cards have pt
|
||||
bool hasPT =
|
||||
std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
|
||||
if (!hasPT) {
|
||||
return {};
|
||||
}
|
||||
|
||||
int total = sumToughness(cards);
|
||||
|
||||
QString name = QCoreApplication::translate("StatsTally", "Total Toughness");
|
||||
return {TallyRow{name, QString::number(total)}};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ namespace StatsTally
|
|||
*/
|
||||
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
|
||||
|
||||
/**
|
||||
* @brief Sums the toughness of all selected cards
|
||||
*
|
||||
* @param cards The list of selected card items to analyze.
|
||||
* @return A single row containing the total, or an empty list if none of the cards have pt
|
||||
*/
|
||||
QList<TallyRow> computeTotalToughness(const QList<CardItem *> &cards);
|
||||
|
||||
} // namespace StatsTally
|
||||
|
||||
#endif // COCKATRICE_STATS_TALLY_H
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType t
|
|||
return SubtypeTally::countSubtypes(cards);
|
||||
case TallyType::TotalPower:
|
||||
return StatsTally::computeTotalPower(cards);
|
||||
case TallyType::TotalToughness:
|
||||
return StatsTally::computeTotalToughness(cards);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ enum class TallyType
|
|||
None,
|
||||
Subtypes,
|
||||
TotalPower,
|
||||
MaxValue = TotalPower // sentinel value
|
||||
TotalToughness,
|
||||
MaxValue = TotalToughness // sentinel value
|
||||
};
|
||||
|
||||
namespace Tally
|
||||
|
|
|
|||
|
|
@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
|
|||
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
bool addComments,
|
||||
bool addSetNameAndNumber)
|
||||
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";
|
||||
|
|
@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out,
|
|||
|
||||
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
|
||||
|
||||
saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber);
|
||||
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,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
QList<DecklistCardNode *> cards,
|
||||
bool addComments,
|
||||
bool addSetNameAndNumber)
|
||||
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 (zoneNode->getName() == DECK_ZONE_SIDE && addComments) {
|
||||
if (boardZoneName == DECK_ZONE_SIDE && addComments) {
|
||||
out << "SB: ";
|
||||
}
|
||||
|
||||
|
|
@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
|
|||
|
||||
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
|
||||
{
|
||||
if (!node || node->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int totalColumns = 2;
|
||||
|
||||
if (node->height() == 1) {
|
||||
// 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);
|
||||
|
|
@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
tableFormat.setCellPadding(0);
|
||||
tableFormat.setCellSpacing(0);
|
||||
tableFormat.setBorder(0);
|
||||
QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat);
|
||||
for (int i = 0; i < node->size(); i++) {
|
||||
auto *card = dynamic_cast<AbstractDecklistCardNode *>(node->at(i));
|
||||
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);
|
||||
|
|
@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
cellCursor = cell.firstCursorPosition();
|
||||
cellCursor.insertText(card->getName());
|
||||
}
|
||||
} else if (node->height() == 2) {
|
||||
}
|
||||
|
||||
for (const InnerDecklistNode *subZone : subZones) {
|
||||
if (subZone->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QTextBlockFormat blockFormat;
|
||||
QTextCharFormat charFormat;
|
||||
charFormat.setFontPointSize(14);
|
||||
|
|
@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
tableFormat.setColumnWidthConstraints(constraints);
|
||||
|
||||
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
|
||||
for (int i = 0; i < node->size(); i++) {
|
||||
QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition();
|
||||
printDeckListNode(&cellCursor, dynamic_cast<InnerDecklistNode *>(node->at(i)));
|
||||
}
|
||||
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
|
||||
printDeckListNode(&cellCursor, subZone);
|
||||
}
|
||||
|
||||
cursor->movePosition(QTextCursor::End);
|
||||
|
|
|
|||
|
|
@ -159,12 +159,13 @@ private:
|
|||
static void saveToStream_DeckZone(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
bool addComments = true,
|
||||
bool addSetNameAndNumber = true);
|
||||
bool addSetNameAndNumber = true,
|
||||
const QString &boardZoneName = QString());
|
||||
static void saveToStream_DeckZoneCards(QTextStream &out,
|
||||
const InnerDecklistNode *zoneNode,
|
||||
QList<DecklistCardNode *> cards,
|
||||
bool addComments = true,
|
||||
bool addSetNameAndNumber = true);
|
||||
bool addSetNameAndNumber = true,
|
||||
const QString &boardZoneName = QString());
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
|
|||
}
|
||||
lastWidth = totalWidth;
|
||||
|
||||
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
|
||||
const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width
|
||||
setFixedHeight(totalHeight);
|
||||
|
||||
const int count = layout->count();
|
||||
|
|
@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
|
|||
const int availableWidth = totalWidth - (spacing * (count - 1));
|
||||
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
|
||||
|
||||
if (iconSize <= 0) {
|
||||
lastIconSize = iconSize;
|
||||
return;
|
||||
}
|
||||
if (iconSize == lastIconSize) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
#include "deck_color_identity.h"
|
||||
|
||||
#include <QSet>
|
||||
#include <libcockatrice/card/database/card_database_querier.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db)
|
||||
{
|
||||
const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
|
||||
if (cardList.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
|
||||
|
||||
for (const QString &cardName : cardList) {
|
||||
CardInfoPtr currentCard = db->getCardInfo(cardName);
|
||||
if (currentCard) {
|
||||
const QString colors = currentCard->getColors(); // returns something like "WUB"
|
||||
for (const QChar &color : colors) {
|
||||
colorSet.insert(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the color identity is in WUBRG order
|
||||
QString colorIdentity;
|
||||
const QString wubrgOrder = "WUBRG";
|
||||
for (const QChar &color : wubrgOrder) {
|
||||
if (colorSet.contains(color)) {
|
||||
colorIdentity.append(color);
|
||||
}
|
||||
}
|
||||
|
||||
return colorIdentity;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
#define COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
class CardDatabaseQuerier;
|
||||
class DeckList;
|
||||
|
||||
/**
|
||||
* @brief Computes the color identity of a deck (e.g. "WUBRG") from the color
|
||||
* symbols of all cards in the main deck and sideboard, ordered WUBRG.
|
||||
*
|
||||
* Shared as a free function so the deck storage previews and the deck share
|
||||
* dialog compute identities identically.
|
||||
*
|
||||
* @param db Card database used to look up card color symbols.
|
||||
*/
|
||||
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db);
|
||||
|
||||
#endif // COCKATRICE_DECK_COLOR_IDENTITY_H
|
||||
|
|
@ -174,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays()
|
|||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
addCardWidgets(QPersistentModelIndex(sourceIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the card amount
|
||||
int cardAmount =
|
||||
sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
|
||||
{
|
||||
// Get the card amount
|
||||
int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public:
|
|||
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
|
||||
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
|
||||
void clearAllDisplayWidgets();
|
||||
void addCardWidgets(const QPersistentModelIndex &persistent);
|
||||
|
||||
DeckListModel *deckListModel;
|
||||
QItemSelectionModel *selectionModel;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
|
||||
#include <QResizeEvent>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
||||
|
|
@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
|||
// User Interaction
|
||||
// =====================================================================================================================
|
||||
|
||||
void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
|
||||
{
|
||||
emit cardClicked(event, card, zoneName);
|
||||
}
|
||||
|
||||
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
|
||||
{
|
||||
emit cardHovered(card);
|
||||
|
|
@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
}
|
||||
|
||||
auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Cards in a custom zone belong to that zone, not the board zone, so that
|
||||
// increment/decrement/swap actions target the custom zone.
|
||||
const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const QString effectiveZoneName = isCustomZone ? categoryName : zoneName;
|
||||
const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) {
|
||||
emit cardClicked(event, card, effectiveZoneName);
|
||||
};
|
||||
if (displayType == DisplayType::Overlap) {
|
||||
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
|
||||
cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this,
|
||||
&DeckCardZoneDisplayWidget::onClick);
|
||||
cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName,
|
||||
activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
|
||||
&DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
|
|
@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
indexToWidgetMap.insert(index, displayWidget);
|
||||
} else if (displayType == DisplayType::Flat) {
|
||||
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
|
||||
zoneName, categoryName, activeGroupCriteria,
|
||||
effectiveZoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
|
||||
|
|
@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
|
||||
void DeckCardZoneDisplayWidget::displayCards()
|
||||
{
|
||||
QSortFilterProxyModel proxy;
|
||||
proxy.setSourceModel(deckListModel);
|
||||
proxy.setSortRole(Qt::EditRole);
|
||||
proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder);
|
||||
if (!trackedIndex.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. trackedIndex is a source index → map it to proxy space
|
||||
QModelIndex proxyParent = proxy.mapFromSource(trackedIndex);
|
||||
|
||||
// 2. iterate children under the proxy parent
|
||||
for (int i = 0; i < proxy.rowCount(proxyParent); ++i) {
|
||||
QModelIndex proxyIndex = proxy.index(i, 0, proxyParent);
|
||||
|
||||
// 3. map back to source
|
||||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
// Iterate the direct children of the tracked zone, keeping the tree view's row
|
||||
// order (criteria groups first, then custom zones, both in the model's sort order).
|
||||
QList<QPersistentModelIndex> rows;
|
||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
||||
rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
|
||||
}
|
||||
|
||||
for (const QPersistentModelIndex &persistent : rows) {
|
||||
constructAppropriateWidget(persistent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ public:
|
|||
void addCardsToOverlapWidget();
|
||||
|
||||
public slots:
|
||||
void onClick(QMouseEvent *event, const ExactCard &card);
|
||||
void onHover(const ExactCard &card);
|
||||
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
|
||||
void constructAppropriateWidget(QPersistentModelIndex index);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
|
|||
{
|
||||
singleClickTimer = new QTimer(this);
|
||||
singleClickTimer->setSingleShot(true);
|
||||
connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); });
|
||||
connect(singleClickTimer, &QTimer::timeout, this, [this]() {
|
||||
emit imageClicked(lastMouseEvent, this);
|
||||
emit imageSingleClicked();
|
||||
});
|
||||
connect(&SettingsCache::instance().visualDeckStorage(),
|
||||
&VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this,
|
||||
&CardInfoPictureWidget::setRaiseOnEnterEnabled);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public:
|
|||
|
||||
signals:
|
||||
void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
void imageSingleClicked();
|
||||
void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
|
||||
private:
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
|
|||
emit cardDecremented(currentCardName(), zoneName);
|
||||
}
|
||||
|
||||
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<QString()> &newZoneHandler)
|
||||
{
|
||||
zoneMenuProvider = provider;
|
||||
this->newZoneHandler = newZoneHandler;
|
||||
}
|
||||
|
||||
void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/)
|
||||
{
|
||||
if (!current.isValid()) {
|
||||
|
|
@ -142,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point)
|
|||
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
|
||||
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked);
|
||||
|
||||
if (zoneMenuProvider) {
|
||||
QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone"));
|
||||
const auto zoneBoards = zoneMenuProvider();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
// Boards with zones nest their children so no two menu entries
|
||||
// share a visible name: "Maindeck ▸ { Maindeck (whole board), … }".
|
||||
const QStringList customZones = [&zoneBoards, boardName] {
|
||||
for (const auto &zoneBoard : zoneBoards) {
|
||||
if (zoneBoard.first == boardName) {
|
||||
return zoneBoard.second;
|
||||
}
|
||||
}
|
||||
return QStringList();
|
||||
}();
|
||||
if (customZones.isEmpty()) {
|
||||
QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
|
||||
} else {
|
||||
QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(wholeBoardAction, &QAction::triggered, this,
|
||||
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
|
||||
for (const QString &zoneName : customZones) {
|
||||
QAction *action = boardSubmenu->addAction(zoneName);
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, zoneName] { emit cardAdded(card->getName(), zoneName); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newZoneHandler) {
|
||||
addToZoneMenu->addSeparator();
|
||||
|
||||
QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this, card] {
|
||||
const QString zoneName = newZoneHandler();
|
||||
if (!zoneName.isEmpty()) {
|
||||
emit cardAdded(card->getName(), zoneName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canBeCommander(*card)) {
|
||||
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
|
||||
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../key_signals.h"
|
||||
|
||||
#include <QTreeView>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
|
||||
class CardDatabaseModel;
|
||||
|
|
@ -19,6 +20,13 @@ class CardDatabaseView : public QTreeView
|
|||
KeySignals searchKeySignals;
|
||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||
|
||||
/// Provides the custom zones available in the current deck, grouped by board zone.
|
||||
/// The list contains (board zone name, custom zone names) pairs for every board.
|
||||
std::function<QList<QPair<QString, QStringList>>()> zoneMenuProvider;
|
||||
/// Handler invoked when the user picks "New zone..." from the add-to-zone menu.
|
||||
/// Returns the name of the created zone, or an empty string if creation was cancelled.
|
||||
std::function<QString()> newZoneHandler;
|
||||
|
||||
public:
|
||||
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
|
||||
|
||||
|
|
@ -33,6 +41,17 @@ public:
|
|||
return &searchKeySignals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the provider used to populate the "Add to zone" submenu of the context menu.
|
||||
* If no provider is set, the submenu is not shown.
|
||||
*
|
||||
* @param provider Returns the custom zones of the current deck, grouped by board zone
|
||||
* @param newZoneHandler Creates a new custom zone and returns its name, or an empty string
|
||||
* if creation was cancelled. The menu entry is hidden when not provided.
|
||||
*/
|
||||
void setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<QString()> &newZoneHandler);
|
||||
|
||||
signals:
|
||||
void cardChanged(const QString &cardName);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
#include "deck_editor_card_database_dock_widget.h"
|
||||
|
||||
#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h"
|
||||
#include "card_database_view.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
|
||||
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
|
||||
{
|
||||
setObjectName("databaseDisplayDock");
|
||||
|
|
@ -15,6 +22,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
|
|||
{
|
||||
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
|
||||
|
||||
databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider(
|
||||
[deckEditor]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
auto *deckListModel = deckEditor->deckStateManager->getModel();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this, deckEditor]() -> QString {
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
|
||||
return deckEditor->deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckEditor->deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
});
|
||||
|
||||
auto *frame = new QVBoxLayout;
|
||||
frame->setObjectName("databaseDisplayFrame");
|
||||
frame->addWidget(databaseDisplayWidget);
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@
|
|||
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
|
||||
#include "deck_list_style_proxy.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSplitter>
|
||||
#include <QTextEdit>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||
#include <libcockatrice/settings/interface_settings.h>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
|
|
@ -772,14 +775,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
|
|||
|
||||
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
|
||||
{
|
||||
const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
|
||||
|
||||
QMenu menu;
|
||||
|
||||
const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid();
|
||||
const bool isCardRow =
|
||||
sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex);
|
||||
|
||||
// Walk the row up to its top-level node to find the hosting board. Cards in
|
||||
// the tokens board cannot be moved (moveCardToZone bails for it), so the
|
||||
// move menu is skipped for them.
|
||||
QString currentBoardName;
|
||||
QModelIndex board = sourceIndex.parent();
|
||||
while (board.isValid() && board.parent().isValid()) {
|
||||
board = board.parent();
|
||||
}
|
||||
if (board.isValid()) {
|
||||
currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
}
|
||||
|
||||
if (isCardRow) {
|
||||
if (currentBoardName != DECK_ZONE_TOKENS) {
|
||||
addMoveToZoneMenu(&menu, sourceIndex, currentBoardName);
|
||||
menu.addSeparator();
|
||||
}
|
||||
} else if (isCustomZoneRow) {
|
||||
const QString zoneName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
|
||||
QAction *renameAction = menu.addAction(tr("&Rename zone..."));
|
||||
connect(renameAction, &QAction::triggered, this, [this, zoneName] {
|
||||
// The unchanged name must not validate as a duplicate.
|
||||
const QString newName =
|
||||
DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) {
|
||||
return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!newName.isEmpty() && newName != zoneName) {
|
||||
deckStateManager->renameCustomZone(zoneName, newName);
|
||||
}
|
||||
});
|
||||
|
||||
QMenu *boardMenu = menu.addMenu(tr("Change &board"));
|
||||
addChangeBoardMenu(boardMenu, zoneName);
|
||||
|
||||
QAction *deleteAction = menu.addAction(tr("&Delete zone"));
|
||||
const bool zoneHasCards = getModel()->hasChildren(sourceIndex);
|
||||
deleteAction->setEnabled(!zoneHasCards);
|
||||
if (zoneHasCards) {
|
||||
deleteAction->setToolTip(tr("Move or remove all cards first."));
|
||||
menu.setToolTipsVisible(true);
|
||||
}
|
||||
connect(deleteAction, &QAction::triggered, this, [this, zoneName] {
|
||||
const auto result =
|
||||
QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (result == QMessageBox::Yes) {
|
||||
deckStateManager->removeCustomZone(zoneName);
|
||||
}
|
||||
});
|
||||
menu.addSeparator();
|
||||
} else if (isBoardZoneRow) {
|
||||
const QString boardName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Tokens cannot host custom zones, so only offer the action on real boards.
|
||||
const bool canHostCustomZones =
|
||||
boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD;
|
||||
if (canHostCustomZones) {
|
||||
addNewZoneAction(&menu, boardName);
|
||||
menu.addSeparator();
|
||||
}
|
||||
} else if (!sourceIndex.isValid()) {
|
||||
addNewZoneAction(&menu);
|
||||
menu.addSeparator();
|
||||
}
|
||||
|
||||
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
|
||||
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
|
||||
|
||||
menu.exec(deckView->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu,
|
||||
const QModelIndex &sourceCardIndex,
|
||||
const QString ¤tBoardName)
|
||||
{
|
||||
// The card's current *zone*, derived with the same ancestor walk as
|
||||
// DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the
|
||||
// top-level board/zone): a card inside "Removal" under the maindeck lives in
|
||||
// "Removal", not "main". Comparing against that instead of the board keeps
|
||||
// the enabled state and the same-zone no-op consistent with the move logic.
|
||||
QString currentZoneName;
|
||||
for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
|
||||
if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) {
|
||||
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName,
|
||||
const QString &label, bool enabled) {
|
||||
QAction *action = targetMenu->addAction(label);
|
||||
action->setEnabled(enabled);
|
||||
if (enabled) {
|
||||
connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] {
|
||||
deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
|
||||
QMenu *moveMenu = menu->addMenu(tr("Move to &zone"));
|
||||
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName);
|
||||
const auto customZones = tree->getCustomZones(boardName);
|
||||
|
||||
// Boards with zones nest their children so no two menu entries share a
|
||||
// visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }".
|
||||
// The board the card already lives on is marked instead of offered.
|
||||
if (!customZones.isEmpty()) {
|
||||
QMenu *boardSubmenu = moveMenu->addMenu(boardLabel);
|
||||
addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName);
|
||||
for (const auto *customZone : customZones) {
|
||||
addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(),
|
||||
customZone->getName() != currentZoneName);
|
||||
}
|
||||
} else {
|
||||
addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName);
|
||||
}
|
||||
}
|
||||
|
||||
moveMenu->addSeparator();
|
||||
|
||||
QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] {
|
||||
// Resolve the card's identity before creating the zone:
|
||||
// createNewCustomZone rebuilds the model tree, so sourceCardIndex's
|
||||
// internal pointer is freed by the time it would be used.
|
||||
const QString cardName =
|
||||
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
const QString providerId =
|
||||
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
|
||||
const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER)
|
||||
.data(Qt::DisplayRole)
|
||||
.toString();
|
||||
|
||||
const QString zoneName = createNewCustomZone(currentBoardName);
|
||||
if (!zoneName.isEmpty()) {
|
||||
// Re-find the card: the old index is no longer safe since rows were
|
||||
// rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern.
|
||||
const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber);
|
||||
if (refreshed.isValid()) {
|
||||
deckStateManager->moveCardToZone(refreshed, zoneName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName)
|
||||
{
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
|
||||
// The board currently holding the zone is marked instead of offered.
|
||||
// Duplicate names cannot come up through the editor, so this doubles as
|
||||
// the uniqueness guard for imported decks.
|
||||
bool holdsTheZone = false;
|
||||
for (const auto *customZone : tree->getCustomZones(boardName)) {
|
||||
if (customZone->getName() == zoneName) {
|
||||
holdsTheZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (holdsTheZone) {
|
||||
action->setCheckable(true);
|
||||
action->setChecked(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); });
|
||||
}
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName)
|
||||
{
|
||||
QAction *newZoneAction = menu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this,
|
||||
[this, initialBoardName] { createNewCustomZone(initialBoardName); });
|
||||
}
|
||||
|
||||
QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName)
|
||||
{
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::refreshShortcuts()
|
||||
{
|
||||
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QTextEdit>
|
||||
#include <QTreeView>
|
||||
|
|
@ -102,6 +103,11 @@ private:
|
|||
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
|
||||
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
|
||||
|
||||
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName);
|
||||
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
|
||||
QString createNewCustomZone(const QString &initialBoardName = {});
|
||||
void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {});
|
||||
|
||||
private slots:
|
||||
void decklistCustomMenu(QPoint point);
|
||||
void updateCard(QModelIndex, const QModelIndex ¤t);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
DeckStateManager::DeckStateManager(QObject *parent)
|
||||
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
||||
|
|
@ -307,6 +308,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
|
|||
return offsetCountAtIndex(idx, -1);
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only actual card rows can be moved. Group or zone rows report an
|
||||
// aggregate amount and must never be deleted by this operation.
|
||||
if (!idx.data(DeckRoles::IsCardRole).toBool()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
|
||||
int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
if (copies <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tokens only live in the tokens zone and cannot be moved into decks.
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (info && info->getIsToken()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine the zone the card currently lives in: the enclosing custom
|
||||
// zone, or the nearest top-level zone (board zone or legacy zone).
|
||||
QString currentZoneName;
|
||||
for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
|
||||
bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
if (isCustomZone || !ancestor.parent().isValid()) {
|
||||
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentZoneName == targetZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Moved %1 × \"%2\" (%3) to %4")
|
||||
.arg(copies)
|
||||
.arg(cardName)
|
||||
.arg(providerId)
|
||||
.arg(InnerDecklistNode::visibleNameFromName(targetZoneName));
|
||||
|
||||
return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) {
|
||||
if (!model->removeRow(idx.row(), idx.parent())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addCard(card, targetZoneName);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addPreferredPrintingCard(cardName, targetZoneName, true);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName)
|
||||
{
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
if (trimmedZoneName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName));
|
||||
|
||||
return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) {
|
||||
return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
|
||||
{
|
||||
const QString trimmedNewZoneName = newZoneName.trimmed();
|
||||
if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName);
|
||||
|
||||
return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) {
|
||||
return tree->renameCustomZone(oldZoneName, trimmedNewZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
|
||||
{
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Locate the zone through the tree's own lookup, which walks every top-level
|
||||
// zone (not just the standard boards) and covers the same-board no-op below.
|
||||
const auto *zone = tree->findCustomZoneByName(zoneName);
|
||||
if (!zone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same-board moves are no-ops and must not pollute the history.
|
||||
const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString();
|
||||
if (currentBoardName == newBoardZoneName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Zone names are deck-unique among zones created through this manager, so a
|
||||
// same-named zone on the target board can only come from an imported deck.
|
||||
// Refuse the move instead of silently stacking same-named zones.
|
||||
for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) {
|
||||
if (targetZone->getName() == zoneName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName));
|
||||
|
||||
return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) {
|
||||
return tree->moveCustomZone(zoneName, newBoardZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::removeCustomZone(const QString &zoneName)
|
||||
{
|
||||
QString reason = tr("Deleted zone \"%1\"").arg(zoneName);
|
||||
|
||||
return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); });
|
||||
}
|
||||
|
||||
QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
|
||||
{
|
||||
if (zoneName.trimmed().isEmpty()) {
|
||||
return tr("Enter a zone name.");
|
||||
}
|
||||
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
|
||||
// The standard zone names are reserved even before they exist.
|
||||
if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE ||
|
||||
trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) {
|
||||
return tr("This name is reserved.");
|
||||
}
|
||||
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Reuse the tree's own uniqueness contract: any top-level zone and any
|
||||
// custom zone on *every* board claims the name (hasZoneName also reserves
|
||||
// the standard board names, which we already rejected with a dedicated
|
||||
// message above). Scanning only the standard boards here would miss a
|
||||
// custom zone an imported deck carries under `tokens`.
|
||||
if (tree->hasZoneName(trimmedZoneName)) {
|
||||
return tr("A zone with this name already exists.");
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
|
|
@ -367,6 +532,25 @@ void DeckStateManager::requestHistorySave(const QString &reason)
|
|||
historyManager->save(deckList->createMemento(reason));
|
||||
}
|
||||
|
||||
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
|
||||
{
|
||||
DeckListMemento memento = deckList->createMemento(reason);
|
||||
bool success = operation(deckList->getTree());
|
||||
|
||||
if (success) {
|
||||
historyManager->save(memento);
|
||||
deckListModel->rebuildTree();
|
||||
deckList->refreshDeckHash();
|
||||
emit deckListModel->deckHashChanged();
|
||||
// removeCustomZone can drop whole card sets the model never notified
|
||||
// about (rebuildTree emits no cardNodesChanged), so tell the consumers.
|
||||
emit deckListModel->cardNodesChanged();
|
||||
doCardModified();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Handles updating state and emitting signals whenever the cards are modified
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "deck_list_model.h"
|
||||
|
||||
#include <QSharedPointer>
|
||||
#include <functional>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
||||
class DeckListHistoryManager;
|
||||
|
|
@ -236,6 +237,68 @@ public:
|
|||
*/
|
||||
bool decrementCountAtIndex(const QModelIndex &idx);
|
||||
|
||||
/**
|
||||
* @brief Moves all copies of the card at the given index to the given zone.
|
||||
* No-ops if the index is invalid, not a card node, the card is a token, or the
|
||||
* card is already in the target zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param idx The model index of the card to move
|
||||
* @param targetZoneName The zone to move the card to (board zone or custom zone name)
|
||||
* @return Whether the operation was successfully performed
|
||||
*/
|
||||
bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName);
|
||||
|
||||
/**
|
||||
* @brief Creates a new custom zone nested under a board zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param boardZoneName The board zone to nest the custom zone under
|
||||
* @param zoneName The name of the new custom zone. Gets trimmed and must be
|
||||
* unique across the deck.
|
||||
* @return Whether the zone was created
|
||||
*/
|
||||
bool createCustomZone(const QString &boardZoneName, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Renames a custom zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param oldZoneName The current name of the custom zone
|
||||
* @param newZoneName The new name. Gets trimmed and must be unique across the deck.
|
||||
* @return Whether the rename succeeded
|
||||
*/
|
||||
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
|
||||
|
||||
/**
|
||||
* @brief Moves a custom zone (and its cards) to a different board zone.
|
||||
* Same-board moves succeed without creating a history entry.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to move
|
||||
* @param newBoardZoneName The board zone to move the custom zone under
|
||||
* @return Whether the move succeeded
|
||||
*/
|
||||
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
|
||||
|
||||
/**
|
||||
* @brief Removes a custom zone and all its cards.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to remove
|
||||
* @return Whether the zone was removed
|
||||
*/
|
||||
bool removeCustomZone(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Checks whether a candidate name is usable for a new custom zone.
|
||||
*
|
||||
* @param zoneName The candidate name
|
||||
* @return An empty string when the name is usable, otherwise a user-facing
|
||||
* error message describing the problem
|
||||
*/
|
||||
[[nodiscard]] QString validateNewZoneName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager.
|
||||
* @param steps Number of steps to undo.
|
||||
|
|
@ -257,6 +320,7 @@ public slots:
|
|||
|
||||
private:
|
||||
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
|
||||
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
|
||||
void doCardModified();
|
||||
void doMetadataModified();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
||||
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
const std::function<QString(const QString &)> &_nameValidator,
|
||||
bool _allowBoardSelection)
|
||||
: QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection)
|
||||
{
|
||||
nameLabel = new QLabel(this);
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
|
||||
errorLabel = new QLabel(this);
|
||||
errorLabel->hide();
|
||||
|
||||
boardLabel = new QLabel(this);
|
||||
boardCombo = new QComboBox(this);
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
// Use the icon overload explicitly so `boardName` lands in the user data role
|
||||
// (visible text is applied below in retranslateUi). The two-argument form
|
||||
// addItem({}, boardName) would be ambiguous and resolve to the icon overload
|
||||
// with empty user data, yielding empty entries and an empty getBoardName().
|
||||
boardCombo->addItem({}, {}, boardName);
|
||||
}
|
||||
if (!initialBoardName.isEmpty()) {
|
||||
int idx = boardCombo->findData(initialBoardName);
|
||||
if (idx != -1) {
|
||||
boardCombo->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(nameLabel);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(errorLabel);
|
||||
if (allowBoardSelection) {
|
||||
layout->addWidget(boardLabel);
|
||||
layout->addWidget(boardCombo);
|
||||
} else {
|
||||
boardLabel->hide();
|
||||
boardCombo->hide();
|
||||
}
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
retranslateUi();
|
||||
|
||||
connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); });
|
||||
validateName();
|
||||
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getZoneName() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getBoardName() const
|
||||
{
|
||||
return boardCombo->currentData().toString();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::setZoneName(const QString &zoneName)
|
||||
{
|
||||
nameEdit->setText(zoneName);
|
||||
nameEdit->selectAll();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::changeEvent(QEvent *event)
|
||||
{
|
||||
QDialog::changeEvent(event);
|
||||
|
||||
if (event->type() == QEvent::LanguageChange) {
|
||||
retranslateUi();
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::retranslateUi()
|
||||
{
|
||||
setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone"));
|
||||
|
||||
nameLabel->setText(tr("Zone &name:"));
|
||||
nameLabel->setBuddy(nameEdit);
|
||||
|
||||
boardLabel->setText(tr("&Parent zone:"));
|
||||
boardLabel->setBuddy(boardCombo);
|
||||
|
||||
for (int i = 0; i < boardCombo->count(); i++) {
|
||||
boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::validateName()
|
||||
{
|
||||
const QString zoneName = nameEdit->text().trimmed();
|
||||
QString error;
|
||||
if (zoneName.isEmpty()) {
|
||||
error = tr("Enter a zone name.");
|
||||
} else if (nameValidator) {
|
||||
error = nameValidator(zoneName);
|
||||
}
|
||||
|
||||
errorLabel->setText(error);
|
||||
errorLabel->setVisible(!error.isEmpty());
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, initialBoardName, nameValidator);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (chosenBoardName) {
|
||||
*chosenBoardName = dialog.getBoardName();
|
||||
}
|
||||
return dialog.getZoneName();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, {}, nameValidator, false);
|
||||
dialog.setZoneName(currentZoneName);
|
||||
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
|
||||
}
|
||||
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file deck_zone_dialog.h
|
||||
* @ingroup DeckEditorWidgets
|
||||
* @brief Shared dialog for creating custom deck zones.
|
||||
*/
|
||||
|
||||
#ifndef DECK_ZONE_DIALOG_H
|
||||
#define DECK_ZONE_DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QEvent>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
class QComboBox;
|
||||
class QDialogButtonBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
* @brief Modal dialog asking for the name and parent zone of a new custom deck zone.
|
||||
*
|
||||
* Menus construct the dialog transiently around exec(), so validation state only
|
||||
* ever reflects the name currently typed.
|
||||
*/
|
||||
class DeckZoneDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs the dialog and runs the initial validation pass.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the combo. Unknown names
|
||||
* fall back to main.
|
||||
* @param _nameValidator Given the trimmed candidate name, returns an empty string
|
||||
* when it is usable, otherwise a user-facing error message. May be empty.
|
||||
* @param _allowBoardSelection When false the parent-zone combo is hidden and the
|
||||
* dialog acts as a rename prompt for an existing zone.
|
||||
*/
|
||||
explicit DeckZoneDialog(QWidget *parent = nullptr,
|
||||
const QString &initialBoardName = {},
|
||||
const std::function<QString(const QString &)> &_nameValidator = {},
|
||||
bool _allowBoardSelection = true);
|
||||
|
||||
/**
|
||||
* @brief The trimmed zone name entered by the user.
|
||||
*/
|
||||
[[nodiscard]] QString getZoneName() const;
|
||||
|
||||
/**
|
||||
* @brief The internal name of the board zone selected in the combo.
|
||||
*/
|
||||
[[nodiscard]] QString getBoardName() const;
|
||||
|
||||
/**
|
||||
* @brief Prefills the name field, e.g. with the current name when renaming.
|
||||
*
|
||||
* @param zoneName The text to put into the name field, selected for quick editing
|
||||
*/
|
||||
void setZoneName(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
|
||||
*
|
||||
* Convenience wrapper that runs DeckZoneDialog modally.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
|
||||
* back to main.
|
||||
* @param chosenBoardName (out) The internal name of the board zone the user chose
|
||||
* @param nameValidator Optional validator forwarded to the dialog
|
||||
* @return The trimmed zone name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new name for an existing custom zone.
|
||||
*
|
||||
* Same inline validation as promptForNewZone, but without a parent-zone picker.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param currentZoneName The current name, prefilled for editing
|
||||
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
|
||||
* the current name too, so callers wanting to allow unchanged names must
|
||||
* special-case that themselves.
|
||||
* @return The trimmed new name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets every user-visible string. Runs on construction and on runtime
|
||||
* language changes.
|
||||
*/
|
||||
void retranslateUi();
|
||||
|
||||
/**
|
||||
* @brief Validates the current input, toggling Ok and the inline error label.
|
||||
*/
|
||||
void validateName();
|
||||
|
||||
QLabel *nameLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *errorLabel;
|
||||
QLabel *boardLabel;
|
||||
QComboBox *boardCombo;
|
||||
QDialogButtonBox *buttonBox;
|
||||
std::function<QString(const QString &)> nameValidator;
|
||||
bool allowBoardSelection;
|
||||
};
|
||||
|
||||
#endif // DECK_ZONE_DIALOG_H
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
#include "deck_share_utils.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QGuiApplication>
|
||||
#include <QTimeZone>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token)
|
||||
{
|
||||
return QString("cockatrice://opendeck?share=%1&hostname=%2&port=%3")
|
||||
.arg(token, client->serverName(), QString::number(client->serverPort()));
|
||||
}
|
||||
|
||||
QString copyShareLinkToClipboard(const QString &link)
|
||||
{
|
||||
QGuiApplication::clipboard()->setText(link);
|
||||
return link;
|
||||
}
|
||||
|
||||
QString formatShareExpiry(const QDateTime &expiry)
|
||||
{
|
||||
return expiry.toLocalTime().toString();
|
||||
}
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @file deck_share_utils.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DECK_SHARE_UTILS_H
|
||||
#define DECK_SHARE_UTILS_H
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
class AbstractClient;
|
||||
|
||||
/**
|
||||
* @brief Shared helpers for creating temporary deck shares.
|
||||
*/
|
||||
namespace DeckShareUtils
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Builds the cockatrice:// link for a freshly created deck share.
|
||||
* @param client Used to embed the target server's hostname and port.
|
||||
* @param token The share token from Response_DeckShareCreate.
|
||||
*/
|
||||
QString buildShareLink(const AbstractClient *client, const QString &token);
|
||||
|
||||
/**
|
||||
* @brief Copies the share link to the clipboard.
|
||||
* @return The link that was copied.
|
||||
*/
|
||||
QString copyShareLinkToClipboard(const QString &link);
|
||||
|
||||
/**
|
||||
* @brief Formats the expiration timestamp for a share.
|
||||
*/
|
||||
QString formatShareExpiry(const QDateTime &expiry);
|
||||
|
||||
} // namespace DeckShareUtils
|
||||
|
||||
#endif // DECK_SHARE_UTILS_H
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
#include "share_bar_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
|
||||
ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(12, 10, 12, 10);
|
||||
layout->setSpacing(8);
|
||||
|
||||
hintLabel = new QLabel(this);
|
||||
hintLabel->setWordWrap(true);
|
||||
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaximumWidth(260);
|
||||
|
||||
countLabel = new QLabel(this);
|
||||
|
||||
cancelButton = new QPushButton(this);
|
||||
connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested);
|
||||
|
||||
createButton = new QPushButton(this);
|
||||
createButton->setDefault(true);
|
||||
connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested);
|
||||
|
||||
layout->addWidget(hintLabel, 1);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(countLabel);
|
||||
layout->addStretch();
|
||||
layout->addWidget(cancelButton);
|
||||
layout->addWidget(createButton);
|
||||
|
||||
setLayout(layout);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void ShareBarWidget::retranslateUi()
|
||||
{
|
||||
nameEdit->setPlaceholderText(tr("Share name"));
|
||||
cancelButton->setText(tr("Cancel"));
|
||||
createButton->setText(tr("Create share link"));
|
||||
hintLabel->setText(tr("Click deck tiles to select the decks you want to share."));
|
||||
}
|
||||
|
||||
QString ShareBarWidget::name() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
void ShareBarWidget::setName(const QString &value)
|
||||
{
|
||||
nameEdit->setText(value);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCountText(const QString &text)
|
||||
{
|
||||
countLabel->setText(text);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setHintText(const QString &text, bool visible)
|
||||
{
|
||||
hintLabel->setText(text);
|
||||
hintLabel->setVisible(visible);
|
||||
}
|
||||
|
||||
void ShareBarWidget::setCreateEnabled(bool enabled)
|
||||
{
|
||||
createButton->setEnabled(enabled);
|
||||
}
|
||||
|
||||
void ShareBarWidget::focusName()
|
||||
{
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @file share_bar_widget.h
|
||||
* @ingroup DeckShareWidgets
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef SHARE_BAR_WIDGET_H
|
||||
#define SHARE_BAR_WIDGET_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
|
||||
/**
|
||||
* @brief The activated toolbar used to create a temporary deck share.
|
||||
*
|
||||
* A single reusable component shared by the local visual deck storage and the
|
||||
* remote server deck storage tabs, so the share workflow renders identically in
|
||||
* both places. It owns its own widgets, strings, and layout; the owning tab only
|
||||
* sets the count/hint text and reacts to the create/cancel signals.
|
||||
*/
|
||||
class ShareBarWidget final : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ShareBarWidget(QWidget *parent = nullptr);
|
||||
|
||||
void retranslateUi();
|
||||
|
||||
/** @return The trimmed name entered by the user. */
|
||||
[[nodiscard]] QString name() const;
|
||||
|
||||
/** @brief Resets the name field to the given default. */
|
||||
void setName(const QString &name);
|
||||
|
||||
/** @brief Sets the selected-count summary label text. */
|
||||
void setCountText(const QString &text);
|
||||
|
||||
/** @brief Sets the explainer hint text, showing it when @p visible is true. */
|
||||
void setHintText(const QString &text, bool visible);
|
||||
|
||||
/** @brief Enables or disables the create-share-link button (guards double submission). */
|
||||
void setCreateEnabled(bool enabled);
|
||||
|
||||
/** @brief Moves keyboard focus to the name field. */
|
||||
void focusName();
|
||||
|
||||
signals:
|
||||
void createRequested();
|
||||
void cancelRequested();
|
||||
|
||||
private:
|
||||
QLabel *hintLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *countLabel;
|
||||
QPushButton *cancelButton;
|
||||
QPushButton *createButton;
|
||||
};
|
||||
|
||||
#endif // SHARE_BAR_WIDGET_H
|
||||
89
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
89
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#include "dlg_share_deck.h"
|
||||
|
||||
#include "../cards/additional_info/deck_color_identity.h"
|
||||
#include "../deck_share/deck_share_utils.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimeZone>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *_parent)
|
||||
: QDialog(_parent), client(_client), deck(_deck)
|
||||
{
|
||||
setWindowTitle(tr("Share deck"));
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setText(tr("Shared deck"));
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
form->addRow(tr("Share name:"), nameEdit);
|
||||
layout->addLayout(form);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link"));
|
||||
buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel"));
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject);
|
||||
this->buttonBox = buttonBox;
|
||||
layout->addWidget(buttonBox);
|
||||
}
|
||||
|
||||
void DlgShareDeck::actShare()
|
||||
{
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
|
||||
|
||||
Command_DeckShareCreate cmd;
|
||||
cmd.set_name(nameEdit->text().trimmed().toStdString());
|
||||
if (cmd.name().empty()) {
|
||||
cmd.set_name(tr("Shared deck").toStdString());
|
||||
}
|
||||
|
||||
DeckShareItem *item = cmd.add_items();
|
||||
item->set_deck_list(deck->writeToString_Native().toStdString());
|
||||
item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString());
|
||||
|
||||
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished);
|
||||
client->sendCommand(pend);
|
||||
}
|
||||
|
||||
void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/)
|
||||
{
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
|
||||
QMessageBox::critical(this, tr("Share deck"),
|
||||
tr("Failed to create the share link (server response code %1).")
|
||||
.arg(QString::number(static_cast<int>(response.response_code()))));
|
||||
return;
|
||||
}
|
||||
|
||||
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
|
||||
const QString token = QString::fromStdString(resp.token());
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
|
||||
#else
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
|
||||
#endif
|
||||
|
||||
const QString link = DeckShareUtils::buildShareLink(client, token);
|
||||
DeckShareUtils::copyShareLinkToClipboard(link);
|
||||
|
||||
QMessageBox::information(this, tr("Share deck"),
|
||||
tr("Share link created and copied to the clipboard:\n\n%1\n\n"
|
||||
"The share expires on %2.")
|
||||
.arg(link, DeckShareUtils::formatShareExpiry(expiry)));
|
||||
accept();
|
||||
}
|
||||
43
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
43
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @file dlg_share_deck.h
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DLG_SHARE_DECK_H
|
||||
#define DLG_SHARE_DECK_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QSharedPointer>
|
||||
|
||||
class AbstractClient;
|
||||
class CommandContainer;
|
||||
class DeckList;
|
||||
class QDialogButtonBox;
|
||||
class QLineEdit;
|
||||
class Response;
|
||||
|
||||
/**
|
||||
* @brief Slim dialog to create a temporary share for the deck open in the editor.
|
||||
*
|
||||
* Asks for a share name, sends Command_DeckShareCreate for the single inline
|
||||
* deck, and copies the resulting link to the clipboard.
|
||||
*/
|
||||
class DlgShareDeck : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_deck, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void actShare();
|
||||
void shareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
|
||||
private:
|
||||
AbstractClient *client;
|
||||
QSharedPointer<DeckList> deck;
|
||||
QLineEdit *nameEdit;
|
||||
QDialogButtonBox *buttonBox;
|
||||
};
|
||||
|
||||
#endif // DLG_SHARE_DECK_H
|
||||
|
|
@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
|
|||
aSaveDeckAs = new QAction(QString(), this);
|
||||
connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs);
|
||||
|
||||
aShareDeck = new QAction(QString(), this);
|
||||
connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck);
|
||||
|
||||
aLoadDeckFromClipboard = new QAction(QString(), this);
|
||||
connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard);
|
||||
|
||||
|
|
@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
|
|||
addMenu(loadRecentDeckMenu);
|
||||
addAction(aSaveDeck);
|
||||
addAction(aSaveDeckAs);
|
||||
addAction(aShareDeck);
|
||||
addSeparator();
|
||||
addAction(aLoadDeckFromClipboard);
|
||||
addMenu(editDeckInClipboardMenu);
|
||||
|
|
@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus)
|
|||
{
|
||||
aSaveDeck->setEnabled(newStatus);
|
||||
aSaveDeckAs->setEnabled(newStatus);
|
||||
aShareDeck->setEnabled(newStatus);
|
||||
aSaveDeckToClipboard->setEnabled(newStatus);
|
||||
aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus);
|
||||
aSaveDeckToClipboardRaw->setEnabled(newStatus);
|
||||
|
|
@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi()
|
|||
aClearRecents->setText(tr("Clear"));
|
||||
aSaveDeck->setText(tr("&Save deck"));
|
||||
aSaveDeckAs->setText(tr("Save deck &as..."));
|
||||
aShareDeck->setText(tr("Share deck..."));
|
||||
|
||||
aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard..."));
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ public:
|
|||
QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard,
|
||||
*aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo,
|
||||
*aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite,
|
||||
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose;
|
||||
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck,
|
||||
*aClose;
|
||||
QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu;
|
||||
|
||||
void setSaveStatus(bool newStatus);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include "../interface/widgets/dialogs/dlg_load_deck.h"
|
||||
#include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h"
|
||||
#include "../interface/widgets/dialogs/dlg_load_deck_from_website.h"
|
||||
#include "../interface/widgets/dialogs/dlg_share_deck.h"
|
||||
#include "../utility/visibility_change_listener.h"
|
||||
#include "tab_supervisor.h"
|
||||
|
||||
|
|
@ -382,6 +383,25 @@ bool AbstractTabDeckEditor::actSaveDeckAs()
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Opens the deck share dialog with the current deck preselected.
|
||||
*/
|
||||
void AbstractTabDeckEditor::actShareDeck()
|
||||
{
|
||||
if (tabSupervisor->getClient()->getStatus() != StatusLoggedIn) {
|
||||
QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QSharedPointer<DeckList> deck = deckStateManager->getDeckListShared();
|
||||
if (deck->isBlankDeck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DlgShareDeck shareDialog(tabSupervisor->getClient(), deck, this);
|
||||
shareDialog.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Callback for remote deck save completion.
|
||||
* @param response Server response.
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ protected slots:
|
|||
/** @brief Saves the current deck under a new name. */
|
||||
virtual bool actSaveDeckAs();
|
||||
|
||||
/** @brief Opens the deck share dialog for the current deck. */
|
||||
void actShareDeck();
|
||||
|
||||
/** @brief Loads a deck from the clipboard. */
|
||||
virtual void actLoadDeckFromClipboard();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,24 @@
|
|||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../deck_loader/deck_loader.h"
|
||||
#include "../deck_share/deck_share_utils.h"
|
||||
#include "../deck_share/share_bar_widget.h"
|
||||
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
|
||||
#include "../interface/widgets/utility/get_text_with_max.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileSystemModel>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QTimeZone>
|
||||
#include <QToolBar>
|
||||
#include <QTreeView>
|
||||
#include <QUrl>
|
||||
|
|
@ -23,9 +29,11 @@
|
|||
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
|
|
@ -91,8 +99,17 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
|||
serverDirView = new RemoteDeckList_TreeWidget(client);
|
||||
|
||||
connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick);
|
||||
connect(serverDirView->selectionModel(), &QItemSelectionModel::selectionChanged, this,
|
||||
[this] { onServerSelectionChanged(); });
|
||||
|
||||
// Share bar for creating a share link from the selected server decks/folders.
|
||||
shareBar = new ShareBarWidget(this);
|
||||
connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorage::actShareSelection);
|
||||
connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorage::cancelShareDecks);
|
||||
shareBar->setVisible(false);
|
||||
|
||||
QVBoxLayout *rightVbox = new QVBoxLayout;
|
||||
rightVbox->addWidget(shareBar);
|
||||
rightVbox->addWidget(serverDirView);
|
||||
rightVbox->addLayout(rightToolBarLayout);
|
||||
rightGroupBox = new QGroupBox;
|
||||
|
|
@ -138,6 +155,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
|||
aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row"));
|
||||
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
|
||||
|
||||
aShareDecks = new QAction(this);
|
||||
aShareDecks->setIcon(QPixmap("theme:icons/share"));
|
||||
connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks);
|
||||
|
||||
// Add actions to toolbars
|
||||
leftToolBar->addAction(aOpenLocalDeck);
|
||||
leftToolBar->addAction(aRenameLocal);
|
||||
|
|
@ -149,6 +170,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
|
|||
|
||||
rightToolBar->addAction(aOpenRemoteDeck);
|
||||
rightToolBar->addAction(aDownload);
|
||||
rightToolBar->addAction(aShareDecks);
|
||||
rightToolBar->addAction(aNewFolder);
|
||||
rightToolBar->addAction(aDeleteRemoteDeck);
|
||||
|
||||
|
|
@ -177,7 +199,9 @@ void TabDeckStorage::retranslateUi()
|
|||
aNewFolder->setText(tr("New folder"));
|
||||
aDeleteLocalDeck->setText(tr("Delete"));
|
||||
aDeleteRemoteDeck->setText(tr("Delete"));
|
||||
aShareDecks->setText(tr("Share decks"));
|
||||
aOpenDecksFolder->setText(tr("Open decks folder"));
|
||||
shareBar->retranslateUi();
|
||||
}
|
||||
|
||||
QString TabDeckStorage::getTargetPath() const
|
||||
|
|
@ -219,12 +243,14 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
|
|||
aUpload->setEnabled(enabled);
|
||||
aOpenRemoteDeck->setEnabled(enabled);
|
||||
aDownload->setEnabled(enabled);
|
||||
aShareDecks->setEnabled(enabled);
|
||||
aNewFolder->setEnabled(enabled);
|
||||
aDeleteRemoteDeck->setEnabled(enabled);
|
||||
|
||||
if (enabled) {
|
||||
serverDirView->refreshTree();
|
||||
} else {
|
||||
setShareModeEnabled(false);
|
||||
serverDirView->clearTree();
|
||||
}
|
||||
}
|
||||
|
|
@ -625,3 +651,151 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman
|
|||
serverDirView->removeNode(toDelete);
|
||||
}
|
||||
}
|
||||
|
||||
void TabDeckStorage::actShareDecks()
|
||||
{
|
||||
setShareModeEnabled(true);
|
||||
}
|
||||
|
||||
void TabDeckStorage::cancelShareDecks()
|
||||
{
|
||||
setShareModeEnabled(false);
|
||||
}
|
||||
|
||||
void TabDeckStorage::setShareModeEnabled(bool enabled)
|
||||
{
|
||||
shareBar->setVisible(enabled);
|
||||
if (enabled) {
|
||||
shareBar->setCreateEnabled(true);
|
||||
shareBar->setName(tr("Shared decks"));
|
||||
onServerSelectionChanged();
|
||||
shareBar->focusName();
|
||||
} else {
|
||||
serverDirView->clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
void TabDeckStorage::onServerSelectionChanged()
|
||||
{
|
||||
if (!shareBar->isVisible()) {
|
||||
return;
|
||||
}
|
||||
const auto selection = serverDirView->getCurrentSelection();
|
||||
int folders = 0;
|
||||
int files = 0;
|
||||
for (const auto *node : selection) {
|
||||
if (dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
|
||||
++folders;
|
||||
} else {
|
||||
++files;
|
||||
}
|
||||
}
|
||||
|
||||
QString hint;
|
||||
if (folders > 1) {
|
||||
hint = tr("Only one folder can be shared at a time.");
|
||||
} else if (folders > 0 && files > 0) {
|
||||
hint = tr("Share either a folder or decks, not both.");
|
||||
} else if (folders == 0 && files == 0) {
|
||||
hint = tr("Select folders or decks in the tree to share.");
|
||||
}
|
||||
shareBar->setHintText(hint, !hint.isEmpty());
|
||||
|
||||
QStringList parts;
|
||||
if (folders > 0) {
|
||||
parts << tr("%n folder(s)", "", folders);
|
||||
}
|
||||
if (files > 0) {
|
||||
parts << tr("%n deck(s)", "", files);
|
||||
}
|
||||
shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") : tr("Selected: %1").arg(parts.join(tr(", "))));
|
||||
}
|
||||
|
||||
void TabDeckStorage::actShareSelection()
|
||||
{
|
||||
const auto selection = serverDirView->getCurrentSelection();
|
||||
QString sharedFolder;
|
||||
bool hasFile = false;
|
||||
bool hasFolder = false;
|
||||
for (const auto *node : selection) {
|
||||
if (const auto *dirNode = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
|
||||
hasFolder = true;
|
||||
if (!sharedFolder.isEmpty()) {
|
||||
showShareNotice(tr("Only one folder can be shared at a time."), true);
|
||||
return;
|
||||
}
|
||||
sharedFolder = dirNode->getPath();
|
||||
} else {
|
||||
hasFile = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFile && hasFolder) {
|
||||
showShareNotice(tr("Share either a folder or decks, not both."), true);
|
||||
return;
|
||||
}
|
||||
if (hasFolder && sharedFolder.isEmpty()) {
|
||||
showShareNotice(tr("The root folder cannot be shared."), true);
|
||||
return;
|
||||
}
|
||||
|
||||
Command_DeckShareCreate cmd;
|
||||
cmd.set_name(shareBar->name().toStdString());
|
||||
if (cmd.name().empty()) {
|
||||
cmd.set_name(tr("Shared decks").toStdString());
|
||||
}
|
||||
|
||||
if (!sharedFolder.isEmpty()) {
|
||||
cmd.set_folder_path(sharedFolder.toStdString());
|
||||
} else {
|
||||
for (const auto *node : selection) {
|
||||
if (const auto *fileNode = dynamic_cast<const RemoteDeckList_TreeModel::FileNode *>(node)) {
|
||||
DeckShareItem *item = cmd.add_items();
|
||||
item->set_deck_id(fileNode->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.items_size() == 0 && cmd.folder_path().empty()) {
|
||||
showShareNotice(tr("Select decks to share."), true);
|
||||
return;
|
||||
}
|
||||
|
||||
shareBar->setCreateEnabled(false);
|
||||
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::shareFromTreeFinished);
|
||||
client->sendCommand(pend);
|
||||
}
|
||||
|
||||
void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/)
|
||||
{
|
||||
shareBar->setCreateEnabled(true);
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
qWarning() << "failed to create deck share:" << response.response_code();
|
||||
showShareNotice(tr("Failed to create the share link (server response code %1).")
|
||||
.arg(QString::number(static_cast<int>(response.response_code()))),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
|
||||
const QString token = QString::fromStdString(resp.token());
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
|
||||
#else
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
|
||||
#endif
|
||||
|
||||
const QString link = DeckShareUtils::buildShareLink(client, token);
|
||||
DeckShareUtils::copyShareLinkToClipboard(link);
|
||||
|
||||
showShareNotice(
|
||||
tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry)));
|
||||
setShareModeEnabled(false);
|
||||
}
|
||||
|
||||
void TabDeckStorage::showShareNotice(const QString &message, bool warning)
|
||||
{
|
||||
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message,
|
||||
QMessageBox::Ok, this);
|
||||
box.exec();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class QTreeWidgetItem;
|
|||
class QGroupBox;
|
||||
class CommandContainer;
|
||||
class Response;
|
||||
class ShareBarWidget;
|
||||
|
||||
class TabDeckStorage : public Tab
|
||||
{
|
||||
|
|
@ -35,14 +36,19 @@ private:
|
|||
QToolBar *leftToolBar, *rightToolBar;
|
||||
RemoteDeckList_TreeWidget *serverDirView;
|
||||
QGroupBox *leftGroupBox, *rightGroupBox;
|
||||
ShareBarWidget *shareBar;
|
||||
|
||||
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
|
||||
QAction *aOpenDecksFolder;
|
||||
QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck;
|
||||
QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aNewFolder, *aDeleteRemoteDeck;
|
||||
QString getTargetPath() const;
|
||||
|
||||
void setRemoteEnabled(bool enabled);
|
||||
|
||||
void showShareNotice(const QString &message, bool warning = false);
|
||||
|
||||
void setShareModeEnabled(bool enabled);
|
||||
|
||||
void uploadDeck(const QString &filePath, const QString &targetPath);
|
||||
void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node);
|
||||
|
||||
|
|
@ -75,6 +81,12 @@ private slots:
|
|||
void actNewFolder();
|
||||
void newFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
|
||||
void actShareDecks();
|
||||
void actShareSelection();
|
||||
void cancelShareDecks();
|
||||
void onServerSelectionChanged();
|
||||
void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer);
|
||||
|
||||
void actDeleteRemoteDeck();
|
||||
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../../../client/settings/shortcuts_settings.h"
|
||||
#include "../../cards/card_info_display_widget.h"
|
||||
#include "../../deck_editor/deck_state_manager.h"
|
||||
#include "../../deck_editor/deck_zone_dialog.h"
|
||||
#include "../../filters/filter_builder.h"
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/widgets/cards/card_info_frame_widget.h"
|
||||
|
|
@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame()
|
|||
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
|
||||
&TabDeckEditorVisual::showPrintingSelector);
|
||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
|
||||
tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); });
|
||||
|
||||
centralFrame->addWidget(tabContainer);
|
||||
setCentralWidget(centralWidget);
|
||||
|
|
@ -269,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs()
|
|||
return result;
|
||||
}
|
||||
|
||||
/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */
|
||||
QString TabDeckEditorVisual::createNewZone()
|
||||
{
|
||||
QString boardName;
|
||||
const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
return zoneName;
|
||||
}
|
||||
|
||||
/** @brief Refreshes keyboard shortcuts for this tab from settings. */
|
||||
void TabDeckEditorVisual::refreshShortcuts()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ public slots:
|
|||
*/
|
||||
bool actSaveDeckAs() override;
|
||||
|
||||
/**
|
||||
* @brief Prompts for and creates a new custom deck zone.
|
||||
* @return The name of the created zone, or an empty string if creation was cancelled.
|
||||
*/
|
||||
QString createNewZone();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets the deck for this tab and selects the sub-tab to open on
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
#include "tab_deck_storage_visual.h"
|
||||
|
||||
#include "../../../deck_loader/deck_loader.h"
|
||||
#include "../../cards/additional_info/deck_color_identity.h"
|
||||
#include "../../deck_share/deck_share_utils.h"
|
||||
#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h"
|
||||
#include "../tab_supervisor.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QMessageBox>
|
||||
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
|
||||
TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor)
|
||||
: Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this))
|
||||
|
|
@ -14,12 +25,42 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor)
|
|||
&TabDeckStorageVisual::actOpenLocalDeck);
|
||||
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this,
|
||||
&TabDeckStorageVisual::openDeckEditor);
|
||||
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareDeckRequested, this,
|
||||
&TabDeckStorageVisual::actShareDeck);
|
||||
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareSelectionChanged, this,
|
||||
&TabDeckStorageVisual::onShareSelectionChanged);
|
||||
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareRequested, this, [this] {
|
||||
if (shareDeckAvailable) {
|
||||
enterShareMode();
|
||||
}
|
||||
});
|
||||
|
||||
AbstractClient *client = tabSupervisor->getClient();
|
||||
connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged);
|
||||
shareDeckAvailable = (client->getStatus() == StatusLoggedIn);
|
||||
visualDeckStorageWidget->setShareAvailable(shareDeckAvailable);
|
||||
|
||||
auto *widget = new QWidget(this);
|
||||
auto *layout = new QVBoxLayout(widget);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->setSpacing(0);
|
||||
widget->setLayout(layout);
|
||||
this->setCentralWidget(widget);
|
||||
layout->addWidget(visualDeckStorageWidget);
|
||||
|
||||
shareBar = new ShareBarWidget(this);
|
||||
connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorageVisual::actShareSelected);
|
||||
connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorageVisual::exitShareMode);
|
||||
|
||||
layout->insertWidget(0, shareBar);
|
||||
shareBar->setVisible(false);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::retranslateUi()
|
||||
{
|
||||
shareBar->retranslateUi();
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
|
||||
|
|
@ -33,3 +74,127 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
|
|||
|
||||
emit openDeckEditor(deckOpt.value());
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles)
|
||||
{
|
||||
if (!shareDeckAvailable) {
|
||||
return; // sharing is gated on being logged in
|
||||
}
|
||||
shareBar->setCreateEnabled(true);
|
||||
visualDeckStorageWidget->setShareSelectable(true);
|
||||
visualDeckStorageWidget->setShareSelectedFiles(preselectFiles);
|
||||
shareBar->setName(tr("Shared decks"));
|
||||
shareBar->setVisible(true);
|
||||
updateShareHint();
|
||||
shareBar->focusName();
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::exitShareMode()
|
||||
{
|
||||
visualDeckStorageWidget->setShareSelectable(false);
|
||||
visualDeckStorageWidget->clearShareSelection();
|
||||
shareBar->setVisible(false);
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::actShareDeck(const QString &filePath)
|
||||
{
|
||||
if (!shareDeckAvailable) {
|
||||
QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck."));
|
||||
return;
|
||||
}
|
||||
enterShareMode({filePath});
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::onShareSelectionChanged()
|
||||
{
|
||||
if (shareBar->isVisible()) {
|
||||
updateShareHint();
|
||||
}
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::updateShareHint()
|
||||
{
|
||||
const int count = visualDeckStorageWidget->selectedFilePaths().size();
|
||||
shareBar->setCountText(tr("%n deck(s)", "", count));
|
||||
if (count == 0) {
|
||||
shareBar->setHintText(tr("Click deck tiles to select the decks you want to share."), true);
|
||||
} else {
|
||||
shareBar->setHintText(tr("%n deck(s) selected. Create the link to share %1 with other players.", "", count)
|
||||
.arg(count == 1 ? tr("it") : tr("them")),
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::actShareSelected()
|
||||
{
|
||||
const QStringList filePaths = visualDeckStorageWidget->selectedFilePaths();
|
||||
if (filePaths.isEmpty()) {
|
||||
QMessageBox::warning(this, tr("Share decks"), tr("Select at least one deck to share."));
|
||||
return;
|
||||
}
|
||||
|
||||
Command_DeckShareCreate cmd;
|
||||
cmd.set_name(shareBar->name().toStdString());
|
||||
if (cmd.name().empty()) {
|
||||
cmd.set_name(tr("Shared decks").toStdString());
|
||||
}
|
||||
|
||||
for (const QString &filePath : filePaths) {
|
||||
std::optional<LoadedDeck> deckOpt =
|
||||
DeckLoader::loadFromFile(filePath, DeckFileFormat::getFormatFromName(filePath), true);
|
||||
if (!deckOpt) {
|
||||
QMessageBox::warning(this, tr("Share decks"), tr("Unable to load deck file %1").arg(filePath));
|
||||
return;
|
||||
}
|
||||
DeckShareItem *item = cmd.add_items();
|
||||
item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString());
|
||||
item->set_color_identity(getDeckColorIdentity(deckOpt->deckList, CardDatabaseManager::query()).toStdString());
|
||||
}
|
||||
|
||||
shareBar->setCreateEnabled(false);
|
||||
PendingCommand *pend = tabSupervisor->getClient()->prepareSessionCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &TabDeckStorageVisual::shareFinished);
|
||||
tabSupervisor->getClient()->sendCommand(pend);
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/)
|
||||
{
|
||||
shareBar->setCreateEnabled(true);
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
showShareNotice(tr("Failed to create the share link (server response code %1).")
|
||||
.arg(QString::number(static_cast<int>(response.response_code()))),
|
||||
true);
|
||||
return;
|
||||
}
|
||||
|
||||
const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext);
|
||||
const QString token = QString::fromStdString(resp.token());
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC);
|
||||
#else
|
||||
const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC);
|
||||
#endif
|
||||
|
||||
const QString link = DeckShareUtils::buildShareLink(tabSupervisor->getClient(), token);
|
||||
DeckShareUtils::copyShareLinkToClipboard(link);
|
||||
|
||||
showShareNotice(
|
||||
tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(expiry)));
|
||||
exitShareMode();
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status)
|
||||
{
|
||||
shareDeckAvailable = (status == StatusLoggedIn);
|
||||
visualDeckStorageWidget->setShareAvailable(shareDeckAvailable);
|
||||
if (!shareDeckAvailable && shareBar->isVisible()) {
|
||||
exitShareMode();
|
||||
}
|
||||
}
|
||||
|
||||
void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning)
|
||||
{
|
||||
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message,
|
||||
QMessageBox::Ok, this);
|
||||
box.exec();
|
||||
}
|
||||
|
|
@ -7,8 +7,12 @@
|
|||
#ifndef TAB_DECK_STORAGE_VISUAL_H
|
||||
#define TAB_DECK_STORAGE_VISUAL_H
|
||||
|
||||
#include "../../deck_share/share_bar_widget.h"
|
||||
#include "../tab.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||
|
||||
struct LoadedDeck;
|
||||
class AbstractClient;
|
||||
class CommandContainer;
|
||||
|
|
@ -27,22 +31,49 @@ class TabDeckStorageVisual final : public Tab
|
|||
Q_OBJECT
|
||||
public:
|
||||
explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor);
|
||||
void retranslateUi() override
|
||||
{
|
||||
}
|
||||
void retranslateUi() override;
|
||||
|
||||
[[nodiscard]] QString getTabText() const override
|
||||
{
|
||||
return tr("Visual Deck Storage");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Enters share-selection mode, optionally preselecting the given deck files.
|
||||
*/
|
||||
void enterShareMode(const QStringList &preselectFiles = {});
|
||||
|
||||
/**
|
||||
* @brief Leaves share-selection mode and clears the selection.
|
||||
*/
|
||||
void exitShareMode();
|
||||
|
||||
[[nodiscard]] bool isShareModeActive() const
|
||||
{
|
||||
return shareBar->isVisible();
|
||||
}
|
||||
|
||||
public slots:
|
||||
void actOpenLocalDeck(const QString &filePath);
|
||||
void actShareDeck(const QString &filePath);
|
||||
|
||||
signals:
|
||||
void openDeckEditor(const LoadedDeck &deck);
|
||||
|
||||
private slots:
|
||||
void actShareSelected();
|
||||
void shareFinished(const Response &response, const CommandContainer &commandContainer);
|
||||
void onShareSelectionChanged();
|
||||
void handleConnectionChanged(ClientStatus status);
|
||||
|
||||
private:
|
||||
void showShareNotice(const QString &message, bool warning = false);
|
||||
void updateShareHint();
|
||||
|
||||
VisualDeckStorageWidget *visualDeckStorageWidget;
|
||||
|
||||
ShareBarWidget *shareBar;
|
||||
bool shareDeckAvailable = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
#include <libcockatrice/card/card_info_comparator.h>
|
||||
#include <libcockatrice/card/database/card_database.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <utility>
|
||||
|
||||
|
|
@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
databaseView->setItemDelegate(nullptr);
|
||||
databaseView->setVisible(false);
|
||||
|
||||
// Without a deck model there is nothing to add cards to, so the zone menu stays hidden.
|
||||
if (deckListModel) {
|
||||
databaseView->setZoneMenuProvider(
|
||||
[deckListModel]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this] { return newZoneCreator ? newZoneCreator() : QString(); });
|
||||
}
|
||||
|
||||
searchEdit->setTreeView(databaseView);
|
||||
searchEdit->installEventFilter(databaseView->getKeySignals());
|
||||
|
||||
|
|
@ -195,6 +209,11 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event)
|
|||
initializeFilters();
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function<QString()> &creator)
|
||||
{
|
||||
newZoneCreator = creator;
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayWidget::retranslateUi()
|
||||
{
|
||||
databaseLoadIndicator->setText(tr("Loading database ..."));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include <QVBoxLayout>
|
||||
#include <QWheelEvent>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
#include <libcockatrice/models/database/card_database_model.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
#include <qscrollarea.h>
|
||||
|
|
@ -46,6 +47,12 @@ public:
|
|||
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
|
||||
void setDeckList(const DeckList &new_deck_list_model);
|
||||
|
||||
/**
|
||||
* @brief Sets the callback used to create a custom zone from the add-to-zone menu.
|
||||
* The callback returns the name of the created zone, or an empty string if creation was cancelled.
|
||||
*/
|
||||
void setNewZoneCreator(const std::function<QString()> &creator);
|
||||
|
||||
CardDatabaseDisplayModel *getDatabaseDisplayModel()
|
||||
{
|
||||
return databaseDisplayModel;
|
||||
|
|
@ -106,6 +113,7 @@ private:
|
|||
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
|
||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||
CardDatabaseView *databaseView;
|
||||
std::function<QString()> newZoneCreator;
|
||||
QList<ExactCard> *cards;
|
||||
QVBoxLayout *mainLayout;
|
||||
QScrollArea *scrollArea;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
#include "deck_preview_color_identity_filter_widget.h"
|
||||
|
||||
#include "../../cards/additional_info/mana_symbol_widget.h"
|
||||
#include "../visual_deck_storage_widget.h"
|
||||
|
||||
#include <QSet>
|
||||
|
||||
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent)
|
||||
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent)
|
||||
: QWidget(parent), layout(new QHBoxLayout(this))
|
||||
{
|
||||
setLayout(layout);
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@
|
|||
#include <QSet>
|
||||
#include <QWidget>
|
||||
|
||||
class VisualDeckStorageWidget;
|
||||
|
||||
class DeckPreviewColorIdentityFilterWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent);
|
||||
explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr);
|
||||
void retranslateUi();
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h"
|
||||
#include "../../../deck_loader/deck_loader.h"
|
||||
#include "../../cards/additional_info/color_identity_widget.h"
|
||||
#include "../../cards/additional_info/deck_color_identity.h"
|
||||
#include "../../cards/deck_preview_card_picture_widget.h"
|
||||
#include "../visual_deck_storage_quick_settings_widget.h"
|
||||
#include "../visual_deck_storage_tag_filter_widget.h"
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
#include "deck_preview_deck_tags_display_widget.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QFrame>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
|
|
@ -27,7 +29,7 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
|
|||
VisualDeckStorageWidget *_visualDeckStorageWidget,
|
||||
VisualDeckStorageModel *_model,
|
||||
const QString &_filePath)
|
||||
: QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath)
|
||||
: QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model)
|
||||
{
|
||||
layout = new QVBoxLayout(this);
|
||||
setLayout(layout);
|
||||
|
|
@ -36,6 +38,8 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
|
|||
new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled);
|
||||
pictureWidget->setFontSize(24);
|
||||
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent);
|
||||
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this,
|
||||
&DeckPreviewWidget::imageSingleClicked);
|
||||
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
|
||||
&DeckPreviewWidget::imageDoubleClickedEvent);
|
||||
bannerCardDisplayWidget = pictureWidget;
|
||||
|
|
@ -99,6 +103,15 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
|
|||
// to keep the resize handler from searching the widget tree on every layout pass.
|
||||
fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel,
|
||||
bannerCardComboBox};
|
||||
|
||||
// Child of the banner widget so the frame tracks the banner's selection animation
|
||||
// (which animates the banner's position) instead of staying at a stale static offset.
|
||||
selectionFrame = new QFrame(bannerCardDisplayWidget);
|
||||
selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
selectionFrame->setStyleSheet(QStringLiteral(
|
||||
"QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }"));
|
||||
selectionFrame->setVisible(false);
|
||||
bannerCardDisplayWidget->installEventFilter(this);
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::retranslateUi()
|
||||
|
|
@ -122,6 +135,63 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event)
|
|||
for (QWidget *widget : fixedWidthChildren) {
|
||||
widget->setMaximumWidth(width);
|
||||
}
|
||||
updateSelectionFrameGeometry();
|
||||
}
|
||||
|
||||
bool DeckPreviewWidget::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == bannerCardDisplayWidget && (event->type() == QEvent::Resize || event->type() == QEvent::Move)) {
|
||||
updateSelectionFrameGeometry();
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::setShareSelectable(bool selectable)
|
||||
{
|
||||
shareSelectable = selectable;
|
||||
if (!selectable) {
|
||||
setShareSelected(false);
|
||||
}
|
||||
updateSelectionStyle();
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::setShareSelected(bool selected)
|
||||
{
|
||||
if (shareSelected == selected) {
|
||||
return;
|
||||
}
|
||||
shareSelected = selected;
|
||||
updateSelectionStyle();
|
||||
emit shareSelectionToggled(selected);
|
||||
}
|
||||
|
||||
bool DeckPreviewWidget::isShareSelected() const
|
||||
{
|
||||
return shareSelected;
|
||||
}
|
||||
|
||||
bool DeckPreviewWidget::isShareSelectable() const
|
||||
{
|
||||
return shareSelectable;
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::updateSelectionFrameGeometry()
|
||||
{
|
||||
if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) {
|
||||
return;
|
||||
}
|
||||
// Frame is a child of the banner, so it is positioned in banner coordinates and
|
||||
// tracks the banner's selection animation automatically. A small inset keeps the
|
||||
// highlight visible around the card art without occluding it.
|
||||
selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1));
|
||||
selectionFrame->raise();
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::updateSelectionStyle()
|
||||
{
|
||||
if (selectionFrame != nullptr) {
|
||||
selectionFrame->setVisible(shareSelectable && isShareSelected());
|
||||
}
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::enterEvent(QEnterEvent *event)
|
||||
|
|
@ -226,10 +296,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the banner card text.
|
||||
* This also calls `refreshBannerCardToolTip`, since those two often need to be updated together.
|
||||
*/
|
||||
void DeckPreviewWidget::refreshBannerCardText()
|
||||
{
|
||||
bannerCardDisplayWidget->setOverlayText(getDisplayName());
|
||||
|
|
@ -337,10 +403,20 @@ void DeckPreviewWidget::imageClickedEvent(QMouseEvent *event, DeckPreviewCardPic
|
|||
}
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::imageSingleClicked()
|
||||
{
|
||||
if (isShareSelectable()) {
|
||||
setShareSelected(!isShareSelected());
|
||||
}
|
||||
}
|
||||
|
||||
void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance)
|
||||
{
|
||||
Q_UNUSED(event);
|
||||
Q_UNUSED(instance);
|
||||
if (isShareSelectable()) {
|
||||
return; // in share mode a double click would just toggle a single selection
|
||||
}
|
||||
emit deckLoadRequested(filePath);
|
||||
}
|
||||
|
||||
|
|
@ -365,6 +441,9 @@ QMenu *DeckPreviewWidget::createRightClickMenu()
|
|||
}
|
||||
});
|
||||
|
||||
connect(menu->addAction(tr("Share deck...")), &QAction::triggered, this,
|
||||
[this] { emit shareDeckRequested(filePath); });
|
||||
|
||||
connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget,
|
||||
&DeckPreviewDeckTagsDisplayWidget::openTagEditDlg);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
#include <QWidget>
|
||||
|
||||
class QEnterEvent;
|
||||
class QFrame;
|
||||
class QLabel;
|
||||
class QMenu;
|
||||
class QMouseEvent;
|
||||
|
|
@ -41,9 +42,19 @@ public:
|
|||
*/
|
||||
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
|
||||
|
||||
/** @brief The path of the deck file backing this preview. */
|
||||
QString filePath;
|
||||
|
||||
void setShareSelectable(bool selectable);
|
||||
void setShareSelected(bool selected);
|
||||
[[nodiscard]] bool isShareSelected() const;
|
||||
[[nodiscard]] bool isShareSelectable() const;
|
||||
|
||||
signals:
|
||||
void deckLoadRequested(const QString &filePath);
|
||||
void openDeckEditor(const LoadedDeck &deck);
|
||||
void shareDeckRequested(const QString &filePath);
|
||||
void shareSelectionToggled(bool selected);
|
||||
|
||||
public slots:
|
||||
/**
|
||||
|
|
@ -66,6 +77,7 @@ public slots:
|
|||
protected:
|
||||
void enterEvent(QEnterEvent *event) override;
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] int row() const;
|
||||
|
|
@ -76,6 +88,7 @@ private:
|
|||
QMenu *createRightClickMenu();
|
||||
void addSetBannerCardMenu(QMenu *menu);
|
||||
void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
void imageSingleClicked();
|
||||
void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
|
||||
|
||||
void actRenameDeck();
|
||||
|
|
@ -84,7 +97,6 @@ private:
|
|||
|
||||
VisualDeckStorageWidget *visualDeckStorageWidget;
|
||||
VisualDeckStorageModel *model;
|
||||
QString filePath;
|
||||
QVBoxLayout *layout;
|
||||
ColorIdentityWidget *colorIdentityWidget;
|
||||
DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget;
|
||||
|
|
@ -92,6 +104,12 @@ private:
|
|||
QComboBox *bannerCardComboBox;
|
||||
QList<QWidget *> fixedWidthChildren; ///< Children clamped to the picture width on resize.
|
||||
int lastKnownBannerWidth = -1; ///< The picture width last applied to the children.
|
||||
QFrame *selectionFrame = nullptr;
|
||||
bool shareSelectable = false;
|
||||
bool shareSelected = false;
|
||||
|
||||
void updateSelectionStyle();
|
||||
void updateSelectionFrameGeometry();
|
||||
};
|
||||
|
||||
class NoScrollFilter : public QObject
|
||||
|
|
|
|||
|
|
@ -209,6 +209,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
|
|||
&VisualDeckStorageWidget::deckLoadRequested);
|
||||
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget,
|
||||
&VisualDeckStorageWidget::openDeckEditor);
|
||||
connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget,
|
||||
&VisualDeckStorageWidget::shareDeckRequested);
|
||||
connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget,
|
||||
&VisualDeckStorageWidget::shareSelectionChanged);
|
||||
deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable());
|
||||
connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged,
|
||||
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor);
|
||||
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize());
|
||||
|
|
@ -216,6 +221,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
|
|||
return deckPreviewWidget;
|
||||
}
|
||||
|
||||
void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable)
|
||||
{
|
||||
const auto previews = flowWidget->findChildren<DeckPreviewWidget *>();
|
||||
for (DeckPreviewWidget *preview : previews) {
|
||||
preview->setShareSelectable(selectable);
|
||||
}
|
||||
const auto subFolders = findChildren<VisualDeckStorageFolderDisplayWidget *>();
|
||||
for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) {
|
||||
subFolder->setShareSelectable(selectable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Creates, removes and keeps in sync the subfolder widgets of this folder.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ public slots:
|
|||
*/
|
||||
void scheduleReconcile();
|
||||
void updateShowFolders(bool enabled);
|
||||
void setShareSelectable(bool selectable);
|
||||
|
||||
signals:
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,3 +39,8 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) :
|
|||
|
||||
connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); });
|
||||
}
|
||||
|
||||
void VisualDeckStorageSearchWidget::setPlaceholderText(const QString &text)
|
||||
{
|
||||
searchBar->setPlaceholderText(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class VisualDeckStorageSearchWidget : public QWidget
|
|||
public:
|
||||
explicit VisualDeckStorageSearchWidget(QWidget *parent);
|
||||
|
||||
void setPlaceholderText(const QString &text);
|
||||
|
||||
signals:
|
||||
/**
|
||||
* Emitted once the debounce timer fires after the user stopped typing.
|
||||
|
|
|
|||
|
|
@ -2,14 +2,10 @@
|
|||
|
||||
#include "../general/layout_containers/flow_widget.h"
|
||||
#include "deck_preview/deck_preview_tag_display_widget.h"
|
||||
#include "visual_deck_storage_model.h"
|
||||
#include "visual_deck_storage_sort_filter_proxy_model.h"
|
||||
#include "visual_deck_storage_widget.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
|
||||
VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent)
|
||||
: QWidget(_parent), parent(_parent)
|
||||
VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
|
||||
setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
|
||||
|
|
@ -25,97 +21,62 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto
|
|||
layout->addWidget(flowWidget);
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function<QSet<QString>()> &provider)
|
||||
{
|
||||
allTagsProvider = provider;
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event)
|
||||
{
|
||||
QWidget::showEvent(event);
|
||||
refreshTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The tags of all decks currently accepted by the proxy model.
|
||||
*/
|
||||
QSet<QString> VisualDeckStorageTagFilterWidget::gatherAllTags() const
|
||||
{
|
||||
QSet<QString> allTags;
|
||||
auto *proxy = parent->proxyModel();
|
||||
|
||||
for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
|
||||
const QModelIndex index = proxy->index(proxyRow, 0);
|
||||
if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) {
|
||||
continue;
|
||||
}
|
||||
const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList();
|
||||
for (const QString &tag : deckTags) {
|
||||
allTags.insert(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return allTags;
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::refreshTags()
|
||||
{
|
||||
QSet<QString> allTags = gatherAllTags();
|
||||
removeTagsNotInList(allTags);
|
||||
addTagsIfNotPresent(allTags);
|
||||
sortTags();
|
||||
}
|
||||
const QSet<QString> allTags = allTagsProvider ? allTagsProvider() : QSet<QString>();
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet<QString> &tags)
|
||||
{
|
||||
// Existing chips survive if their tag is still part of the deck set, or if the chip
|
||||
// is currently selected/excluded. Everything else is dropped. Dropped chips must NOT
|
||||
// be re-added to the layout afterwards: they are scheduled for a deferred delete, and
|
||||
// the flow layout would keep a dangling reference to them once the deletion runs on
|
||||
// the next event-loop cycle.
|
||||
QList<DeckPreviewTagDisplayWidget *> chips;
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) {
|
||||
const QString &tagName = tagWidget->getTagName();
|
||||
|
||||
// Keep the tag widget if it is either selected or excluded
|
||||
if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) {
|
||||
if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) {
|
||||
chips.append(tagWidget);
|
||||
} else {
|
||||
flowWidget->removeWidget(tagWidget);
|
||||
tagWidget->deleteLater();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet<QString> &tags)
|
||||
{
|
||||
for (const QString &tag : tags) {
|
||||
addTagIfNotPresent(tag);
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag)
|
||||
{
|
||||
// Check if the tag already exists in the flow widget
|
||||
bool tagExists = false;
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) {
|
||||
if (tagWidget->getTagName() == tag) {
|
||||
tagExists = true;
|
||||
break;
|
||||
// Add chips for tags that are not shown yet.
|
||||
for (const QString &tag : allTags) {
|
||||
bool tagExists = false;
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
|
||||
if (tagWidget->getTagName() == tag) {
|
||||
tagExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!tagExists) {
|
||||
auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag);
|
||||
connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this,
|
||||
&VisualDeckStorageTagFilterWidget::filterChanged);
|
||||
flowWidget->addWidget(newTagWidget);
|
||||
chips.append(newTagWidget);
|
||||
}
|
||||
}
|
||||
|
||||
// If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget
|
||||
if (!tagExists) {
|
||||
auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag);
|
||||
connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent,
|
||||
&VisualDeckStorageWidget::updateTagFilter);
|
||||
flowWidget->addWidget(newTagWidget);
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageTagFilterWidget::sortTags()
|
||||
{
|
||||
// Get all tag widgets
|
||||
QList<DeckPreviewTagDisplayWidget *> tagWidgets = findChildren<DeckPreviewTagDisplayWidget *>();
|
||||
|
||||
// Sort widgets by tag name
|
||||
std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
|
||||
// Clear and re-add the chips in sorted order.
|
||||
std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
|
||||
return a->getTagName().toLower() < b->getTagName().toLower();
|
||||
});
|
||||
|
||||
// Clear and re-add widgets in sorted order
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) {
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
|
||||
flowWidget->removeWidget(tagWidget);
|
||||
}
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) {
|
||||
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
|
||||
flowWidget->addWidget(tagWidget);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,26 +9,27 @@
|
|||
#include <QSet>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
|
||||
class FlowWidget;
|
||||
class VisualDeckStorageWidget;
|
||||
|
||||
class VisualDeckStorageTagFilterWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
VisualDeckStorageWidget *parent;
|
||||
FlowWidget *flowWidget;
|
||||
|
||||
[[nodiscard]] QSet<QString> gatherAllTags() const;
|
||||
void removeTagsNotInList(const QSet<QString> &tags);
|
||||
void addTagsIfNotPresent(const QSet<QString> &tags);
|
||||
void addTagIfNotPresent(const QString &tag);
|
||||
void sortTags();
|
||||
std::function<QSet<QString>()> allTagsProvider;
|
||||
|
||||
public:
|
||||
explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent);
|
||||
explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr);
|
||||
[[nodiscard]] QStringList getAllKnownTags() const;
|
||||
|
||||
/**
|
||||
* @brief Sets a provider for the full set of tags to draw chips from.
|
||||
*/
|
||||
void setAllTagsProvider(const std::function<QSet<QString>()> &provider);
|
||||
|
||||
/**
|
||||
* @brief The tags currently in "selected" state.
|
||||
*/
|
||||
|
|
@ -39,9 +40,15 @@ public:
|
|||
*/
|
||||
[[nodiscard]] QStringList excludedTags() const;
|
||||
|
||||
signals:
|
||||
/**
|
||||
* Emitted whenever a chip's selection/exclusion state changes.
|
||||
*/
|
||||
void filterChanged();
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Rebuilds the tag chips from the tags of the currently visible decks.
|
||||
* @brief Rebuilds the tag chips from the currently available tags.
|
||||
*/
|
||||
void refreshTags();
|
||||
void showEvent(QShowEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
|
|||
refreshButton->setFixedSize(32, 32);
|
||||
connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible);
|
||||
|
||||
shareButton = new QToolButton(this);
|
||||
shareButton->setIcon(QPixmap("theme:icons/share"));
|
||||
shareButton->setFixedSize(32, 32);
|
||||
shareButton->setToolTip(tr("Select decks to share"));
|
||||
shareButton->setVisible(false);
|
||||
connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested);
|
||||
|
||||
quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
|
||||
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this,
|
||||
&VisualDeckStorageWidget::updateShowFolders);
|
||||
|
|
@ -57,10 +64,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
|
|||
searchAndSortLayout->addWidget(sortWidget);
|
||||
searchAndSortLayout->addWidget(searchWidget);
|
||||
searchAndSortLayout->addWidget(refreshButton);
|
||||
searchAndSortLayout->addWidget(shareButton);
|
||||
searchAndSortLayout->addWidget(quickSettingsWidget);
|
||||
|
||||
// tag filter box
|
||||
tagFilterWidget = new VisualDeckStorageTagFilterWidget(this);
|
||||
tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); });
|
||||
connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this,
|
||||
&VisualDeckStorageWidget::updateTagFilter);
|
||||
updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter());
|
||||
|
||||
deckPreviewSelectionAnimationEnabled =
|
||||
|
|
@ -159,6 +170,63 @@ void VisualDeckStorageWidget::retranslateUi()
|
|||
sortWidget->retranslateUi();
|
||||
}
|
||||
|
||||
void VisualDeckStorageWidget::setShareSelectable(bool selectable)
|
||||
{
|
||||
if (shareSelectable == selectable) {
|
||||
return;
|
||||
}
|
||||
shareSelectable = selectable;
|
||||
if (folderWidget != nullptr) {
|
||||
folderWidget->setShareSelectable(selectable);
|
||||
}
|
||||
emit shareSelectionChanged();
|
||||
}
|
||||
|
||||
bool VisualDeckStorageWidget::isShareSelectable() const
|
||||
{
|
||||
return shareSelectable;
|
||||
}
|
||||
|
||||
QStringList VisualDeckStorageWidget::selectedFilePaths() const
|
||||
{
|
||||
QStringList selectedPaths;
|
||||
if (folderWidget != nullptr) {
|
||||
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
|
||||
for (DeckPreviewWidget *preview : previews) {
|
||||
if (preview->isShareSelected()) {
|
||||
selectedPaths.append(preview->filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectedPaths;
|
||||
}
|
||||
|
||||
void VisualDeckStorageWidget::clearShareSelection()
|
||||
{
|
||||
if (folderWidget != nullptr) {
|
||||
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
|
||||
for (DeckPreviewWidget *preview : previews) {
|
||||
preview->setShareSelected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageWidget::setShareAvailable(bool available)
|
||||
{
|
||||
shareButton->setVisible(available);
|
||||
shareButton->setEnabled(available);
|
||||
}
|
||||
|
||||
void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths)
|
||||
{
|
||||
if (folderWidget != nullptr) {
|
||||
const auto previews = folderWidget->findChildren<DeckPreviewWidget *>();
|
||||
for (DeckPreviewWidget *preview : previews) {
|
||||
preview->setShareSelected(paths.contains(preview->filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a const pointer to the quick settings so that the values can be accessed.
|
||||
*/
|
||||
|
|
@ -216,6 +284,25 @@ void VisualDeckStorageWidget::updateTagFilter()
|
|||
tagFilterWidget->refreshTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The tags of all decks currently accepted by the proxy model.
|
||||
*/
|
||||
QSet<QString> VisualDeckStorageWidget::gatherVisibleTags() const
|
||||
{
|
||||
QSet<QString> allTags;
|
||||
for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) {
|
||||
const QModelIndex index = storageProxyModel->index(proxyRow, 0);
|
||||
if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) {
|
||||
continue;
|
||||
}
|
||||
const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList();
|
||||
for (const QString &tag : deckTags) {
|
||||
allTags.insert(tag);
|
||||
}
|
||||
}
|
||||
return allTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the color identity filter widget's state into the proxy model.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ public:
|
|||
explicit VisualDeckStorageWidget(QWidget *parent);
|
||||
void refreshIfPossible();
|
||||
void retranslateUi();
|
||||
void setShareSelectable(bool selectable);
|
||||
[[nodiscard]] bool isShareSelectable() const;
|
||||
[[nodiscard]] QStringList selectedFilePaths() const;
|
||||
void setShareSelectedFiles(const QStringList &paths);
|
||||
void clearShareSelection();
|
||||
void setShareAvailable(bool available);
|
||||
|
||||
VisualDeckStorageTagFilterWidget *tagFilterWidget;
|
||||
bool deckPreviewSelectionAnimationEnabled;
|
||||
|
|
@ -63,6 +69,9 @@ public slots:
|
|||
signals:
|
||||
void deckLoadRequested(const QString &filePath);
|
||||
void openDeckEditor(const LoadedDeck &deck);
|
||||
void shareDeckRequested(const QString &filePath);
|
||||
void shareSelectionChanged();
|
||||
void shareRequested();
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
|
|
@ -70,6 +79,7 @@ protected:
|
|||
|
||||
private:
|
||||
void reapplySortAndFilters();
|
||||
[[nodiscard]] QSet<QString> gatherVisibleTags() const;
|
||||
|
||||
private:
|
||||
QVBoxLayout *layout;
|
||||
|
|
@ -80,12 +90,14 @@ private:
|
|||
VisualDeckStorageSearchWidget *searchWidget;
|
||||
DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget;
|
||||
QToolButton *refreshButton;
|
||||
QToolButton *shareButton;
|
||||
VisualDeckStorageQuickSettingsWidget *quickSettingsWidget;
|
||||
QScrollArea *scrollArea;
|
||||
VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr;
|
||||
VisualDeckStorageModel *storageModel = nullptr;
|
||||
VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr;
|
||||
QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads.
|
||||
bool shareSelectable = false;
|
||||
};
|
||||
|
||||
#endif // VISUAL_DECK_STORAGE_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ include=("cockatrice/src" \
|
|||
libcockatrice_* \
|
||||
"oracle/src" \
|
||||
"servatrice/src" \
|
||||
"cmake/pch" \
|
||||
"tests")
|
||||
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
|
||||
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
|
||||
|
|
|
|||
|
|
@ -115,6 +115,25 @@ public:
|
|||
*/
|
||||
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Checks whether a zone name is taken anywhere in the deck.
|
||||
*
|
||||
* Covers the standard board names and any top-level or nested custom zone.
|
||||
* @param zoneName The checked name.
|
||||
* @return true if the name is reserved or already in use.
|
||||
*/
|
||||
bool hasZoneName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Finds a custom zone anywhere in the deck by name.
|
||||
*
|
||||
* Walks the children of every top-level zone, so a zone nested under any
|
||||
* board (and not just the standard ones) is found.
|
||||
* @param zoneName The zone name to find.
|
||||
* @return The matching zone node, or nullptr if none exists.
|
||||
*/
|
||||
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* @brief Applies a function to every card in the deck tree. This can modify the cards.
|
||||
*
|
||||
|
|
@ -128,8 +147,6 @@ private:
|
|||
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
|
||||
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
||||
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
||||
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
|
||||
bool hasZoneName(const QString &zoneName) const;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
|
|||
}
|
||||
}
|
||||
|
||||
const QList<QString> &InnerDecklistNode::boardZoneNames()
|
||||
{
|
||||
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
|
||||
QString(DECK_ZONE_MAYBEBOARD)};
|
||||
return names;
|
||||
}
|
||||
|
||||
QString InnerDecklistNode::getVisibleName() const
|
||||
{
|
||||
return visibleNameFromName(name);
|
||||
|
|
@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber(
|
|||
|
||||
int InnerDecklistNode::height() const
|
||||
{
|
||||
if (isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
return at(0)->height() + 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
|
||||
#include "abstract_deck_list_node.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Constant for the "main" deck zone name. */
|
||||
#define DECK_ZONE_MAIN "main"
|
||||
/** @brief Constant for the "sideboard" zone name. */
|
||||
|
|
@ -118,6 +121,13 @@ public:
|
|||
*/
|
||||
static QString visibleNameFromName(const QString &_name);
|
||||
|
||||
/**
|
||||
* @brief The standard board zone names, in display order.
|
||||
*
|
||||
* @return main, side and maybeboard.
|
||||
*/
|
||||
static const QList<QString> &boardZoneNames();
|
||||
|
||||
/**
|
||||
* @brief Get this node’s display-friendly name.
|
||||
* @return Human-readable name (zone/group name).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/utility/peglib.h>
|
||||
|
||||
static peg::parser search(R"(
|
||||
|
|
@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
|
|||
QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
|
||||
|
||||
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
|
||||
SetQuery <- ('e'/'set') [:] FlexStringValue
|
||||
SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
|
||||
OracleQuery <- 'o' [:] MatcherString
|
||||
|
||||
|
||||
|
|
@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+
|
|||
FlexStringValue <- CompactStringSet / String / [(] StringList [)]
|
||||
CompactStringSet <- StringListString ([,+] StringListString)+
|
||||
|
||||
SetExpression <- NumericOperator ws? String
|
||||
|
||||
NumericExpression <- NumericOperator ws? NumericValue
|
||||
NumericOperator <- [=:] / <[><!][=]?>
|
||||
NumericValue <- [0-9]+
|
||||
|
|
@ -101,12 +104,25 @@ static void setupParserRules()
|
|||
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
|
||||
};
|
||||
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
|
||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
QList<QString> sets = x->getSets().keys();
|
||||
if (sv.choice() == 1) {
|
||||
auto matcher = std::any_cast<StringMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
QList<QString> sets = x->getSets().keys();
|
||||
|
||||
auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
|
||||
return std::any_of(sets.begin(), sets.end(), matchesSet);
|
||||
auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
|
||||
return std::any_of(sets.begin(), sets.end(), matchesSet);
|
||||
};
|
||||
}
|
||||
|
||||
auto matcher = std::any_cast<NumberMatcher>(sv[0]);
|
||||
return [=](const CardData &x) -> bool {
|
||||
const auto &sets = x->getSets().values();
|
||||
auto matchesSet = [&](const PrintingInfo &printing) {
|
||||
return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay());
|
||||
};
|
||||
return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) {
|
||||
return std::any_of(printings.begin(), printings.end(), matchesSet);
|
||||
});
|
||||
};
|
||||
};
|
||||
search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
|
||||
|
|
@ -247,40 +263,54 @@ static void setupParserRules()
|
|||
return QString::fromStdString(std::string(sv.sv()));
|
||||
};
|
||||
|
||||
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto arg = std::any_cast<int>(sv[1]);
|
||||
const auto op = std::any_cast<QString>(sv[0]);
|
||||
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer {
|
||||
const auto op = QString::fromStdString(std::string(sv.sv()));
|
||||
|
||||
if (op == ">") {
|
||||
return [=](const int s) { return s > arg; };
|
||||
return [=](const int s, const int arg) { return s > arg; };
|
||||
}
|
||||
if (op == ">=") {
|
||||
return [=](const int s) { return s >= arg; };
|
||||
return [=](const int s, const int arg) { return s >= arg; };
|
||||
}
|
||||
if (op == "<") {
|
||||
return [=](const int s) { return s < arg; };
|
||||
return [=](const int s, const int arg) { return s < arg; };
|
||||
}
|
||||
if (op == "<=") {
|
||||
return [=](const int s) { return s <= arg; };
|
||||
return [=](const int s, const int arg) { return s <= arg; };
|
||||
}
|
||||
if (op == "=") {
|
||||
return [=](const int s) { return s == arg; };
|
||||
return [=](const int s, const int arg) { return s == arg; };
|
||||
}
|
||||
if (op == ":") {
|
||||
return [=](const int s) { return s == arg; };
|
||||
return [=](const int s, const int arg) { return s == arg; };
|
||||
}
|
||||
if (op == "!=") {
|
||||
return [=](const int s) { return s != arg; };
|
||||
return [=](const int s, const int arg) { return s != arg; };
|
||||
}
|
||||
return [](int) { return false; };
|
||||
return [](int, int) { return false; };
|
||||
};
|
||||
|
||||
search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
|
||||
return QString::fromStdString(std::string(sv.sv())).toInt();
|
||||
};
|
||||
|
||||
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString {
|
||||
return QString::fromStdString(std::string(sv.sv()));
|
||||
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
||||
const auto arg = std::any_cast<int>(sv[1]);
|
||||
return [=](int s) { return comparer(s, arg); };
|
||||
};
|
||||
|
||||
search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
|
||||
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
|
||||
const auto setCode = std::any_cast<QString>(sv[1]);
|
||||
const auto allSets = CardDatabaseManager::getInstance()->getSetList();
|
||||
for (auto &set : allSets) {
|
||||
if (set->getShortName() == setCode) {
|
||||
const int releaseDate = set->getReleaseDate().toJulianDay();
|
||||
return [=](int s) { return comparer(s, releaseDate); };
|
||||
}
|
||||
}
|
||||
return [](int) { return false; };
|
||||
};
|
||||
|
||||
search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ typedef CardInfoPtr CardData;
|
|||
typedef std::function<bool(const CardData &)> Filter;
|
||||
typedef std::function<bool(const QString &)> StringMatcher;
|
||||
typedef std::function<bool(int)> NumberMatcher;
|
||||
typedef std::function<bool(int, int)> NumberComparer;
|
||||
|
||||
namespace peg
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
|
|||
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
||||
|
||||
add_library(
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp
|
||||
deck_list_sort_filter_proxy_model.cpp
|
||||
)
|
||||
|
||||
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
|
|||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
|
||||
//! \todo Better sanity checking.
|
||||
// Non-card children are custom zones; they are mirrored in a single
|
||||
// pass below so each is mirrored exactly once.
|
||||
if (currentCard == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -82,8 +83,19 @@ void DeckListModel::rebuildTree()
|
|||
|
||||
new DecklistModelCardNode(currentCard, groupNode);
|
||||
}
|
||||
|
||||
// Custom zones nested under the board zone are mirrored as-is, with their
|
||||
// cards as direct children (no further grouping).
|
||||
DeckListModelCustomZones::mirrorCustomZones(currentZone, node);
|
||||
}
|
||||
|
||||
// The shadow tree was built in deck file order. Apply the active sort while
|
||||
// the reset is still open so every consumer (tree view and visual editor)
|
||||
// sees the canonical order from the start. sortShadowTree emits no signals,
|
||||
// which is only valid before endResetModel closes the reset.
|
||||
root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName);
|
||||
sortShadowTree(root, lastKnownOrder);
|
||||
|
||||
endResetModel();
|
||||
|
||||
refreshCardFormatLegalities();
|
||||
|
|
@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
case DeckRoles::IsLegalRole:
|
||||
return true;
|
||||
|
||||
case DeckRoles::IsCustomZoneRole:
|
||||
return DeckListModelCustomZones::isCustomZone(group);
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
return card->getFormatLegality();
|
||||
}
|
||||
|
||||
case DeckRoles::IsCustomZoneRole: {
|
||||
return false;
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
return false;
|
||||
}
|
||||
|
||||
// Custom zone rows are managed through the deck tree, never removed as model rows.
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
beginRemoveRows(parent, row, row + count - 1);
|
||||
for (int i = 0; i < count; i++) {
|
||||
AbstractDecklistNode *toDelete = node->takeAt(row);
|
||||
|
|
@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
}
|
||||
endRemoveRows();
|
||||
|
||||
if (node->empty() && (node != root)) {
|
||||
// Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
|
||||
if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) {
|
||||
removeRows(parent.row(), 1, parent.parent());
|
||||
} else {
|
||||
emitRecursiveUpdates(parent);
|
||||
|
|
@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
|
|||
|
||||
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
|
||||
{
|
||||
auto *newNode = dynamic_cast<InnerDecklistNode *>(parent->findChild(name));
|
||||
// Group lookups must not resolve a mirrored custom zone that shares the name.
|
||||
auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
|
||||
if (!newNode) {
|
||||
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
|
||||
newNode = new InnerDecklistNode(name, parent);
|
||||
|
|
@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
|
|||
const QString &providerId,
|
||||
const QString &cardNumber) const
|
||||
{
|
||||
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||
if (!zoneNode) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (!info) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
||||
InnerDecklistNode *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria));
|
||||
if (!groupNode) {
|
||||
return nullptr;
|
||||
// 1. Board zone lookup: search the criteria groups, then the custom zones
|
||||
// nested under the board.
|
||||
if (auto *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName))) {
|
||||
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
|
||||
if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
|
||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
||||
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto *child : *zoneNode) {
|
||||
if (!DeckListModelCustomZones::isCustomZone(child)) {
|
||||
continue;
|
||||
}
|
||||
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
|
||||
if (!customZone) {
|
||||
continue;
|
||||
}
|
||||
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
|
||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dynamic_cast<DecklistModelCardNode *>(
|
||||
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||
// 2. Custom zone lookup by name (custom zone names are deck-unique).
|
||||
if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
|
||||
return dynamic_cast<DecklistModelCardNode *>(
|
||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QModelIndex DeckListModel::findCard(const QString &cardName,
|
||||
|
|
@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
|
|||
return {};
|
||||
}
|
||||
|
||||
InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
|
||||
|
||||
CardInfoPtr cardInfo = card.getCardPtr();
|
||||
PrintingInfo printingInfo = card.getPrinting();
|
||||
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
|
||||
InnerDecklistNode *cardParent = nullptr;
|
||||
|
||||
const QModelIndex parentIndex = nodeToIndex(groupNode);
|
||||
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber(
|
||||
auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||
auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName);
|
||||
|
||||
// Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row
|
||||
// index is only usable as a deck-tree position while both sides have the same
|
||||
// direct-children shape. When they diverge, the card is appended to the deck
|
||||
// zone instead of being written out of range.
|
||||
InnerDecklistNode *deckCardParent = nullptr;
|
||||
bool customZoneNeedsAppend = false;
|
||||
|
||||
if (boardNode) {
|
||||
// Board zone: cards are grouped by the active criteria.
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
cardParent = createNodeIfNeeded(groupCriteria, boardNode);
|
||||
} else if (customZoneNode) {
|
||||
// Custom zone: cards live flat inside the zone.
|
||||
cardParent = customZoneNode;
|
||||
auto *listRoot = deckList->getTree()->getRoot();
|
||||
for (int i = 0; i < listRoot->size(); ++i) {
|
||||
auto *boardZone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i));
|
||||
if (!boardZone) {
|
||||
continue;
|
||||
}
|
||||
deckCardParent = dynamic_cast<InnerDecklistNode *>(boardZone->findChild(zoneName));
|
||||
if (deckCardParent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A deck custom zone holding nested sub-zones mirrors with flattened rows,
|
||||
// so a shadow row index does not map onto its direct children.
|
||||
if (deckCardParent) {
|
||||
for (int i = 0; i < deckCardParent->size(); ++i) {
|
||||
if (dynamic_cast<InnerDecklistNode *>(deckCardParent->at(i))) {
|
||||
customZoneNeedsAppend = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not present in the shadow tree. The deck tree may still hold a custom
|
||||
// zone that has not been mirrored (callers can add a zone and then a
|
||||
// card without a rebuild). Check before falling back to creating a
|
||||
// top-level zone the deck does not actually have.
|
||||
auto *listRoot = deckList->getTree()->getRoot();
|
||||
bool hasDeckZone = false;
|
||||
for (int i = 0; i < listRoot->size(); ++i) {
|
||||
if (auto *boardZone = dynamic_cast<InnerDecklistNode *>(listRoot->at(i))) {
|
||||
// Only real zones count: a card sitting directly under the board
|
||||
// shares the name comparison but is not a zone, and treating it as
|
||||
// one would recurse forever without mirroring anything.
|
||||
if (dynamic_cast<InnerDecklistNode *>(boardZone->findChild(zoneName))) {
|
||||
hasDeckZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDeckZone) {
|
||||
rebuildTree();
|
||||
return addCard(card, zoneName);
|
||||
}
|
||||
|
||||
// Unknown zone: create a top-level zone (legacy behavior).
|
||||
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
|
||||
auto *newZone = createNodeIfNeeded(zoneName, root);
|
||||
cardParent = createNodeIfNeeded(groupCriteria, newZone);
|
||||
}
|
||||
|
||||
const QModelIndex parentIndex = nodeToIndex(cardParent);
|
||||
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(cardParent->findCardChildByNameProviderIdAndNumber(
|
||||
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
|
||||
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
|
||||
|
||||
bool cardNodeAdded = false;
|
||||
if (!cardNode) {
|
||||
// Determine the correct index
|
||||
int insertRow = findSortedInsertRow(groupNode, cardInfo);
|
||||
int insertRow = findSortedInsertRow(cardParent, cardInfo);
|
||||
int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow;
|
||||
|
||||
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName,
|
||||
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName,
|
||||
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
|
||||
|
||||
beginInsertRows(parentIndex, insertRow, insertRow);
|
||||
cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow);
|
||||
cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
|
||||
endInsertRows();
|
||||
|
||||
cardNodeAdded = true;
|
||||
|
|
@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
|
|||
return createIndex(node->getParent()->indexOf(node), 0, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sorts a freshly built shadow subtree without emitting model signals.
|
||||
*
|
||||
* Used by rebuildTree while the model reset is still open (emitting layout
|
||||
* changes during a reset is invalid). Reorders every node just like
|
||||
* sortHelper does, but ignores the movement mapping because there are no
|
||||
* persistent indices established yet.
|
||||
*/
|
||||
void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
// The mapping is not needed: fresh shadow nodes have no persistent indices yet.
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
for (int i = node->size() - 1; i >= 0; --i) {
|
||||
if (auto *subNode = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
|
||||
sortShadowTree(subNode, order);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
// Sort children of node and save the information needed to
|
||||
// update the list of persistent indexes.
|
||||
QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
// Sort children (custom zones always sorted after groups within a board) and
|
||||
// use the movement mapping to update the list of persistent indices.
|
||||
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
QModelIndexList from, to;
|
||||
int columns = columnCount();
|
||||
for (int i = sortResult.size() - 1; i >= 0; --i) {
|
||||
const int fromRow = sortResult[i].first;
|
||||
const int toRow = sortResult[i].second;
|
||||
AbstractDecklistNode *temp = node->at(toRow);
|
||||
for (const auto &move : mapping) {
|
||||
const int preSortRow = move.first;
|
||||
const int finalRow = move.second;
|
||||
AbstractDecklistNode *temp = node->at(finalRow);
|
||||
for (int j = 0; j < columns; ++j) {
|
||||
from << createIndex(fromRow, j, temp);
|
||||
to << createIndex(toRow, j, temp);
|
||||
from << createIndex(preSortRow, j, temp);
|
||||
to << createIndex(finalRow, j, temp);
|
||||
}
|
||||
}
|
||||
changePersistentIndexList(from, to);
|
||||
|
|
@ -704,6 +838,15 @@ QList<QString> DeckListModel::getZones() const
|
|||
return zones;
|
||||
}
|
||||
|
||||
QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const
|
||||
{
|
||||
QStringList zoneNames;
|
||||
for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) {
|
||||
zoneNames.append(customZone->getName());
|
||||
}
|
||||
return zoneNames;
|
||||
}
|
||||
|
||||
static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
|
||||
{
|
||||
for (const AllowedCount &c : format.allowedCounts) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#ifndef DECKLISTMODEL_H
|
||||
#define DECKLISTMODEL_H
|
||||
|
||||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h>
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QAbstractItemModel>
|
||||
|
|
@ -30,7 +32,8 @@ enum
|
|||
{
|
||||
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
|
||||
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
|
||||
IsLegalRole /**< Whether the card is legal in the current deck format. */
|
||||
IsLegalRole, /**< Whether the card is legal in the current deck format. */
|
||||
IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */
|
||||
};
|
||||
} // namespace DeckRoles
|
||||
|
||||
|
|
@ -391,6 +394,14 @@ public:
|
|||
*/
|
||||
[[nodiscard]] QList<QString> getZones() const;
|
||||
|
||||
/**
|
||||
* @brief Gets the names of the custom zones nested under the given board zone.
|
||||
*
|
||||
* @param boardZoneName The board zone to query (main/side/maybeboard)
|
||||
* @return The custom zone names, in deck order
|
||||
*/
|
||||
[[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const;
|
||||
|
||||
private:
|
||||
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
|
||||
InnerDecklistNode *root; /**< Root node of the model tree. */
|
||||
|
|
@ -427,6 +438,7 @@ private:
|
|||
void emitRecursiveUpdates(const QModelIndex &index);
|
||||
|
||||
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
template <typename T> T getNode(const QModelIndex &index) const
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include "deck_list_model.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QHash>
|
||||
#include <QVector>
|
||||
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
bool isCustomZone(const AbstractDecklistNode *node)
|
||||
{
|
||||
return dynamic_cast<const DecklistModelSubZoneNode *>(node) != nullptr;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Flattens every card under @p zone into @p shadowZone, preserving order.
|
||||
*
|
||||
* Custom zones mirror as a single row level: cards nested in sub-zones of any
|
||||
* depth are added as direct children of the mirrored zone so no card is left
|
||||
* without a model row.
|
||||
*/
|
||||
void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone)
|
||||
{
|
||||
for (int k = 0; k < zone->size(); k++) {
|
||||
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(zone->at(k))) {
|
||||
new DecklistModelCardNode(zoneCard, shadowZone);
|
||||
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zone->at(k))) {
|
||||
flattenCards(subZone, shadowZone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
|
||||
{
|
||||
for (int j = 0; j < deckBoardZone->size(); j++) {
|
||||
auto *customZone = dynamic_cast<const InnerDecklistNode *>(deckBoardZone->at(j));
|
||||
if (!customZone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone);
|
||||
flattenCards(customZone, shadowZone);
|
||||
}
|
||||
}
|
||||
|
||||
InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name)
|
||||
{
|
||||
for (int i = 0; i < parent->size(); i++) {
|
||||
AbstractDecklistNode *child = parent->at(i);
|
||||
if (isCustomZone(child)) {
|
||||
continue;
|
||||
}
|
||||
auto *group = dynamic_cast<InnerDecklistNode *>(child);
|
||||
if (group && group->getName() == name) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName)
|
||||
{
|
||||
for (int i = 0; i < root->size(); i++) {
|
||||
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->at(i));
|
||||
if (!boardZone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = 0; j < boardZone->size(); j++) {
|
||||
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(j));
|
||||
if (customZone && customZone->getName() == zoneName) {
|
||||
return customZone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping.
|
||||
*/
|
||||
QList<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(node->size());
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
mapping.append({sortResult[i].first, i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sorts a board zone's children, then stably moves custom zones to the end.
|
||||
*
|
||||
* @return The (preSortRow, finalRow) mapping covering both the sort and the shift.
|
||||
*/
|
||||
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QVector<AbstractDecklistNode *> groups;
|
||||
QVector<AbstractDecklistNode *> customZones;
|
||||
QHash<AbstractDecklistNode *, int> preSortRowOf;
|
||||
|
||||
groups.reserve(node->size());
|
||||
customZones.reserve(node->size());
|
||||
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
AbstractDecklistNode *child = node->at(i);
|
||||
preSortRowOf.insert(child, sortResult[i].first);
|
||||
if (isCustomZone(child)) {
|
||||
customZones.append(child);
|
||||
} else {
|
||||
groups.append(child);
|
||||
}
|
||||
}
|
||||
|
||||
QVector<AbstractDecklistNode *> ordered = groups + customZones;
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
node->replace(i, ordered[i]);
|
||||
}
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(ordered.size());
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
mapping.append({preSortRowOf.value(ordered[i]), i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const bool isBoardZone = (node != root) && (node->getParent() == root);
|
||||
return isBoardZone ? boardSort(node, order) : plainSort(node, order);
|
||||
}
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
#define DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QtGlobal>
|
||||
|
||||
/**
|
||||
* @class DecklistModelSubZoneNode
|
||||
* @ingroup DeckModels
|
||||
* @brief Model node representing a custom zone nested under a board zone.
|
||||
*
|
||||
* Custom zones group cards by user-defined names (e.g. "Removal", "Utility")
|
||||
* inside a board zone. They are mirrored from the underlying deck tree so that
|
||||
* they can be told apart from criteria group nodes by type.
|
||||
*/
|
||||
class DecklistModelSubZoneNode : public InnerDecklistNode
|
||||
{
|
||||
public:
|
||||
using InnerDecklistNode::InnerDecklistNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace DeckListModelCustomZones
|
||||
* @ingroup DeckModels
|
||||
* @brief Tree-level helpers for the deck list model's custom-zone shadow nodes.
|
||||
*
|
||||
* The deck list model keeps a second "shadow" tree of InnerDecklistNode that
|
||||
* mirrors the canonical deck tree for grouping and sorting. Custom zones add a
|
||||
* layer of bookkeeping to that shadow tree: they must be mirrored alongside
|
||||
* criteria groups, always sort after the groups within a board, and be
|
||||
* resolvable by deck-unique name.
|
||||
*
|
||||
* This namespace centralizes every "what is / where is a custom zone" decision
|
||||
* so the model itself only wires the results into Qt model signals.
|
||||
*/
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Whether the given node is a custom zone (as opposed to a criteria group).
|
||||
*/
|
||||
[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node);
|
||||
|
||||
/**
|
||||
* @brief Finds a criteria-group child of @p parent by name, skipping custom zones.
|
||||
*
|
||||
* The shadow tree keeps criteria groups and mirrored custom zones as siblings
|
||||
* under a board zone, and `InnerDecklistNode::findChild` matches both by name.
|
||||
* Group lookups must not resolve a custom zone that happens to share the group
|
||||
* name (e.g. a zone called "Creature"), so this searches only non-custom
|
||||
* children.
|
||||
*
|
||||
* @param parent The shadow node whose children are searched.
|
||||
* @param name The group name to find.
|
||||
* @return The matching group node, or nullptr if none exists.
|
||||
*/
|
||||
[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name);
|
||||
|
||||
/**
|
||||
* @brief Mirrors the custom zones of a deck board zone into its shadow board node.
|
||||
*
|
||||
* Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone
|
||||
* with its cards as direct (un-grouped) children.
|
||||
*
|
||||
* @param deckBoardZone The board zone in the canonical deck tree.
|
||||
* @param shadowBoardZone The matching board zone in the model's shadow tree.
|
||||
*/
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone);
|
||||
|
||||
/**
|
||||
* @brief Finds a custom zone in the shadow tree by deck-unique name.
|
||||
* @param root Root of the shadow tree.
|
||||
* @param zoneName The custom zone name to find.
|
||||
* @return The matching custom zone node, or nullptr if not found.
|
||||
*/
|
||||
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Sorts a shadow node's children, keeping a board's custom zones last.
|
||||
*
|
||||
* Sorting alone would interleave custom zones with criteria groups by name, but
|
||||
* custom zones must always stay after the groups within a board, regardless of
|
||||
* name. This applies the sort and, for board zones, stably moves the custom
|
||||
* zones to the end.
|
||||
*
|
||||
* @param root Root of the shadow tree (used to classify board zones).
|
||||
* @param node The shadow node whose children are reordered.
|
||||
* @param order Sort order to apply.
|
||||
* @return A list of (preSortRow, finalRow) pairs describing how each node moved.
|
||||
*/
|
||||
[[nodiscard]] QList<QPair<int, int>>
|
||||
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
||||
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
|
@ -15,9 +15,15 @@ set(PROTO_FILES
|
|||
command_deck_del.proto
|
||||
command_deck_del_dir.proto
|
||||
command_deck_download.proto
|
||||
command_deck_download_public.proto
|
||||
command_deck_list.proto
|
||||
command_deck_list_other_user.proto
|
||||
command_deck_new_dir.proto
|
||||
command_deck_select.proto
|
||||
command_deck_set_visibility.proto
|
||||
command_deck_share_create.proto
|
||||
command_deck_share_download.proto
|
||||
command_deck_share_list.proto
|
||||
command_deck_upload.proto
|
||||
command_del_counter.proto
|
||||
command_delete_arrow.proto
|
||||
|
|
@ -135,6 +141,9 @@ set(PROTO_FILES
|
|||
response_card_art_rule_entry.proto
|
||||
response_deck_download.proto
|
||||
response_deck_list.proto
|
||||
response_deck_share_create.proto
|
||||
response_deck_share_download.proto
|
||||
response_deck_share_list.proto
|
||||
response_deck_upload.proto
|
||||
response_dump_zone.proto
|
||||
response_forgotpasswordrequest.proto
|
||||
|
|
@ -172,6 +181,7 @@ set(PROTO_FILES
|
|||
serverinfo_cardcounter.proto
|
||||
serverinfo_chat_message.proto
|
||||
serverinfo_counter.proto
|
||||
serverinfo_deck_share_item.proto
|
||||
serverinfo_deckstorage.proto
|
||||
serverinfo_game.proto
|
||||
serverinfo_gametype.proto
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message Command_DeckDownloadPublic {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckDownloadPublic ext = 1031;
|
||||
}
|
||||
optional uint32 deck_id = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message Command_DeckListOtherUser {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckListOtherUser ext = 1029;
|
||||
}
|
||||
optional string user_name = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message Command_DeckSetVisibility {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckSetVisibility ext = 1030;
|
||||
}
|
||||
// Set the public visibility of a single deck (mutually exclusive with folder_path).
|
||||
optional uint32 deck_id = 1;
|
||||
// Set the public visibility of a folder (all decks under it inherit).
|
||||
optional string folder_path = 2;
|
||||
optional bool is_public = 3;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message DeckShareItem {
|
||||
// Reference an existing deck in the sharer's personal deck storage.
|
||||
// Mutually exclusive with deck_list.
|
||||
optional uint32 deck_id = 1;
|
||||
// Inline deck content in the native format.
|
||||
// Mutually exclusive with deck_id.
|
||||
optional string deck_list = 2;
|
||||
// Color identity of the deck (e.g. "WUBRG"), computed by the sharing client.
|
||||
optional string color_identity = 3;
|
||||
}
|
||||
|
||||
message Command_DeckShareCreate {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckShareCreate ext = 1026;
|
||||
}
|
||||
optional string name = 1;
|
||||
repeated DeckShareItem items = 2;
|
||||
// Path of a folder in the sharer's personal deck storage. When set, all
|
||||
// decks in that folder are shared (resolved by the server).
|
||||
optional string folder_path = 3;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message Command_DeckShareDownload {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckShareDownload ext = 1028;
|
||||
}
|
||||
optional string token = 1;
|
||||
optional uint32 item_id = 2;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
syntax = "proto2";
|
||||
import "session_commands.proto";
|
||||
|
||||
message Command_DeckShareList {
|
||||
extend SessionCommand {
|
||||
optional Command_DeckShareList ext = 1027;
|
||||
}
|
||||
optional string token = 1;
|
||||
}
|
||||
|
|
@ -8,4 +8,12 @@ message Command_DeckUpload {
|
|||
optional string path = 1; // to upload a new deck
|
||||
optional uint32 deck_id = 2; // to replace an existing deck
|
||||
optional string deck_list = 3;
|
||||
optional bool is_public = 4; // mark the deck public on upload (publish)
|
||||
// Preview metadata computed by the uploading client (see ServerInfo_DeckStorage_File).
|
||||
optional string banner_card_name = 5;
|
||||
optional string banner_card_provider = 6;
|
||||
optional string color_identity = 7;
|
||||
// Comma-separated list of tag names associated with the deck, used to render
|
||||
// and filter another user's public decks on the client.
|
||||
optional string tags = 8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ message Response {
|
|||
REPLAY_LIST = 1100; // Response listing replays
|
||||
REPLAY_DOWNLOAD = 1101; // Response for replay download
|
||||
REPLAY_GET_CODE = 1102; // Response containing replay code
|
||||
DECK_SHARE_CREATE = 1103; // Response to deck share creation
|
||||
DECK_SHARE_LIST = 1104; // Response listing shared decks
|
||||
DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download
|
||||
CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
syntax = "proto2";
|
||||
import "response.proto";
|
||||
|
||||
message Response_DeckShareCreate {
|
||||
extend Response {
|
||||
optional Response_DeckShareCreate ext = 1103;
|
||||
}
|
||||
optional string token = 1;
|
||||
optional uint64 expires_at = 2;
|
||||
optional uint32 item_count = 3;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
syntax = "proto2";
|
||||
import "response.proto";
|
||||
|
||||
message Response_DeckShareDownload {
|
||||
extend Response {
|
||||
optional Response_DeckShareDownload ext = 1105;
|
||||
}
|
||||
optional string deck = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
syntax = "proto2";
|
||||
import "response.proto";
|
||||
import "serverinfo_deck_share_item.proto";
|
||||
|
||||
message Response_DeckShareList {
|
||||
extend Response {
|
||||
optional Response_DeckShareList ext = 1104;
|
||||
}
|
||||
optional string name = 1;
|
||||
optional uint64 expires_at = 2;
|
||||
repeated ServerInfo_DeckShareItem items = 3;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
syntax = "proto2";
|
||||
|
||||
message ServerInfo_DeckShareItem {
|
||||
optional uint32 id = 1;
|
||||
optional string name = 2;
|
||||
repeated string tags = 3;
|
||||
optional string banner_card = 4;
|
||||
optional string game_format = 5;
|
||||
optional string color_identity = 6;
|
||||
}
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
syntax = "proto2";
|
||||
message ServerInfo_DeckStorage_File {
|
||||
optional uint32 creation_time = 1;
|
||||
optional bool is_public = 2;
|
||||
// Preview metadata computed by the uploading client, so other clients can
|
||||
// render this deck (e.g. in a visual storage grid) without downloading the
|
||||
// full deck list. Empty for decks uploaded before the metadata columns.
|
||||
optional string banner_card_name = 3;
|
||||
optional string banner_card_provider = 4;
|
||||
optional string color_identity = 5;
|
||||
// Comma-separated list of tag names, matching the corresponding
|
||||
// ServerInfo_DeckStorage_File upload metadata. Empty for decks uploaded
|
||||
// before the tags column existed.
|
||||
optional string tags = 6;
|
||||
}
|
||||
|
||||
message ServerInfo_DeckStorage_Folder {
|
||||
repeated ServerInfo_DeckStorage_TreeItem items = 1;
|
||||
optional bool is_public = 2;
|
||||
}
|
||||
|
||||
message ServerInfo_DeckStorage_TreeItem {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ message SessionCommand {
|
|||
FORGOT_PASSWORD_CHALLENGE = 1023;
|
||||
REQUEST_PASSWORD_SALT = 1024;
|
||||
SET_CARD_ART_PARAMS = 1025;
|
||||
DECK_SHARE_CREATE = 1026;
|
||||
DECK_SHARE_LIST = 1027;
|
||||
DECK_SHARE_DOWNLOAD = 1028;
|
||||
DECK_LIST_OTHER_USER = 1029;
|
||||
DECK_SET_VISIBILITY = 1030;
|
||||
DECK_DOWNLOAD_PUBLIC = 1031;
|
||||
REPLAY_LIST = 1100;
|
||||
REPLAY_DOWNLOAD = 1101;
|
||||
REPLAY_MODIFY_MATCH = 1102;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ set(oracle_SOURCES
|
|||
src/pages.cpp
|
||||
src/pagetemplates.cpp
|
||||
src/parsehelpers.cpp
|
||||
src/raw_json_scanner.cpp
|
||||
../cockatrice/src/client/settings/cache_settings.cpp
|
||||
../cockatrice/src/client/settings/card_counter_settings.cpp
|
||||
../cockatrice/src/client/settings/shortcuts_settings.cpp
|
||||
|
|
@ -112,6 +113,8 @@ qt6_add_executable(
|
|||
MANUAL_FINALIZATION
|
||||
)
|
||||
|
||||
target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
|
||||
|
||||
# ------------------------
|
||||
# Link libraries
|
||||
# ------------------------
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
|
|
@ -44,26 +45,23 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
|
|||
return priority;
|
||||
}
|
||||
|
||||
bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
|
||||
bool OracleImporter::readSetsFromByteArray(QByteArray data)
|
||||
{
|
||||
QJsonParseError error;
|
||||
auto doc = QJsonDocument::fromJson(data, &error);
|
||||
if (error.error != QJsonParseError::NoError) {
|
||||
qDebug() << "error: QJsonDocument::fromJson():" << error.errorString();
|
||||
RawJson::ScanError error;
|
||||
const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error);
|
||||
if (error.isError()) {
|
||||
qDebug() << "error: RawJson::scanSetRanges():" << error.message;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto setsObj = doc.object().value("data").toObject();
|
||||
|
||||
QList<SetToDownload> newSetList;
|
||||
newSetList.reserve(ranges.size());
|
||||
|
||||
for (auto it = setsObj.constBegin(); it != setsObj.constEnd(); ++it) {
|
||||
QJsonObject setObj = it.value().toObject();
|
||||
QString shortName = setObj.value("code").toString().toUpper();
|
||||
QString longName = setObj.value("name").toString();
|
||||
QJsonArray setCards = setObj.value("cards").toArray();
|
||||
QString setType = setObj.value("type").toString();
|
||||
QDate releaseDate = QDate::fromString(setObj.value("releaseDate").toString(), Qt::ISODate);
|
||||
for (const RawJson::SetRange &range : ranges) {
|
||||
QString shortName = range.code.toUpper();
|
||||
QString longName = range.name;
|
||||
QString setType = range.type;
|
||||
QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate);
|
||||
CardSet::Priority priority = getSetPriority(setType, shortName);
|
||||
// capitalize set type
|
||||
if (setType.length() > 0) {
|
||||
|
|
@ -83,7 +81,9 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
|
|||
}
|
||||
setType = setType.trimmed();
|
||||
}
|
||||
newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate));
|
||||
SetToDownload set(shortName, longName, priority, setType, releaseDate);
|
||||
set.setRawRange(range.dataRange);
|
||||
newSetList.append(set);
|
||||
}
|
||||
|
||||
std::sort(newSetList.begin(), newSetList.end());
|
||||
|
|
@ -92,6 +92,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
|
|||
return false;
|
||||
}
|
||||
allSets = newSetList;
|
||||
rawSetsData = std::move(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -550,22 +551,16 @@ int OracleImporter::startImport()
|
|||
{
|
||||
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
|
||||
|
||||
// Pre-allocate the cards hash to avoid rehashing during import. The hash
|
||||
// is keyed by distinct card name rather than by printings: AllPrintings
|
||||
// ships ~100k printings for ~35k names, so reserving the printing count
|
||||
// would overallocate ~3x (against this stack's RAM goal). Collecting
|
||||
// distinct names is cheap — one pass over the already-parsed name fields.
|
||||
{
|
||||
QSet<QString> distinctNames;
|
||||
for (const SetToDownload &curSetToParse : allSets) {
|
||||
for (const QJsonValue &cardValue : curSetToParse.getCards()) {
|
||||
distinctNames.insert(cardValue.toObject().value("name").toString());
|
||||
}
|
||||
}
|
||||
cards.reserve(distinctNames.size());
|
||||
// The set goes out of scope here, handing the ~35k name QStrings back
|
||||
// to the allocator before the (memory-heavy) import loop starts.
|
||||
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
|
||||
// distinct card names while raw ranges only count printings (AllPrintings
|
||||
// ~100k printings vs ~35k names), so this over-reserves somewhat; an exact
|
||||
// distinct-name count would require eagerly parsing, which the lazy reader
|
||||
// deliberately avoids. It's a capacity hint, so the overshoot is harmless.
|
||||
int estimatedCards = 0;
|
||||
for (const SetToDownload &curSetToParse : allSets) {
|
||||
estimatedCards += curSetToParse.getRawRange().cardCount;
|
||||
}
|
||||
cards.reserve(estimatedCards);
|
||||
|
||||
// add an empty set for tokens
|
||||
CardSetPtr tokenSet =
|
||||
|
|
@ -578,11 +573,44 @@ int OracleImporter::startImport()
|
|||
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
|
||||
curSetToParse.getLongName(), curSetToParse.getSetType(),
|
||||
curSetToParse.getReleaseDate(), curSetToParse.getPriority());
|
||||
|
||||
// parse only this set's slice of the raw document so the whole JSON tree is
|
||||
// never kept in memory at once
|
||||
const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange();
|
||||
const qsizetype rangeEnd = rawRange.start + rawRange.length;
|
||||
if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) {
|
||||
// rawSetsData is cleared by releaseSetData() while SetToDownload copies
|
||||
// taken from getSets() keep their ranges, and nothing else enforces the
|
||||
// pairing — so never index past the buffer on stale/mismatched ranges.
|
||||
qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping";
|
||||
++setIndex;
|
||||
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
|
||||
continue;
|
||||
}
|
||||
// sliced() shares the buffer instead of deep-copying the slice; the largest
|
||||
// sets in AllPrintings are tens of MB, so the copy is worth avoiding here.
|
||||
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":"
|
||||
<< parseError.errorString();
|
||||
++setIndex;
|
||||
// Keep the progress accounting honest: a set that failed to parse
|
||||
// still advanced the index, so report it (with zero imported cards)
|
||||
// rather than letting SaveSetsPage's bar stall per failed set.
|
||||
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add the set to the database once its slice parsed cleanly;
|
||||
// a set that fails here must not persist as an empty set in cards.xml.
|
||||
if (!sets.contains(newSet->getShortName())) {
|
||||
sets.insert(newSet->getShortName(), newSet);
|
||||
}
|
||||
|
||||
int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards());
|
||||
const QJsonArray setCards = setDoc.object().value("cards").toArray();
|
||||
int numCardsInSet = importCardsFromSet(newSet, setCards);
|
||||
|
||||
++setIndex;
|
||||
|
||||
|
|
@ -605,6 +633,7 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr
|
|||
void OracleImporter::releaseSetData()
|
||||
{
|
||||
allSets.clear();
|
||||
rawSetsData.clear();
|
||||
}
|
||||
|
||||
void OracleImporter::clear()
|
||||
|
|
@ -612,4 +641,5 @@ void OracleImporter::clear()
|
|||
sets.clear();
|
||||
cards.clear();
|
||||
allSets.clear();
|
||||
rawSetsData.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
#ifndef ORACLEIMPORTER_H
|
||||
#define ORACLEIMPORTER_H
|
||||
|
||||
#include "raw_json_scanner.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QMap>
|
||||
|
|
@ -46,10 +49,12 @@ class SetToDownload
|
|||
{
|
||||
private:
|
||||
QString shortName, longName;
|
||||
QJsonArray cards;
|
||||
QDate releaseDate;
|
||||
QString setType;
|
||||
CardSet::Priority priority;
|
||||
// Byte range of this set's object within the importer's raw JSON text. Parsing
|
||||
// one set at a time keeps peak memory low instead of holding the whole document.
|
||||
RawJson::SetDataRange rawRange;
|
||||
|
||||
public:
|
||||
const QString &getShortName() const
|
||||
|
|
@ -60,10 +65,6 @@ public:
|
|||
{
|
||||
return longName;
|
||||
}
|
||||
const QJsonArray &getCards() const
|
||||
{
|
||||
return cards;
|
||||
}
|
||||
const QString &getSetType() const
|
||||
{
|
||||
return setType;
|
||||
|
|
@ -76,16 +77,23 @@ public:
|
|||
{
|
||||
return priority;
|
||||
}
|
||||
const RawJson::SetDataRange &getRawRange() const
|
||||
{
|
||||
return rawRange;
|
||||
}
|
||||
SetToDownload(QString _shortName,
|
||||
QString _longName,
|
||||
QJsonArray _cards,
|
||||
CardSet::Priority _priority,
|
||||
QString _setType = QString(),
|
||||
const QDate &_releaseDate = QDate())
|
||||
: shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)),
|
||||
releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority)
|
||||
: shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate),
|
||||
setType(std::move(_setType)), priority(_priority)
|
||||
{
|
||||
}
|
||||
void setRawRange(const RawJson::SetDataRange &_rawRange)
|
||||
{
|
||||
rawRange = _rawRange;
|
||||
}
|
||||
bool operator<(const SetToDownload &set) const
|
||||
{
|
||||
return longName.compare(set.longName, Qt::CaseInsensitive) < 0;
|
||||
|
|
@ -141,6 +149,12 @@ private:
|
|||
|
||||
QList<SetToDownload> allSets;
|
||||
|
||||
/**
|
||||
* The raw JSON text of the source document, retained for lazy per-set
|
||||
* parsing during startImport(). Frees the card data as each set is imported.
|
||||
*/
|
||||
QByteArray rawSetsData;
|
||||
|
||||
CardInfoPtr addCard(QString name,
|
||||
const QString &text,
|
||||
bool isToken,
|
||||
|
|
@ -153,7 +167,11 @@ signals:
|
|||
|
||||
public:
|
||||
explicit OracleImporter(QObject *parent = nullptr);
|
||||
bool readSetsFromByteArray(const QByteArray &data);
|
||||
/**
|
||||
* Scans the given JSON document for set metadata. Takes the data by value so
|
||||
* the wizard can hand over its decompressed buffer without copying it.
|
||||
*/
|
||||
bool readSetsFromByteArray(QByteArray data);
|
||||
int startImport();
|
||||
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
||||
int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList);
|
||||
|
|
@ -169,6 +187,10 @@ public:
|
|||
{
|
||||
return allSets;
|
||||
}
|
||||
const QByteArray &getRawSetsData() const
|
||||
{
|
||||
return rawSetsData;
|
||||
}
|
||||
void releaseSetData();
|
||||
void clear();
|
||||
};
|
||||
|
|
|
|||
621
oracle/src/raw_json_scanner.cpp
Normal file
621
oracle/src/raw_json_scanner.cpp
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
#include "raw_json_scanner.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Nesting cap matching QJsonDocument's limit, so a pathologically deep document
|
||||
// fails shallowly instead of overflowing the stack through the recursive
|
||||
// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same
|
||||
// reason and reports DeepNesting).
|
||||
constexpr int kMaxNestingDepth = 1024;
|
||||
|
||||
inline bool isWhitespace(char c)
|
||||
{
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
}
|
||||
|
||||
const char *skipWhitespace(const char *p, const char *end)
|
||||
{
|
||||
while (p < end && isWhitespace(*p)) {
|
||||
++p;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
inline bool isHexDigit(char c)
|
||||
{
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
inline quint8 hexValue(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return c - 'a' + 10;
|
||||
}
|
||||
return c - 'A' + 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Skips past a JSON string without decoding it, validating escapes.
|
||||
* @param p In: pointing at the opening quote. Out: pointing past the closing quote.
|
||||
*/
|
||||
bool skipString(const char *&p, const char *end)
|
||||
{
|
||||
++p; // opening quote
|
||||
for (;;) {
|
||||
const void *quote = memchr(p, '"', static_cast<size_t>(end - p));
|
||||
if (!quote) {
|
||||
return false; // unterminated string
|
||||
}
|
||||
// Backslash escapes can only appear before the closing quote, so bound
|
||||
// the scan to the string extent instead of the rest of the document.
|
||||
const void *backslash = memchr(p, '\\', static_cast<size_t>(static_cast<const char *>(quote) - p));
|
||||
if (!backslash) {
|
||||
p = static_cast<const char *>(quote) + 1;
|
||||
return true;
|
||||
}
|
||||
const char *b = static_cast<const char *>(backslash);
|
||||
if (end - b < 2) {
|
||||
return false;
|
||||
}
|
||||
const char escaped = b[1];
|
||||
if (escaped == 'u') {
|
||||
if (end - b < 6) {
|
||||
return false;
|
||||
}
|
||||
quint32 codepoint = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
if (!isHexDigit(b[2 + i])) {
|
||||
return false;
|
||||
}
|
||||
codepoint = codepoint * 16 + hexValue(b[2 + i]);
|
||||
}
|
||||
p = b + 6;
|
||||
if (codepoint >= 0xD800 && codepoint <= 0xDBFF) {
|
||||
// expect the low-surrogate escape for the second half
|
||||
if (end - p < 6 || p[0] != '\\' || p[1] != 'u') {
|
||||
return false; // unpaired high surrogate
|
||||
}
|
||||
quint32 low = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
if (!isHexDigit(p[2 + i])) {
|
||||
return false;
|
||||
}
|
||||
low = low * 16 + hexValue(p[2 + i]);
|
||||
}
|
||||
if (low < 0xDC00 || low > 0xDFFF) {
|
||||
return false;
|
||||
}
|
||||
p += 6;
|
||||
} else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) {
|
||||
return false; // unpaired low surrogate
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (escaped) {
|
||||
case '"':
|
||||
case '\\':
|
||||
case '/':
|
||||
case 'b':
|
||||
case 'f':
|
||||
case 'n':
|
||||
case 'r':
|
||||
case 't':
|
||||
p = b + 2;
|
||||
continue;
|
||||
default:
|
||||
return false; // invalid escape
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Decodes a JSON string into @p out, validating it as it goes.
|
||||
* @param p In: pointing at the opening quote. Out: pointing past the closing quote.
|
||||
*/
|
||||
bool decodeString(const char *&p, const char *end, QString &out)
|
||||
{
|
||||
out.clear();
|
||||
QByteArray utf8;
|
||||
auto flush = [&out, &utf8]() {
|
||||
if (!utf8.isEmpty()) {
|
||||
out += QString::fromUtf8(utf8);
|
||||
utf8.clear();
|
||||
}
|
||||
};
|
||||
|
||||
++p; // opening quote
|
||||
while (p < end) {
|
||||
const char c = *p;
|
||||
if (c == '\\') {
|
||||
flush();
|
||||
++p; // escaped character
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
const char escaped = *p;
|
||||
if (escaped == 'u') {
|
||||
++p; // first hex digit
|
||||
if (p + 4 > end) {
|
||||
return false;
|
||||
}
|
||||
quint32 codepoint = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
if (!isHexDigit(p[i])) {
|
||||
return false;
|
||||
}
|
||||
codepoint = codepoint * 16 + hexValue(p[i]);
|
||||
}
|
||||
p += 4;
|
||||
if (codepoint >= 0xD800 && codepoint <= 0xDBFF) {
|
||||
// expect a low-surrogate escape for the second half
|
||||
if (p + 6 > end || p[0] != '\\' || p[1] != 'u') {
|
||||
return false; // unpaired high surrogate
|
||||
}
|
||||
quint32 low = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
if (!isHexDigit(p[2 + i])) {
|
||||
return false;
|
||||
}
|
||||
low = low * 16 + hexValue(p[2 + i]);
|
||||
}
|
||||
if (low < 0xDC00 || low > 0xDFFF) {
|
||||
return false;
|
||||
}
|
||||
out += QChar(codepoint);
|
||||
out += QChar(low);
|
||||
p += 6;
|
||||
} else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) {
|
||||
return false; // unpaired low surrogate
|
||||
} else {
|
||||
out += QChar(codepoint);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (escaped) {
|
||||
case '"':
|
||||
out += '"';
|
||||
break;
|
||||
case '\\':
|
||||
out += '\\';
|
||||
break;
|
||||
case '/':
|
||||
out += '/';
|
||||
break;
|
||||
case 'b':
|
||||
out += '\b';
|
||||
break;
|
||||
case 'f':
|
||||
out += '\f';
|
||||
break;
|
||||
case 'n':
|
||||
out += '\n';
|
||||
break;
|
||||
case 'r':
|
||||
out += '\r';
|
||||
break;
|
||||
case 't':
|
||||
out += '\t';
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
++p;
|
||||
flush();
|
||||
return true;
|
||||
}
|
||||
// Deliberately accept unescaped control characters (e.g. a tab inside
|
||||
// a set name): QJsonDocument and skipString accept them too, so
|
||||
// rejecting them here would fail the whole document on a byte that
|
||||
// Qt is fine with — the very total-failure mode this scanner avoids.
|
||||
utf8 += c;
|
||||
++p;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Reads a set-metadata field, tolerating null and non-string values.
|
||||
*
|
||||
* A set's metadata may carry null or non-string values in otherwise-valid
|
||||
* payloads ("releaseDate": null, "type": 7). The token itself was already
|
||||
* structurally validated by skipValue, so a non-string value is accepted and
|
||||
* leaves @p out at its default (empty) — one bad set must not abort the
|
||||
* import of every other set in the document.
|
||||
*/
|
||||
bool decodeStringMember(const char *&fs, const char *&fe, QString &out)
|
||||
{
|
||||
if (fs >= fe) {
|
||||
return false;
|
||||
}
|
||||
if (*fs != '"') {
|
||||
return true;
|
||||
}
|
||||
return decodeString(fs, fe, out);
|
||||
}
|
||||
|
||||
bool matchLiteral(const char *&p, const char *end, const char *literal, int length)
|
||||
{
|
||||
if (end - p < length || memcmp(p, literal, static_cast<size_t>(length)) != 0) {
|
||||
return false;
|
||||
}
|
||||
const char *after = p + length;
|
||||
if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) {
|
||||
return false;
|
||||
}
|
||||
p = after;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool skipNumber(const char *&p, const char *end)
|
||||
{
|
||||
// JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?
|
||||
if (p < end && *p == '-') {
|
||||
++p;
|
||||
}
|
||||
if (p < end && *p == '0') {
|
||||
++p;
|
||||
} else if (p < end && *p >= '1' && *p <= '9') {
|
||||
++p;
|
||||
while (p < end && QChar::isDigit(*p)) {
|
||||
++p;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (p < end && *p == '.') {
|
||||
++p;
|
||||
if (p >= end || !QChar::isDigit(*p)) {
|
||||
return false;
|
||||
}
|
||||
while (p < end && QChar::isDigit(*p)) {
|
||||
++p;
|
||||
}
|
||||
}
|
||||
if (p < end && (*p == 'e' || *p == 'E')) {
|
||||
++p;
|
||||
if (p < end && (*p == '+' || *p == '-')) {
|
||||
++p;
|
||||
}
|
||||
if (p >= end || !QChar::isDigit(*p)) {
|
||||
return false;
|
||||
}
|
||||
while (p < end && QChar::isDigit(*p)) {
|
||||
++p;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool skipValue(const char *&p, const char *end, int depth);
|
||||
bool skipObject(const char *&p, const char *end, int depth);
|
||||
bool skipArray(const char *&p, const char *end, int depth);
|
||||
|
||||
bool skipPrimitive(const char *&p, const char *end)
|
||||
{
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
const char c = *p;
|
||||
if (c == '"') {
|
||||
return skipString(p, end);
|
||||
}
|
||||
if (c == 't') {
|
||||
return matchLiteral(p, end, "true", 4);
|
||||
}
|
||||
if (c == 'f') {
|
||||
return matchLiteral(p, end, "false", 5);
|
||||
}
|
||||
if (c == 'n') {
|
||||
return matchLiteral(p, end, "null", 4);
|
||||
}
|
||||
if (c == '-' || (c >= '0' && c <= '9')) {
|
||||
return skipNumber(p, end);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool skipObject(const char *&p, const char *end, int depth)
|
||||
{
|
||||
if (depth <= 0) {
|
||||
return false; // nest deeper than the cap
|
||||
}
|
||||
++p; // '{'
|
||||
p = skipWhitespace(p, end);
|
||||
if (p < end && *p == '}') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
for (;;) {
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end || *p != '"') {
|
||||
return false;
|
||||
}
|
||||
if (!skipString(p, end)) {
|
||||
return false;
|
||||
}
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end || *p != ':') {
|
||||
return false;
|
||||
}
|
||||
++p;
|
||||
if (!skipValue(p, end, depth - 1)) {
|
||||
return false;
|
||||
}
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
if (*p == ',') {
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (*p == '}') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool skipArray(const char *&p, const char *end, int depth)
|
||||
{
|
||||
if (depth <= 0) {
|
||||
return false; // nest deeper than the cap
|
||||
}
|
||||
++p; // '['
|
||||
p = skipWhitespace(p, end);
|
||||
if (p < end && *p == ']') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
for (;;) {
|
||||
if (!skipValue(p, end, depth - 1)) {
|
||||
return false;
|
||||
}
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
if (*p == ',') {
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (*p == ']') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool skipValue(const char *&p, const char *end, int depth)
|
||||
{
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
const char c = *p;
|
||||
if (c == '{') {
|
||||
// pass depth through: skipObject consumes the single decrement for this level
|
||||
return skipObject(p, end, depth);
|
||||
}
|
||||
if (c == '[') {
|
||||
return skipArray(p, end, depth);
|
||||
}
|
||||
// a primitive is a leaf, so it never wastes a nesting level
|
||||
return skipPrimitive(p, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Iterates the members of the object starting at @p p.
|
||||
*
|
||||
* For each member invokes @p memberCallback with the key and the byte range of
|
||||
* its value. Advancing @p p is unaffected by the callback.
|
||||
*/
|
||||
template <typename F> bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback)
|
||||
{
|
||||
if (depth <= 0) {
|
||||
return false; // nest deeper than the cap
|
||||
}
|
||||
++p; // '{'
|
||||
p = skipWhitespace(p, end);
|
||||
if (p < end && *p == '}') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
for (;;) {
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end || *p != '"') {
|
||||
return false;
|
||||
}
|
||||
QString key;
|
||||
if (!decodeString(p, end, key)) {
|
||||
return false;
|
||||
}
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end || *p != ':') {
|
||||
return false;
|
||||
}
|
||||
++p;
|
||||
const char *valueStart = skipWhitespace(p, end);
|
||||
const char *valueEnd = valueStart;
|
||||
if (!skipValue(valueEnd, end, depth - 1)) {
|
||||
return false;
|
||||
}
|
||||
if (!memberCallback(key, valueStart, valueEnd)) {
|
||||
return false;
|
||||
}
|
||||
p = valueEnd;
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end) {
|
||||
return false;
|
||||
}
|
||||
if (*p == ',') {
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (*p == '}') {
|
||||
++p;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Counts the direct elements of an array value; returns -1 if the array is malformed.
|
||||
int countArrayElements(const char *p, const char *end, int depth)
|
||||
{
|
||||
if (depth <= 0) {
|
||||
return -1; // nest deeper than the cap
|
||||
}
|
||||
++p; // '['
|
||||
p = skipWhitespace(p, end);
|
||||
int count = 0;
|
||||
if (p < end && *p == ']') {
|
||||
return 0;
|
||||
}
|
||||
for (;;) {
|
||||
if (!skipValue(p, end, depth - 1)) {
|
||||
return -1;
|
||||
}
|
||||
++count;
|
||||
p = skipWhitespace(p, end);
|
||||
if (p >= end) {
|
||||
return -1;
|
||||
}
|
||||
if (*p == ',') {
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (*p == ']') {
|
||||
return count;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace RawJson
|
||||
{
|
||||
|
||||
QList<SetRange> scanSetRanges(const QByteArray &json, ScanError *error)
|
||||
{
|
||||
QList<SetRange> ranges;
|
||||
if (error) {
|
||||
*error = ScanError{};
|
||||
}
|
||||
|
||||
const auto fail = [&](const QString &message) -> QList<SetRange> {
|
||||
if (error) {
|
||||
error->message = message;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const char *begin = json.constData();
|
||||
const char *end = begin + json.size();
|
||||
if (begin >= end) {
|
||||
return fail(QStringLiteral("empty JSON document"));
|
||||
}
|
||||
|
||||
const char *p = skipWhitespace(begin, end);
|
||||
if (p >= end || *p != '{') {
|
||||
return fail(QStringLiteral("top-level JSON must be an object"));
|
||||
}
|
||||
|
||||
bool foundData = false;
|
||||
bool malformedSetData = false;
|
||||
|
||||
const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) {
|
||||
if (key == QStringLiteral("data")) {
|
||||
foundData = true;
|
||||
if (valueStart >= valueEnd || *valueStart != '{') {
|
||||
malformedSetData = true;
|
||||
return false;
|
||||
}
|
||||
const char *setP = valueStart;
|
||||
const bool ok = forEachObjectMember(setP, valueEnd, kMaxNestingDepth - 1,
|
||||
[&](const QString &setCode, const char *setStart, const char *setEnd) {
|
||||
if (setStart >= setEnd || *setStart != '{') {
|
||||
malformedSetData = true;
|
||||
return false;
|
||||
}
|
||||
SetRange range;
|
||||
range.dataRange.start = setStart - begin;
|
||||
range.dataRange.length = setEnd - setStart;
|
||||
range.code = setCode;
|
||||
|
||||
const char *memberP = setStart;
|
||||
const bool metaOk = forEachObjectMember(
|
||||
memberP, setEnd, kMaxNestingDepth - 2,
|
||||
[&](const QString &field, const char *fs, const char *fe) {
|
||||
if (field == QStringLiteral("code")) {
|
||||
return decodeStringMember(fs, fe, range.code);
|
||||
}
|
||||
if (field == QStringLiteral("name")) {
|
||||
return decodeStringMember(fs, fe, range.name);
|
||||
}
|
||||
if (field == QStringLiteral("type")) {
|
||||
return decodeStringMember(fs, fe, range.type);
|
||||
}
|
||||
if (field == QStringLiteral("releaseDate")) {
|
||||
return decodeStringMember(fs, fe, range.releaseDate);
|
||||
}
|
||||
if (field == QStringLiteral("cards")) {
|
||||
if (fs >= fe) {
|
||||
return false;
|
||||
}
|
||||
if (*fs != '[') {
|
||||
// e.g. "cards": null — treat as an empty array,
|
||||
// matching Qt's tolerance.
|
||||
return true;
|
||||
}
|
||||
range.dataRange.cardCount =
|
||||
countArrayElements(fs, fe, kMaxNestingDepth - 2);
|
||||
return range.dataRange.cardCount >= 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!metaOk) {
|
||||
malformedSetData = true;
|
||||
return false;
|
||||
}
|
||||
ranges.append(range);
|
||||
return true;
|
||||
});
|
||||
if (!ok) {
|
||||
malformedSetData = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback)) {
|
||||
return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON"));
|
||||
}
|
||||
p = skipWhitespace(p, end);
|
||||
if (p != end) {
|
||||
return fail(QStringLiteral("trailing content after top-level JSON object"));
|
||||
}
|
||||
if (!foundData) {
|
||||
return fail(QStringLiteral("missing \"data\" object"));
|
||||
}
|
||||
if (ranges.isEmpty()) {
|
||||
return fail(QStringLiteral("no sets found in \"data\""));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
} // namespace RawJson
|
||||
76
oracle/src/raw_json_scanner.h
Normal file
76
oracle/src/raw_json_scanner.h
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#ifndef RAW_JSON_SCANNER_H
|
||||
#define RAW_JSON_SCANNER_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
namespace RawJson
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief The byte extent of a set's object inside the scanned document, plus
|
||||
* the size of its cards array. This is the slice SetToDownload needs for lazy
|
||||
* per-set parsing; the metadata strings live in SetRange alongside it.
|
||||
*/
|
||||
struct SetDataRange
|
||||
{
|
||||
/** @brief Byte offset of the set's object within the scanned buffer. */
|
||||
qsizetype start = -1;
|
||||
/** @brief Byte length of the set's object, including the surrounding braces. */
|
||||
qsizetype length = 0;
|
||||
/** @brief Number of entries in the set's "cards" array. */
|
||||
int cardCount = 0;
|
||||
};
|
||||
|
||||
struct SetRange
|
||||
{
|
||||
/** @brief The byte slice of this set within the document. */
|
||||
SetDataRange dataRange;
|
||||
QString code;
|
||||
QString name;
|
||||
QString type;
|
||||
QString releaseDate;
|
||||
};
|
||||
|
||||
struct ScanError
|
||||
{
|
||||
bool isError() const
|
||||
{
|
||||
return !message.isEmpty();
|
||||
}
|
||||
QString message;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Scans a full MTGJSON document without materializing the JSON tree.
|
||||
*
|
||||
* Splits the top-level "data" object into per-set byte ranges and reads each
|
||||
* set's metadata directly from the raw bytes. The oracle importer can then
|
||||
* parse one set at a time during import, keeping peak memory far below a single
|
||||
* QJsonDocument::fromJson() over the whole file.
|
||||
*
|
||||
* The whole document is structurally validated while scanning (strings,
|
||||
* escapes, braces, and a trailing-content check) and nesting depth is capped at
|
||||
* 1024 to match QJsonDocument, so pathologically deep documents fail shallowly
|
||||
* instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson
|
||||
* on structurally malformed input; unlike Qt, string metadata fields
|
||||
* ("name", "type", "releaseDate", "code") tolerate null / non-string values by
|
||||
* defaulting to empty rather than rejecting the whole document, so one broken
|
||||
* set cannot abort the import of the rest.
|
||||
*
|
||||
* Following QJsonDocument::fromJson's convention, the parsed ranges are
|
||||
* returned by value and any failure is reported through the @p error out
|
||||
* parameter.
|
||||
*
|
||||
* @param json The raw MTGJSON document bytes.
|
||||
* @param error Out parameter. Set to an error ScanError when the document
|
||||
* cannot be parsed, otherwise left empty. Passing a null
|
||||
* pointer disables error reporting.
|
||||
* @return The detected per-set ranges, or an empty list on failure.
|
||||
*/
|
||||
QList<SetRange> scanSetRanges(const QByteArray &json, ScanError *error = nullptr);
|
||||
|
||||
} // namespace RawJson
|
||||
|
||||
#endif // RAW_JSON_SCANNER_H
|
||||
|
|
@ -95,6 +95,8 @@ set(DESKTOPDIR
|
|||
# Build servatrice binary and link it
|
||||
add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES})
|
||||
|
||||
target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||
|
||||
if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD")
|
||||
target_link_libraries(
|
||||
servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES}
|
||||
|
|
|
|||
71
servatrice/migrations/servatrice_0036_to_0037.sql
Normal file
71
servatrice/migrations/servatrice_0036_to_0037.sql
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
-- Servatrice db migration from version 36 to version 37
|
||||
|
||||
-- Deck sharing (temporary share links + permanent public decks).
|
||||
--
|
||||
-- This feature was developed behind several intermediate migrations that have
|
||||
-- never shipped, so they are folded into this single 36 -> 37 migration:
|
||||
-- temporary share links, permanent public-deck visibility, preview metadata,
|
||||
-- and per-deck tags.
|
||||
|
||||
-- 1. Temporary deck shares: a named bundle of decks that can be fetched by
|
||||
-- anyone who knows the (unguessable) token, until the share expires.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`token` varchar(64) NOT NULL,
|
||||
`name` varchar(64) NOT NULL,
|
||||
`created_by` int(7) unsigned NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `token` (`token`),
|
||||
KEY `expires_at` (`expires_at`),
|
||||
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Individual decks inside a share bundle. Content is materialized at share
|
||||
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
|
||||
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
|
||||
-- using prepared statements.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`share_id` int(7) unsigned zerofill NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`tags` text NULL,
|
||||
`banner_card` varchar(255) NULL,
|
||||
`game_format` varchar(50) NULL,
|
||||
`color_identity` varchar(5) NULL,
|
||||
`content` text NOT NULL,
|
||||
`position` int(7) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `share_id` (`share_id`),
|
||||
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Permanent deck sharing: add public visibility flags to the deck storage
|
||||
-- tables. A deck is visible to other users if it is marked public, or if any
|
||||
-- ancestor folder is marked public (inherited). Existing decks default to
|
||||
-- private, so the upgrade does not expose any data.
|
||||
ALTER TABLE `cockatrice_decklist_files`
|
||||
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`;
|
||||
|
||||
ALTER TABLE `cockatrice_decklist_folders`
|
||||
ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`;
|
||||
|
||||
-- 3. Per-deck preview metadata so clients can render another user's public
|
||||
-- decks (e.g. in a visual deck storage grid) without downloading each deck
|
||||
-- list. The metadata is computed by the uploading client; decks uploaded
|
||||
-- before this migration have empty values until they are re-uploaded.
|
||||
ALTER TABLE `cockatrice_decklist_files`
|
||||
ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `content`,
|
||||
ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`,
|
||||
ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`;
|
||||
|
||||
-- 4. Per-deck tags for public decks. The uploading client sends a
|
||||
-- comma-separated tag string (matching the deck's own tags), so another user's
|
||||
-- public decks can render and filter by tag without downloading each deck list.
|
||||
-- Decks uploaded before this migration have NULL tags until they are
|
||||
-- re-uploaded.
|
||||
ALTER TABLE `cockatrice_decklist_files`
|
||||
ADD COLUMN `tags` text NULL AFTER `color_identity`;
|
||||
|
||||
UPDATE cockatrice_schema_version SET version=37 WHERE version=36;
|
||||
|
|
@ -439,3 +439,19 @@ ssl_cert=ssl_cert.pem
|
|||
|
||||
; Filename of the private key for the server-to-server certificate
|
||||
ssl_key=ssl_key.pem
|
||||
|
||||
|
||||
[deck_share]
|
||||
|
||||
; How many days a created deck share link remains valid before it expires.
|
||||
; Default: 7
|
||||
expiry_days=7
|
||||
|
||||
; How often (in minutes) the server checks for and removes expired deck shares.
|
||||
; A value of 0 disables the automatic cleanup.
|
||||
; Default: 60
|
||||
cleanup_interval=60
|
||||
|
||||
; Maximum number of decks a single share link can contain.
|
||||
; Default: 50
|
||||
max_decks_per_share=50
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
|
|||
PRIMARY KEY (`version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO cockatrice_schema_version VALUES(36);
|
||||
INSERT INTO cockatrice_schema_version VALUES(37);
|
||||
|
||||
-- users and user data tables
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
|
||||
|
|
@ -63,16 +63,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` (
|
|||
`name` varchar(50) NOT NULL,
|
||||
`upload_time` datetime NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`is_public` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`banner_card_name` varchar(255) NULL,
|
||||
`banner_card_provider` varchar(32) NULL,
|
||||
`color_identity` varchar(5) NULL,
|
||||
`tags` text NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `FolderPlusUser` (`id_folder`,`id_user`),
|
||||
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Temporary deck shares: a named bundle of decks that can be fetched by
|
||||
-- anyone who knows the (unguessable) token, until the share expires.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`token` varchar(64) NOT NULL,
|
||||
`name` varchar(64) NOT NULL,
|
||||
`created_by` int(7) unsigned NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `token` (`token`),
|
||||
KEY `expires_at` (`expires_at`),
|
||||
FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Individual decks inside a share bundle. Content is materialized at share
|
||||
-- time so expiring/deleting a share can cascade cleanly. The metadata columns
|
||||
-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when
|
||||
-- using prepared statements.
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`share_id` int(7) unsigned zerofill NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`tags` text NULL,
|
||||
`banner_card` varchar(255) NULL,
|
||||
`game_format` varchar(50) NULL,
|
||||
`color_identity` varchar(5) NULL,
|
||||
`content` text NOT NULL,
|
||||
`position` int(7) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `share_id` (`share_id`),
|
||||
FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` (
|
||||
`id` int(7) unsigned zerofill NOT NULL auto_increment,
|
||||
`id_parent` int(7) unsigned zerofill NOT NULL,
|
||||
`id_user` int(7) unsigned NULL,
|
||||
`name` varchar(30) NOT NULL,
|
||||
`is_public` tinyint(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `ParentPlusUser` (`id_parent`,`id_user`),
|
||||
FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
|
|
|
|||
|
|
@ -428,6 +428,14 @@ bool Servatrice::initServer()
|
|||
statusUpdateClock->start(getServerStatusUpdateTime());
|
||||
}
|
||||
|
||||
deckShareCleanupClock = new QTimer(this);
|
||||
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
|
||||
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
|
||||
if (deckShareCleanupInterval > 0) {
|
||||
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
|
||||
deckShareCleanupClock->start(deckShareCleanupInterval);
|
||||
}
|
||||
|
||||
// SOCKET SERVER
|
||||
if (getNumberOfTCPPools() > 0) {
|
||||
gameServer =
|
||||
|
|
@ -600,6 +608,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList)
|
|||
qDebug() << "Set required client features to:" << serverRequiredFeatureList;
|
||||
}
|
||||
|
||||
void Servatrice::cleanupExpiredDeckShares()
|
||||
{
|
||||
servatriceDatabaseInterface->cleanupExpiredDeckShares();
|
||||
}
|
||||
|
||||
void Servatrice::statusUpdate()
|
||||
{
|
||||
if (!servatriceDatabaseInterface->checkSql()) {
|
||||
|
|
@ -1012,6 +1025,22 @@ int Servatrice::getServerStatusUpdateTime() const
|
|||
return settingsCache->value("server/statusupdate", 15000).toInt();
|
||||
}
|
||||
|
||||
int Servatrice::getDeckShareExpiryDays() const
|
||||
{
|
||||
return settingsCache->value("deck_share/expiry_days", 7).toInt();
|
||||
}
|
||||
|
||||
int Servatrice::getDeckShareCleanupInterval() const
|
||||
{
|
||||
// default: every 60 minutes
|
||||
return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000;
|
||||
}
|
||||
|
||||
int Servatrice::getDeckShareMaxDecksPerShare() const
|
||||
{
|
||||
return settingsCache->value("deck_share/max_decks_per_share", 50).toInt();
|
||||
}
|
||||
|
||||
int Servatrice::getNumberOfTCPPools() const
|
||||
{
|
||||
return settingsCache->value("server/number_pools", 1).toInt();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue