Analyze deck on tappedout Fix small bug in deckstats interface
This commit is contained in:
parent
680277ad6a
commit
7f28a7e844
10 changed files with 310 additions and 21 deletions
|
@ -80,6 +80,7 @@ SET(cockatrice_SOURCES
|
|||
src/tab_logs.cpp
|
||||
src/replay_timeline_widget.cpp
|
||||
src/deckstats_interface.cpp
|
||||
src/tappedout_interface.cpp
|
||||
src/chatview.cpp
|
||||
src/userlist.cpp
|
||||
src/userinfobox.cpp
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
#include <QDebug>
|
||||
#include "deck_loader.h"
|
||||
#include "decklist.h"
|
||||
#include "carddatabase.h"
|
||||
#include "main.h"
|
||||
|
||||
const QStringList DeckLoader::fileNameFilters = QStringList()
|
||||
<< QObject::tr("Common deck formats (*.cod *.dec *.txt *.mwDeck)")
|
||||
|
@ -108,3 +110,89 @@ DeckLoader::FileFormat DeckLoader::getFormatFromName(const QString &fileName)
|
|||
}
|
||||
return PlainTextFormat;
|
||||
}
|
||||
|
||||
bool DeckLoader::saveToStream_Plain(QTextStream &out)
|
||||
{
|
||||
saveToStream_DeckHeader(out);
|
||||
|
||||
// loop zones
|
||||
for (int i = 0; i < getRoot()->size(); i++) {
|
||||
const InnerDecklistNode *zoneNode =
|
||||
dynamic_cast<InnerDecklistNode *>(getRoot()->at(i));
|
||||
|
||||
saveToStream_DeckZone(out, zoneNode);
|
||||
|
||||
// end of zone
|
||||
out << "\n";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void DeckLoader::saveToStream_DeckHeader(QTextStream &out)
|
||||
{
|
||||
if(!getName().isEmpty())
|
||||
out << "// " << getName() << "\n\n";
|
||||
if(!getComments().isEmpty())
|
||||
{
|
||||
QStringList commentRows = getComments().split(QRegExp("\n|\r\n|\r"));
|
||||
foreach(QString row, commentRows)
|
||||
out << "// " << row << "\n";
|
||||
out << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode)
|
||||
{
|
||||
// group cards by card type and count the subtotals
|
||||
QMultiMap<QString, DecklistCardNode*> cardsByType;
|
||||
QMap<QString, int> cardTotalByType;
|
||||
int cardTotal = 0;
|
||||
|
||||
for (int j = 0; j < zoneNode->size(); j++) {
|
||||
DecklistCardNode *card =
|
||||
dynamic_cast<DecklistCardNode *>(
|
||||
zoneNode->at(j)
|
||||
);
|
||||
|
||||
CardInfo *info = db->getCard(card->getName());
|
||||
QString cardType = info ? info->getMainCardType() : "unknown";
|
||||
|
||||
cardsByType.insert(cardType, card);
|
||||
|
||||
if(cardTotalByType.contains(cardType))
|
||||
cardTotalByType[cardType] += card->getNumber();
|
||||
else
|
||||
cardTotalByType[cardType] = card->getNumber();
|
||||
|
||||
cardTotal += card->getNumber();
|
||||
}
|
||||
|
||||
out << "// " << cardTotal << " " << zoneNode->getVisibleName() << "\n";
|
||||
|
||||
// print cards to stream
|
||||
foreach(QString cardType, cardsByType.uniqueKeys())
|
||||
{
|
||||
|
||||
out << "// " << cardTotalByType[cardType] << " " << cardType << "\n";
|
||||
QList <DecklistCardNode*> cards = cardsByType.values(cardType);
|
||||
|
||||
saveToStream_DeckZoneCards(out, zoneNode, cards);
|
||||
|
||||
out << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, const InnerDecklistNode *zoneNode, QList <DecklistCardNode*> cards)
|
||||
{
|
||||
// QMultiMap sorts values in reverse order
|
||||
for(int i = cards.size() - 1; i >= 0; --i)
|
||||
{
|
||||
DecklistCardNode* card = cards[i];
|
||||
|
||||
if (zoneNode->getName() == "side")
|
||||
out << "SB: ";
|
||||
|
||||
out << card->getNumber() << " " << card->getName() << "\n";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,14 @@ public:
|
|||
bool loadFromFile(const QString &fileName, FileFormat fmt);
|
||||
bool loadFromRemote(const QString &nativeString, int remoteDeckId);
|
||||
bool saveToFile(const QString &fileName, FileFormat fmt);
|
||||
|
||||
// overload
|
||||
bool saveToStream_Plain(QTextStream &out);
|
||||
|
||||
protected:
|
||||
void saveToStream_DeckHeader(QTextStream &out);
|
||||
void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode);
|
||||
void saveToStream_DeckZoneCards(QTextStream &out, const InnerDecklistNode *zoneNode, QList <DecklistCardNode*> cards);
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
@ -30,7 +30,7 @@ void DeckStatsInterface::queryFinished(QNetworkReply *reply)
|
|||
reply->deleteLater();
|
||||
|
||||
QRegExp rx("<meta property=\"og:url\" content=\"([^\"]+)\"/>");
|
||||
if (!rx.indexIn(data)) {
|
||||
if (-1 == rx.indexIn(data)) {
|
||||
QMessageBox::critical(0, tr("Error"), tr("The reply from the server could not be parsed."));
|
||||
deleteLater();
|
||||
return;
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
#include "priceupdater.h"
|
||||
#include "tab_supervisor.h"
|
||||
#include "deckstats_interface.h"
|
||||
#include "tappedout_interface.h"
|
||||
#include "abstractclient.h"
|
||||
#include "pending_command.h"
|
||||
#include "pb/response.pb.h"
|
||||
|
@ -226,8 +227,15 @@ void TabDeckEditor::createMenus()
|
|||
aPrintDeck = new QAction(QString(), this);
|
||||
connect(aPrintDeck, SIGNAL(triggered()), this, SLOT(actPrintDeck()));
|
||||
|
||||
aAnalyzeDeck = new QAction(QString(), this);
|
||||
connect(aAnalyzeDeck, SIGNAL(triggered()), this, SLOT(actAnalyzeDeck()));
|
||||
aAnalyzeDeckDeckstats = new QAction(QString(), this);
|
||||
connect(aAnalyzeDeckDeckstats, SIGNAL(triggered()), this, SLOT(actAnalyzeDeckDeckstats()));
|
||||
|
||||
aAnalyzeDeckTappedout = new QAction(QString(), this);
|
||||
connect(aAnalyzeDeckTappedout, SIGNAL(triggered()), this, SLOT(actAnalyzeDeckTappedout()));
|
||||
|
||||
analyzeDeckMenu = new QMenu(this);
|
||||
analyzeDeckMenu->addAction(aAnalyzeDeckDeckstats);
|
||||
analyzeDeckMenu->addAction(aAnalyzeDeckTappedout);
|
||||
|
||||
aClose = new QAction(QString(), this);
|
||||
connect(aClose, SIGNAL(triggered()), this, SLOT(closeRequest()));
|
||||
|
@ -250,7 +258,7 @@ void TabDeckEditor::createMenus()
|
|||
deckMenu->addAction(aSaveDeckToClipboard);
|
||||
deckMenu->addSeparator();
|
||||
deckMenu->addAction(aPrintDeck);
|
||||
deckMenu->addAction(aAnalyzeDeck);
|
||||
deckMenu->addMenu(analyzeDeckMenu);
|
||||
deckMenu->addSeparator();
|
||||
deckMenu->addAction(aClearFilterOne);
|
||||
deckMenu->addAction(aClearFilterAll);
|
||||
|
@ -444,7 +452,7 @@ void TabDeckEditor::refreshShortcuts()
|
|||
aSaveDeckAs->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aSaveDeckAs"));
|
||||
aLoadDeckFromClipboard->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aLoadDeckFromClipboard"));
|
||||
aPrintDeck->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aPrintDeck"));
|
||||
aAnalyzeDeck->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aAnalyzeDeck"));
|
||||
aAnalyzeDeckDeckstats->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aAnalyzeDeck"));
|
||||
aClose->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aClose"));
|
||||
aResetLayout->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aResetLayout"));
|
||||
aClearFilterAll->setShortcuts(settingsCache->shortcuts().getShortcut("TabDeckEditor/aClearFilterAll"));
|
||||
|
@ -536,7 +544,11 @@ void TabDeckEditor::retranslateUi()
|
|||
aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard..."));
|
||||
aSaveDeckToClipboard->setText(tr("Save deck to clip&board"));
|
||||
aPrintDeck->setText(tr("&Print deck..."));
|
||||
aAnalyzeDeck->setText(tr("&Analyze deck on deckstats.net"));
|
||||
|
||||
analyzeDeckMenu->setTitle(tr("&Analyze deck online"));
|
||||
aAnalyzeDeckDeckstats->setText(tr("deckstats.net"));
|
||||
aAnalyzeDeckTappedout->setText(tr("tappedout.net"));
|
||||
|
||||
aClose->setText(tr("&Close"));
|
||||
|
||||
aAddCard->setText(tr("Add card to &maindeck"));
|
||||
|
@ -751,7 +763,7 @@ void TabDeckEditor::actPrintDeck()
|
|||
dlg->exec();
|
||||
}
|
||||
|
||||
void TabDeckEditor::actAnalyzeDeck()
|
||||
void TabDeckEditor::actAnalyzeDeckDeckstats()
|
||||
{
|
||||
DeckStatsInterface *interface = new DeckStatsInterface(
|
||||
*databaseModel->getDatabase(),
|
||||
|
@ -760,6 +772,15 @@ void TabDeckEditor::actAnalyzeDeck()
|
|||
interface->analyzeDeck(deckModel->getDeckList());
|
||||
}
|
||||
|
||||
void TabDeckEditor::actAnalyzeDeckTappedout()
|
||||
{
|
||||
TappedOutInterface *interface = new TappedOutInterface(
|
||||
*databaseModel->getDatabase(),
|
||||
this
|
||||
); // it deletes itself when done
|
||||
interface->analyzeDeck(deckModel->getDeckList());
|
||||
}
|
||||
|
||||
void TabDeckEditor::actClearFilterAll()
|
||||
{
|
||||
databaseDisplayModel->clearFilterAll();
|
||||
|
|
|
@ -52,7 +52,8 @@ class TabDeckEditor : public Tab {
|
|||
void actLoadDeckFromClipboard();
|
||||
void actSaveDeckToClipboard();
|
||||
void actPrintDeck();
|
||||
void actAnalyzeDeck();
|
||||
void actAnalyzeDeckDeckstats();
|
||||
void actAnalyzeDeckTappedout();
|
||||
|
||||
void actClearFilterAll();
|
||||
void actClearFilterOne();
|
||||
|
@ -112,8 +113,8 @@ private:
|
|||
QTreeView *filterView;
|
||||
QWidget *filterBox;
|
||||
|
||||
QMenu *deckMenu, *viewMenu, *cardInfoDockMenu, *deckDockMenu, *filterDockMenu;
|
||||
QAction *aNewDeck, *aLoadDeck, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, *aSaveDeckToClipboard, *aPrintDeck, *aAnalyzeDeck, *aClose;
|
||||
QMenu *deckMenu, *viewMenu, *cardInfoDockMenu, *deckDockMenu, *filterDockMenu, *analyzeDeckMenu;
|
||||
QAction *aNewDeck, *aLoadDeck, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, *aSaveDeckToClipboard, *aPrintDeck, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose;
|
||||
QAction *aClearFilterAll, *aClearFilterOne;
|
||||
QAction *aAddCard, *aAddCardToSideboard, *aRemoveCard, *aIncrement, *aDecrement;// *aUpdatePrices;
|
||||
QAction *aResetLayout;
|
||||
|
|
125
cockatrice/src/tappedout_interface.cpp
Normal file
125
cockatrice/src/tappedout_interface.cpp
Normal file
|
@ -0,0 +1,125 @@
|
|||
#include "tappedout_interface.h"
|
||||
#include "decklist.h"
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QRegExp>
|
||||
#include <QMessageBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QUrlQuery>
|
||||
|
||||
TappedOutInterface::TappedOutInterface(
|
||||
CardDatabase &_cardDatabase,
|
||||
QObject *parent
|
||||
) : QObject(parent), cardDatabase(_cardDatabase)
|
||||
{
|
||||
manager = new QNetworkAccessManager(this);
|
||||
connect(manager, SIGNAL(finished(QNetworkReply *)), this, SLOT(queryFinished(QNetworkReply *)));
|
||||
}
|
||||
|
||||
void TappedOutInterface::queryFinished(QNetworkReply *reply)
|
||||
{
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
QMessageBox::critical(0, tr("Error"), reply->errorString());
|
||||
reply->deleteLater();
|
||||
deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if(reply->hasRawHeader("Location"))
|
||||
{
|
||||
/*
|
||||
* If the reply contains a "Location" header, a relative URL to the deck on TappedOut
|
||||
* can be extracted from the header. The http status is a 302 "redirect".
|
||||
*/
|
||||
QString deckUrl = reply->rawHeader("Location");
|
||||
qDebug() << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl;
|
||||
QDesktopServices::openUrl("http://tappedout.net" + deckUrl);
|
||||
} else {
|
||||
/*
|
||||
* Otherwise, the deck has not been parsed correctly. Error messages can be extracted
|
||||
* from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li")
|
||||
*/
|
||||
QString data(reply->readAll());
|
||||
QString errorMessage = tr("Unable to analyze the deck.");
|
||||
|
||||
QRegExp rx("<div class=\"alert alert-danger.*<ul>(.*)</ul>");
|
||||
rx.setMinimal(true);
|
||||
int found = rx.indexIn(data);
|
||||
if(found >= 0)
|
||||
{
|
||||
QString errors = rx.cap(1);
|
||||
QRegExp rx2("<li>(.*)</li>");
|
||||
rx2.setMinimal(true);
|
||||
|
||||
found = rx2.indexIn(errors);
|
||||
int captures = rx2.captureCount();
|
||||
for(int i = 1; i <= captures; i++)
|
||||
{
|
||||
errorMessage += QString("\n") + rx2.cap(i).remove(QRegExp("<[^>]*>")).simplified();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
qDebug() << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size() << "message" << errorMessage;
|
||||
|
||||
QMessageBox::critical(0, tr("Error"), errorMessage);
|
||||
}
|
||||
|
||||
reply->deleteLater();
|
||||
deleteLater();
|
||||
}
|
||||
|
||||
void TappedOutInterface::getAnalyzeRequestData(DeckList *deck, QByteArray *data)
|
||||
{
|
||||
DeckList mainboard, sideboard;
|
||||
copyDeckSplitMainAndSide(*deck, mainboard, sideboard);
|
||||
|
||||
QUrl params;
|
||||
QUrlQuery urlQuery;
|
||||
urlQuery.addQueryItem("name", deck->getName());
|
||||
urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false));
|
||||
urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false));
|
||||
params.setQuery(urlQuery);
|
||||
data->append(params.query(QUrl::EncodeReserved));
|
||||
}
|
||||
|
||||
void TappedOutInterface::analyzeDeck(DeckList *deck)
|
||||
{
|
||||
QByteArray data;
|
||||
getAnalyzeRequestData(deck, &data);
|
||||
|
||||
QNetworkRequest request(QUrl("http://tappedout.net/mtg-decks/paste/"));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
||||
|
||||
manager->post(request, data);
|
||||
}
|
||||
|
||||
struct CopyMainOrSide {
|
||||
CardDatabase &cardDatabase;
|
||||
DeckList &mainboard, &sideboard;
|
||||
|
||||
CopyMainOrSide(CardDatabase &_cardDatabase, DeckList &_mainboard, DeckList &_sideboard)
|
||||
: cardDatabase(_cardDatabase), mainboard(_mainboard), sideboard(_sideboard) {};
|
||||
|
||||
void operator()(const InnerDecklistNode *node, const DecklistCardNode *card) const
|
||||
{
|
||||
CardInfo * dbCard = cardDatabase.getCard(card->getName());
|
||||
if (!dbCard || dbCard->getIsToken())
|
||||
return;
|
||||
|
||||
DecklistCardNode *addedCard;
|
||||
if(node->getName() == "side")
|
||||
addedCard = sideboard.addCard(card->getName(), node->getName());
|
||||
else
|
||||
addedCard = mainboard.addCard(card->getName(), node->getName());
|
||||
addedCard->setNumber(card->getNumber());
|
||||
}
|
||||
};
|
||||
|
||||
void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard)
|
||||
{
|
||||
CopyMainOrSide copyMainOrSide(cardDatabase, mainboard, sideboard);
|
||||
source.forEachCard(copyMainOrSide);
|
||||
}
|
34
cockatrice/src/tappedout_interface.h
Normal file
34
cockatrice/src/tappedout_interface.h
Normal file
|
@ -0,0 +1,34 @@
|
|||
#ifndef TAPPEDOUT_INTERFACE_H
|
||||
#define TAPPEDOUT_INTERFACE_H
|
||||
|
||||
#include "carddatabase.h"
|
||||
#include "decklist.h"
|
||||
#include <QObject>
|
||||
|
||||
class QByteArray;
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class DeckList;
|
||||
|
||||
/**
|
||||
* TappedOutInterface exists in order to support the "Analyze on TappedOut" feature.
|
||||
* An http POST request is sent and the result is retrieved from the reply. Parsing
|
||||
* logic is implemented in TappedOutInterface::queryFinished().
|
||||
*/
|
||||
|
||||
class TappedOutInterface : public QObject {
|
||||
Q_OBJECT
|
||||
private:
|
||||
QNetworkAccessManager *manager;
|
||||
|
||||
CardDatabase &cardDatabase;
|
||||
void copyDeckSplitMainAndSide(const DeckList &source, DeckList& mainboard, DeckList& sideboard);
|
||||
private slots:
|
||||
void queryFinished(QNetworkReply *reply);
|
||||
void getAnalyzeRequestData(DeckList *deck, QByteArray *data);
|
||||
public:
|
||||
TappedOutInterface(CardDatabase &_cardDatabase, QObject *parent = 0);
|
||||
void analyzeDeck(DeckList *deck);
|
||||
};
|
||||
|
||||
#endif
|
|
@ -514,10 +514,20 @@ bool DeckList::loadFromStream_Plain(QTextStream &in)
|
|||
bool inSideboard = false;
|
||||
|
||||
int okRows = 0;
|
||||
bool titleFound = false;
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().simplified();
|
||||
if (line.startsWith("//"))
|
||||
{
|
||||
if(!titleFound)
|
||||
{
|
||||
name = line.mid(2).trimmed();
|
||||
titleFound = true;
|
||||
} else if(okRows == 0) {
|
||||
comments += line.mid(2).trimmed() + "\n";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
InnerDecklistNode *zone;
|
||||
if (line.startsWith("Sideboard", Qt::CaseInsensitive)) {
|
||||
|
@ -600,14 +610,15 @@ bool DeckList::loadFromFile_Plain(QIODevice *device)
|
|||
|
||||
struct WriteToStream {
|
||||
QTextStream &stream;
|
||||
bool prefixSideboardCards;
|
||||
|
||||
WriteToStream(QTextStream &_stream) : stream(_stream) {}
|
||||
WriteToStream(QTextStream &_stream, bool _prefixSideboardCards) : stream(_stream), prefixSideboardCards(_prefixSideboardCards) {}
|
||||
|
||||
void operator()(
|
||||
const InnerDecklistNode *node,
|
||||
const DecklistCardNode *card
|
||||
) {
|
||||
if (node->getName() == "side") {
|
||||
if (prefixSideboardCards && node->getName() == "side") {
|
||||
stream << "SB: ";
|
||||
}
|
||||
stream << QString("%1 %2\n").arg(
|
||||
|
@ -618,24 +629,24 @@ struct WriteToStream {
|
|||
}
|
||||
};
|
||||
|
||||
bool DeckList::saveToStream_Plain(QTextStream &out)
|
||||
bool DeckList::saveToStream_Plain(QTextStream &out, bool prefixSideboardCards)
|
||||
{
|
||||
WriteToStream writeToStream(out);
|
||||
WriteToStream writeToStream(out, prefixSideboardCards);
|
||||
forEachCard(writeToStream);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeckList::saveToFile_Plain(QIODevice *device)
|
||||
bool DeckList::saveToFile_Plain(QIODevice *device, bool prefixSideboardCards)
|
||||
{
|
||||
QTextStream out(device);
|
||||
return saveToStream_Plain(out);
|
||||
return saveToStream_Plain(out, prefixSideboardCards);
|
||||
}
|
||||
|
||||
QString DeckList::writeToString_Plain()
|
||||
QString DeckList::writeToString_Plain(bool prefixSideboardCards)
|
||||
{
|
||||
QString result;
|
||||
QTextStream out(&result);
|
||||
saveToStream_Plain(out);
|
||||
saveToStream_Plain(out, prefixSideboardCards);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
@ -153,9 +153,9 @@ public:
|
|||
bool saveToFile_Native(QIODevice *device);
|
||||
bool loadFromStream_Plain(QTextStream &stream);
|
||||
bool loadFromFile_Plain(QIODevice *device);
|
||||
bool saveToStream_Plain(QTextStream &stream);
|
||||
bool saveToFile_Plain(QIODevice *device);
|
||||
QString writeToString_Plain();
|
||||
bool saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards);
|
||||
bool saveToFile_Plain(QIODevice *device, bool prefixSideboardCards=true);
|
||||
QString writeToString_Plain(bool prefixSideboardCards=true);
|
||||
|
||||
void cleanList();
|
||||
bool isEmpty() const { return root->isEmpty() && name.isEmpty() && comments.isEmpty() && sideboardPlans.isEmpty(); }
|
||||
|
|
Loading…
Reference in a new issue