mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-22 09:35:08 -07:00
* [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>
934 lines
34 KiB
C++
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();
|
|
}
|