Cockatrice/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp
BruebachL 6823d54c1e
[DeckShare] Browse and open public decks with loading, error and accessibility states (#7245)
* [DeckShare] Browse and open public decks with loading, error and accessibility states

Add a public-decks tab that lists decks published by other users using the
server's deck visibility feature, previewing each deck's banner card, color
identity, tags and upload time without downloading the deck list until the
user opens it.

- Add a public-decks tab with a shared-settings widget and a remote model
  that fetches the target user's decks and refreshes both automatically and
  on user request, with a loading indicator and a server-error message
  instead of a blank tab when the fetch fails or the connection drops
- Render each deck as a focusable preview tile whose banner, color identity,
  tags and upload time follow the existing Preview settings, with the deck
  name announced as the tile's accessible name and Space/Enter opening the
  deck, mirroring the shared-deck preview tile
- Show a message box when opening a public deck fails or arrives corrupted
- Publish and unpublish decks from the server storage toolbar and context
  menu, toggling the deck's own visibility bit (what the server persists)
  rather than the inherited effective state, and batch the visibility
  refresh until the last in-flight change is acknowledged
- Add the Show Upload Time setting so the tile's upload stamp can be hidden
  like the other preview details
- Update the retranslateUi wiring for the new public-decks tab and rename
  the share action tooltip from "Deck share" to "Share link"

* [DeckShare] Adapt deck upload to the server-derived banner and tag protocol

The server now derives the banner card and tags from the uploaded deck
list itself, so Command_DeckUpload only carries the client-computed color
identity. Drop the reserved banner/tag setters from the editor and storage
uploads, send the color identity on remote saves, and read tags from the
now-repeated ServerInfo_DeckStorage_TreeItem field.

* [DeckStorage] Refresh the visibility column with a guarded timer instead of a latch counter

A dropped visibility reply used to leave the pendingVisibilityChanges
counter permanently positive, so the Public/Private column never refreshed
again and nothing reset it on disconnect. A restartable single-shot timer
with a boolean guard re-reads the tree whenever publishes quiet down and is
stopped on disconnect, so a lost reply costs one stale refresh instead of
killing the column for the session.

* [DeckStorage] Summarize batch publish failures when the batch drains

Each rejected node stacked its own modal dialog, so publishing a ten-deck
selection against a rejecting server made the user dismiss ten dialogs one
at a time. Failures are now collected while the batch is in flight and shown
as a single summary when the visibility refresh timer fires; a reply that
lands outside an active batch still reports right away.

* [PublicDecks] Time out the loading state so a dropped reply cannot wedge the tab

loading only cleared in decksReceived, but the ping sweep can drop a pending
command without ever emitting finished, leaving the tab stuck on 'Loading
public decks...' and the refresh button permanently inert. A single-shot
timer started per refresh clears the latch and reports a timeout; the latch
also clears when the client disconnects.

* [PublicDecks] Escape remote-crafted text in tooltips and the tab title

Deck names and usernames come from other users' records and Qt renders
QLabel tooltips as AutoText, so a name like '<h1><table>...' parsed as
markup. Escape and bound the deck-name tooltip and escape the username
interpolated into the title label.

* [DeckStorage] Distinguish an inherited public state in the visibility column

The column reported the effective state while publishing toggles the node's
own bit, so a private deck inside a public folder already read 'Public' and
toggling appeared to do nothing (and toggling again silently unpublished
it). The cell now shows 'Public (inherited)' for that case and the tooltip
explains why.

* [PublicDecks] Run retranslateUi at construction and name the refresh button

retranslateUi was never called from the constructor, so the tooltips set
there were absent until a language change. Call it before the first refresh,
and give the icon-only refresh button an accessible name for screen readers.

* [PublicDecks] Keep the empty and status variants correct across language changes

retranslateUi unconditionally rewrote the empty label to the 'nothing
published' variant, stomping the 'no decks match your filters' choice
rebuildGrid had made, and a visible loading message stayed in the old
language. Let retranslateUi pick the same variant rebuildGrid does and
re-show the status so it retranslates.

* [VDS] Share one color-identity match rule between the two deck grids

The remote public decks model verbatim-copied updateColorMatches' switch,
down to the ExactMatch normalization and the fact that Includes/Excludes do
not normalize case. Extract colorIdentityMatches() next to the FilterMode
enum and call it from both so the subtle rule cannot drift.

* [DeckStorage] Drop the unused tree widget model accessor

The accessor handed the model out past the wrapper methods that exist to
keep it encapsulated, and nothing in the stack called it.

* [DeckShare] End the public-decks files with a trailing newline

keeps the final line's diff clean and stops clang-format CI from flagging
the files.

* [VDS] Reuse the shared quick settings widget for the public decks tab

PublicDecksQuickSettingsWidget was VisualDeckStorageQuickSettingsWidget
minus the folders, banner and tooltip controls, with identical wiring for
the shared keys and a version of the near-identical file to keep in sync by
hand. Fold the Show Upload Time checkbox into the shared widget, give it a
setPublicDecksMode() that hides the controls that do not apply, and delete
the duplicate.

* [PublicDecks] Drop stale deck-list replies after the loading timeout

A reply that lands after its own loading timeout (the reverse of the ping
sweep dropping the command) could stop the newer request's timeout timer
and repaint the grid with out-of-date data. Each refresh now captures a
monotonically increasing request id, and only the newest request's reply
updates the grid.

* [PublicDecks] Re-show a displayed failure message on language changes

The status label carries both the loading and the failure message, and
retranslateUi hid it whenever the model was not loading, so a language
change while a server-error or timeout message was on screen swapped it
for the (empty) grid. The tab now keeps the last failure text and
re-shows it when not loading, clearing it once a new refresh starts.

* [DeckStorage] Keep the visibility refresh armed until replies land

The single-shot drain was armed with the 500 ms delay at send time, so a
round trip slower than that drained before the server applied the change,
re-read the old state and never re-armed, leaving the column stale until
a manual refresh. The timer is now armed with the full network timeout at
send time (a lost reply still costs one stale refresh) and re-armed with
the short delay every time a reply lands.

* [DeckShare] Close public decks tabs when the client disconnects

TabSupervisor::stop() built tabsToDelete from the room and game tabs
only, so a public decks tab survived a disconnect, sitting with stale
contents and a refresh button that kept hitting the dead client. Its
values are now folded into the same cleanup.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-20 20:22:17 +02:00

934 lines
34 KiB
C++

#include "tab_deck_storage.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../../pixel_map_generator.h"
#include "../cards/additional_info/deck_color_identity.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 <QTimer>
#include <QToolBar>
#include <QTreeView>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#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_set_visibility.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/network_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/string_limits.h>
namespace
{
// How long to wait after the last visibility change before reading back the
// Public/Private column, in milliseconds.
constexpr int VISIBILITY_REFRESH_DELAY = 500;
} // namespace
TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
const ServerInfo_User *currentUserInfo)
: Tab(_tabSupervisor), client(_client)
{
localDirModel = new QFileSystemModel(this);
localDirModel->setRootPath(SettingsCache::instance().paths().getDeckPath());
localDirModel->sort(0, Qt::AscendingOrder);
localDirView = new QTreeView;
localDirView->setModel(localDirModel);
localDirView->setColumnHidden(1, true);
localDirView->setRootIndex(localDirModel->index(localDirModel->rootPath(), 0));
localDirView->setSortingEnabled(true);
localDirView->setSelectionMode(QAbstractItemView::ExtendedSelection);
localDirView->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
localDirView->header()->setSortIndicator(0, Qt::AscendingOrder);
connect(localDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actLocalDoubleClick);
// Left side layout
/* put an invisible dummy QToolBar in the leftmost column so that the main toolbar is centered.
* Really ugly workaround, but I couldn't figure out the proper way to make it centered */
QToolBar *dummyToolBar = new QToolBar(this);
QSizePolicy sizePolicy = dummyToolBar->sizePolicy();
sizePolicy.setRetainSizeWhenHidden(true);
dummyToolBar->setSizePolicy(sizePolicy);
dummyToolBar->setVisible(false);
leftToolBar = new QToolBar(this);
leftToolBar->setOrientation(Qt::Horizontal);
leftToolBar->setIconSize(QSize(32, 32));
QToolBar *leftRightmostToolBar = new QToolBar(this);
leftRightmostToolBar->setOrientation(Qt::Horizontal);
leftRightmostToolBar->setIconSize(QSize(32, 32));
QGridLayout *leftToolBarLayout = new QGridLayout;
leftToolBarLayout->addWidget(dummyToolBar, 0, 0, Qt::AlignLeft);
leftToolBarLayout->addWidget(leftToolBar, 0, 1, Qt::AlignHCenter);
leftToolBarLayout->addWidget(leftRightmostToolBar, 0, 2, Qt::AlignRight);
QVBoxLayout *leftVbox = new QVBoxLayout;
leftVbox->addWidget(localDirView);
leftVbox->addLayout(leftToolBarLayout);
leftGroupBox = new QGroupBox;
leftGroupBox->setLayout(leftVbox);
// Right side layout
rightToolBar = new QToolBar;
rightToolBar->setOrientation(Qt::Horizontal);
rightToolBar->setIconSize(QSize(32, 32));
QHBoxLayout *rightToolBarLayout = new QHBoxLayout;
rightToolBarLayout->addStretch();
rightToolBarLayout->addWidget(rightToolBar);
rightToolBarLayout->addStretch();
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);
shareTimeoutTimer = new QTimer(this);
shareTimeoutTimer->setSingleShot(true);
shareTimeoutTimer->setInterval(
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
SettingsCache::instance().network().getKeepAlive() * 1000));
connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout);
// Restartable single-shot refresh for the Public/Private column. It is
// armed with the full network timeout when a publish is sent (so a dropped
// reply still drains once) and re-armed with the short delay every time a
// reply lands, so the drain cannot fire while a slow round trip is still in
// flight. Either way the tree is re-read once things quiet down.
visibilityRefreshTimer = new QTimer(this);
visibilityRefreshTimer->setSingleShot(true);
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
connect(visibilityRefreshTimer, &QTimer::timeout, this, &TabDeckStorage::onVisibilityRefreshTimeout);
QVBoxLayout *rightVbox = new QVBoxLayout;
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout);
rightGroupBox = new QGroupBox;
rightGroupBox->setLayout(rightVbox);
// combine layouts
QHBoxLayout *hbox = new QHBoxLayout;
hbox->addWidget(leftGroupBox);
hbox->addWidget(rightGroupBox);
// Left side actions
aOpenLocalDeck = new QAction(this);
aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck);
aRenameLocal = new QAction(this);
aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename")));
connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal);
aUpload = new QAction(this);
aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green")));
connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload);
aNewLocalFolder = new QAction(this);
aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder);
aDeleteLocalDeck = new QAction(this);
aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck);
aOpenDecksFolder = new QAction(this);
aOpenDecksFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_DirOpenIcon));
connect(aOpenDecksFolder, &QAction::triggered, this, &TabDeckStorage::actOpenDecksFolder);
// Right side actions
aOpenRemoteDeck = new QAction(this);
aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil")));
connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck);
aDownload = new QAction(this);
aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green")));
connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload);
aNewFolder = new QAction(this);
aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder));
connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder);
aDeleteRemoteDeck = new QAction(this);
aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck);
aShareDecks = new QAction(this);
aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share")));
connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks);
aPublishDeck = new QAction(this);
aPublishDeck->setIcon(QPixmap("theme:icons/lock"));
connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck);
// Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal);
leftToolBar->addAction(aUpload);
leftToolBar->addAction(aNewLocalFolder);
leftToolBar->addAction(aDeleteLocalDeck);
leftRightmostToolBar->addAction(aOpenDecksFolder);
rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck);
retranslateUi();
QWidget *mainWidget = new QWidget(this);
mainWidget->setLayout(hbox);
setCentralWidget(mainWidget);
connect(client, &AbstractClient::userInfoChanged, this, &TabDeckStorage::handleConnected);
connect(client, &AbstractClient::statusChanged, this, &TabDeckStorage::handleConnectionChanged);
setRemoteEnabled(currentUserInfo && currentUserInfo->user_level() & ServerInfo_User::IsRegistered);
}
void TabDeckStorage::retranslateUi()
{
leftGroupBox->setTitle(tr("Local file system"));
rightGroupBox->setTitle(tr("Server deck storage"));
aOpenLocalDeck->setText(tr("Open in deck editor"));
aRenameLocal->setText(tr("Rename deck or folder"));
aUpload->setText(tr("Upload deck"));
aOpenRemoteDeck->setText(tr("Open in deck editor"));
aDownload->setText(tr("Download deck"));
aNewLocalFolder->setText(tr("New folder"));
aNewFolder->setText(tr("New folder"));
aDeleteLocalDeck->setText(tr("Delete"));
aDeleteRemoteDeck->setText(tr("Delete"));
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder"));
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
}
}
QString TabDeckStorage::getTargetPath() const
{
RemoteDeckList_TreeModel::Node *curRight = serverDirView->getCurrentItem();
if (curRight == nullptr) {
return {};
}
auto *dir = dynamic_cast<RemoteDeckList_TreeModel::DirectoryNode *>(curRight);
if (dir == nullptr) {
dir = dynamic_cast<RemoteDeckList_TreeModel::DirectoryNode *>(curRight->getParent());
if (dir != nullptr) {
return dir->getPath();
} else {
return {};
}
} else {
return dir->getPath();
}
}
void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
{
setRemoteEnabled(userInfo.user_level() & ServerInfo_User::IsRegistered);
}
/**
* This is only responsible for handling the disconnect. The connect is already handled elsewhere
*/
void TabDeckStorage::handleConnectionChanged(ClientStatus status)
{
if (status == StatusDisconnected) {
visibilityRefreshTimer->stop();
visibilityRefreshStarted = false;
setRemoteEnabled(false);
}
}
void TabDeckStorage::setRemoteEnabled(bool enabled)
{
aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled);
if (enabled) {
serverDirView->refreshTree();
} else {
setShareModeEnabled(false);
serverDirView->clearTree();
}
}
void TabDeckStorage::actLocalDoubleClick(const QModelIndex &curLeft)
{
if (!localDirModel->isDir(curLeft)) {
actOpenLocalDeck();
}
}
void TabDeckStorage::actOpenLocalDeck()
{
QModelIndexList curLefts = localDirView->selectionModel()->selectedRows();
for (const auto &curLeft : curLefts) {
if (localDirModel->isDir(curLeft)) {
continue;
}
QString filePath = localDirModel->filePath(curLeft);
std::optional<LoadedDeck> deckOpt = DeckLoader::loadFromFile(filePath, DeckFileFormat::Cockatrice, true);
if (!deckOpt) {
continue;
}
emit openDeckEditor(deckOpt.value());
}
}
void TabDeckStorage::actRenameLocal()
{
QModelIndexList curLefts = localDirView->selectionModel()->selectedRows();
for (const auto &curLeft : curLefts) {
const QFileInfo info = localDirModel->fileInfo(curLeft);
const QString oldName = info.baseName();
const QString title = info.isDir() ? tr("Rename local folder") : tr("Rename local file");
bool ok;
QString newName = QInputDialog::getText(this, title, tr("New name:"), QLineEdit::Normal, oldName, &ok);
if (!ok) { // terminate all remaining selections if user cancels
return;
}
if (newName.isEmpty() || oldName == newName) {
continue;
}
QString newFileName = newName;
if (!info.suffix().isEmpty()) {
newFileName += "." + info.suffix();
}
const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath();
if (!QFile::rename(info.filePath(), newFilePath)) {
QMessageBox::critical(this, tr("Error"), tr("Rename failed"));
}
}
}
void TabDeckStorage::actUpload()
{
QModelIndexList curLefts = localDirView->selectionModel()->selectedRows();
if (curLefts.isEmpty()) {
return;
}
QString targetPath = getTargetPath();
if (targetPath.length() > MAX_NAME_LENGTH) {
qCritical() << "target path to upload to is too long" << targetPath;
return;
}
for (const auto &curLeft : curLefts) {
if (localDirModel->isDir(curLeft)) {
continue;
}
QString filePath = localDirModel->filePath(curLeft);
uploadDeck(filePath, targetPath);
}
}
void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPath)
{
QFile deckFile(filePath);
QFileInfo deckFileInfo(deckFile);
std::optional<LoadedDeck> deckOpt = DeckLoader::loadFromFile(filePath, DeckFileFormat::Cockatrice, true);
if (!deckOpt) {
QMessageBox::critical(this, tr("Error"), tr("Invalid deck file"));
return;
}
DeckList deck = deckOpt.value().deckList;
if (deck.getName().isEmpty()) {
bool ok;
QString deckName =
getTextWithMax(this, tr("Enter deck name"), tr("This decklist does not have a name.\nPlease enter a name:"),
QLineEdit::Normal, deckFileInfo.completeBaseName(), &ok);
if (!ok) {
return;
}
if (deckName.isEmpty()) {
deckName = tr("Unnamed deck");
}
deck.setName(deckName);
} else {
deck.setName(deck.getName().left(MAX_NAME_LENGTH));
}
QString deckString = deck.writeToString_Native();
if (deckString.length() > MAX_FILE_LENGTH) {
QMessageBox::critical(this, tr("Error"), tr("Invalid deck file"));
return;
}
Command_DeckUpload cmd;
cmd.set_path(targetPath.toStdString());
cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished);
client->sendCommand(pend);
}
void TabDeckStorage::uploadFinished(const Response &r, const CommandContainer &commandContainer)
{
if (r.response_code() != Response::RespOk) {
qCritical() << "failed to upload deck:" << r.response_code();
QMessageBox::critical(this, tr("Error"), tr("Failed to upload deck to server"));
return;
}
const Response_DeckUpload &resp = r.GetExtension(Response_DeckUpload::ext);
const Command_DeckUpload &cmd = commandContainer.session_command(0).GetExtension(Command_DeckUpload::ext);
serverDirView->addFileToTree(resp.new_file(), serverDirView->getNodeByPath(QString::fromStdString(cmd.path())));
}
void TabDeckStorage::actNewLocalFolder()
{
QModelIndex curLeft = localDirView->selectionModel()->currentIndex();
QModelIndex dirIndex;
if (curLeft.isValid() && !localDirModel->isDir(curLeft)) {
dirIndex = curLeft.parent();
} else {
dirIndex = curLeft;
}
bool ok;
QString folderName =
QInputDialog::getText(this, tr("New folder"), tr("Name of new folder:"), QLineEdit::Normal, "", &ok);
if (!ok || folderName.isEmpty()) {
return;
}
localDirModel->mkdir(dirIndex, folderName);
}
void TabDeckStorage::actDeleteLocalDeck()
{
const QModelIndexList curLefts = localDirView->selectionModel()->selectedRows();
if (curLefts.isEmpty()) {
return;
}
if (QMessageBox::warning(this, tr("Delete local file"), tr("Are you sure you want to delete the selected files?"),
QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) {
return;
}
for (const auto &curLeft : curLefts) {
if (curLeft.isValid()) {
localDirModel->remove(curLeft);
}
}
}
void TabDeckStorage::actOpenDecksFolder()
{
QString dir = localDirModel->rootPath();
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
}
void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight)
{
if (dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(serverDirView->getNode(curRight))) {
actOpenRemoteDeck();
}
}
void TabDeckStorage::actOpenRemoteDeck()
{
for (const auto &curRight : serverDirView->getCurrentSelection()) {
RemoteDeckList_TreeModel::FileNode *node = dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(curRight);
if (!node) {
continue;
}
Command_DeckDownload cmd;
cmd.set_deck_id(node->getId());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::openRemoteDeckFinished);
client->sendCommand(pend);
}
}
void TabDeckStorage::openRemoteDeckFinished(const Response &r, const CommandContainer &commandContainer)
{
if (r.response_code() != Response::RespOk) {
return;
}
const Response_DeckDownload &resp = r.GetExtension(Response_DeckDownload::ext);
const Command_DeckDownload &cmd = commandContainer.session_command(0).GetExtension(Command_DeckDownload::ext);
std::optional<LoadedDeck> deckOpt = DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), cmd.deck_id());
if (!deckOpt) {
return;
}
emit openDeckEditor(deckOpt.value());
}
void TabDeckStorage::actDownload()
{
QModelIndex curLeft = localDirView->selectionModel()->currentIndex();
while (!localDirModel->isDir(curLeft)) {
curLeft = curLeft.parent();
}
for (const auto curRight : serverDirView->selectionModel()->selectedRows()) {
downloadNodeAtIndex(curLeft, curRight);
}
}
void TabDeckStorage::downloadNodeAtIndex(const QModelIndex &curLeft, const QModelIndex &curRight)
{
auto node = serverDirView->getNode(curRight);
if (const auto dirNode = dynamic_cast<RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
// node at index is a folder
const QString name = dirNode->getName();
const auto dirIndex = curLeft.isValid() ? curLeft : localDirModel->index(localDirModel->rootPath());
const auto newDirIndex = localDirModel->mkdir(dirIndex, name);
int rows = serverDirView->model()->rowCount(curRight);
for (int i = 0; i < rows; i++) {
const auto childIndex = serverDirView->model()->index(i, 0, curRight);
downloadNodeAtIndex(newDirIndex, childIndex);
}
} else if (const auto fileNode = dynamic_cast<RemoteDeckList_TreeModel::FileNode *>(node)) {
// node at index is a deck
const QString dirPath = curLeft.isValid() ? localDirModel->filePath(curLeft) : localDirModel->rootPath();
const QString filePath = dirPath + QString("/deck_%1.cod").arg(fileNode->getId());
Command_DeckDownload cmd;
cmd.set_deck_id(fileNode->getId());
PendingCommand *pend = client->prepareSessionCommand(cmd);
pend->setExtraData(filePath);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::downloadFinished);
client->sendCommand(pend);
}
// node at index is invalid
}
void TabDeckStorage::downloadFinished(const Response &r,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (r.response_code() != Response::RespOk) {
return;
}
const Response_DeckDownload &resp = r.GetExtension(Response_DeckDownload::ext);
QString filePath = extraData.toString();
DeckList deckList = DeckList(QString::fromStdString(resp.deck()));
DeckLoader::saveToFile(deckList, filePath, DeckFileFormat::Cockatrice);
}
void TabDeckStorage::actNewFolder()
{
QString targetPath = getTargetPath();
int max_length = MAX_NAME_LENGTH - targetPath.length() - 1; // generated length would be path + / + name
if (max_length < 1) { // can't create path that's short enough
return;
}
QString folderName = getTextWithMax(this, tr("New folder"), tr("Name of new folder:"), max_length);
if (folderName.isEmpty()) {
return;
}
// '/' isn't a valid filename character on *nix so we're choosing to replace it with a different arbitrary
// character.
std::string folder = folderName.toStdString();
std::replace(folder.begin(), folder.end(), '/', '-');
Command_DeckNewDir cmd;
cmd.set_path(targetPath.toStdString());
cmd.set_dir_name(folder);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::newFolderFinished);
client->sendCommand(pend);
}
void TabDeckStorage::newFolderFinished(const Response &response, const CommandContainer &commandContainer)
{
if (response.response_code() != Response::RespOk) {
return;
}
const Command_DeckNewDir &cmd = commandContainer.session_command(0).GetExtension(Command_DeckNewDir::ext);
serverDirView->addFolderToTree(QString::fromStdString(cmd.dir_name()),
serverDirView->getNodeByPath(QString::fromStdString(cmd.path())));
}
void TabDeckStorage::actDeleteRemoteDeck()
{
auto curRights = serverDirView->getCurrentSelection();
if (curRights.isEmpty()) {
return;
}
if (QMessageBox::warning(this, tr("Delete remote decks"), tr("Are you sure you want to delete the selected decks?"),
QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) {
return;
}
for (const auto &curRight : curRights) {
deleteRemoteDeck(curRight);
}
}
void TabDeckStorage::deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *curRight)
{
if (!curRight) {
return;
}
PendingCommand *pend;
if (const auto *dir = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(curRight)) {
QString targetPath = dir->getPath();
if (targetPath.isEmpty()) {
return;
}
if (targetPath.length() > MAX_NAME_LENGTH) {
qCritical() << "target path to delete is too long" << targetPath;
return;
}
Command_DeckDelDir cmd;
cmd.set_path(targetPath.toStdString());
pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::deleteFolderFinished);
} else {
const auto *deckNode = dynamic_cast<const RemoteDeckList_TreeModel::FileNode *>(curRight);
Command_DeckDel cmd;
cmd.set_deck_id(deckNode->getId());
pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::deleteDeckFinished);
}
client->sendCommand(pend);
}
void TabDeckStorage::deleteDeckFinished(const Response &response, const CommandContainer &commandContainer)
{
if (response.response_code() != Response::RespOk) {
return;
}
const Command_DeckDel &cmd = commandContainer.session_command(0).GetExtension(Command_DeckDel::ext);
RemoteDeckList_TreeModel::Node *toDelete = serverDirView->getNodeById(cmd.deck_id());
if (toDelete) {
serverDirView->removeNode(toDelete);
}
}
void TabDeckStorage::deleteFolderFinished(const Response &response, const CommandContainer &commandContainer)
{
if (response.response_code() != Response::RespOk) {
return;
}
const Command_DeckDelDir &cmd = commandContainer.session_command(0).GetExtension(Command_DeckDelDir::ext);
RemoteDeckList_TreeModel::Node *toDelete = serverDirView->getNodeByPath(QString::fromStdString(cmd.path()));
if (toDelete) {
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 {
// Abandon any in-flight request: otherwise the timer keeps running and a late
// response reports the share as created after the user already backed out.
shareTimeoutTimer->stop();
shareInFlightSeq = 0;
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(QStringLiteral(", "))));
}
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);
const int seq = ++shareRequestSeq;
shareInFlightSeq = seq;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, seq](const Response &response, const CommandContainer &commandContainer) {
if (shareInFlightSeq != seq) {
return; // the user cancelled or a newer request superseded this one
}
shareInFlightSeq = 0;
shareFromTreeFinished(response, commandContainer);
});
client->sendCommand(pend);
shareTimeoutTimer->start();
}
void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/)
{
shareTimeoutTimer->stop();
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 DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response);
showShareNotice(
tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry)));
setShareModeEnabled(false);
}
void TabDeckStorage::showShareNotice(const QString &message, bool warning)
{
QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message,
QMessageBox::Ok, this);
box.exec();
}
void TabDeckStorage::onShareFromTreeTimeout()
{
if (shareInFlightSeq == 0) {
return; // share mode was left while the request was still outstanding
}
shareInFlightSeq = 0;
shareBar->setCreateEnabled(true);
showShareNotice(tr("The server did not respond in time. Try again."), true);
}
void TabDeckStorage::actPublishDeck()
{
visibilityFailures.clear();
// Arm the drain with the full network timeout so a lost reply still costs
// one refresh instead of a dead column; each reply shrinks it to the short
// delay below, so a slow round trip is never drained before it lands.
const int visibilityFailSafeDelay =
static_cast<int>((static_cast<qint64>(SettingsCache::instance().network().getTimeOut()) + 1) *
SettingsCache::instance().network().getKeepAlive() * 1000);
const auto selection = serverDirView->getCurrentSelection();
for (const auto *node : selection) {
Command_DeckSetVisibility cmd;
if (const auto *fileNode = dynamic_cast<const RemoteDeckList_TreeModel::FileNode *>(node)) {
cmd.set_deck_id(fileNode->getId());
} else if (const auto *dirNode = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(node)) {
const QString path = dirNode->getPath();
if (path.isEmpty()) {
continue; // the root folder cannot be published
}
cmd.set_folder_path(path.toStdString());
} else {
continue;
}
// Toggle the node's own visibility bit (what the server persists); the
// effective visibility shown by the column may additionally be inherited
// from a parent folder.
cmd.set_is_public(!node->isPublic());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished);
visibilityRefreshStarted = true;
visibilityRefreshTimer->setInterval(visibilityFailSafeDelay);
visibilityRefreshTimer->start();
client->sendCommand(pend);
}
}
void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/)
{
if (r.response_code() == Response::RespOk) {
if (visibilityRefreshStarted) {
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
visibilityRefreshTimer->start();
}
return;
}
// Collect batch failures and surface them once, when publishing quiets
// down, instead of stacking one modal dialog per rejected node.
const QString message = tr("Failed to change deck visibility on server (response code %1).")
.arg(QString::number(static_cast<int>(r.response_code())));
if (visibilityRefreshStarted) {
visibilityFailures.append(message);
visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY);
visibilityRefreshTimer->start();
} else {
QMessageBox::critical(this, tr("Error"), message);
}
}
void TabDeckStorage::onVisibilityRefreshTimeout()
{
visibilityRefreshStarted = false;
if (!visibilityFailures.isEmpty()) {
QMessageBox::critical(
this, tr("Error"),
tr("Failed to change the visibility of %n selected deck(s).", "", visibilityFailures.size()));
visibilityFailures.clear();
}
serverDirView->refreshTree();
}