Merge branch 'master' into cmake_qt5
Conflicts: cockatrice/src/main.cpp
This commit is contained in:
commit
09d6d26fb2
22 changed files with 156 additions and 93 deletions
|
@ -5,6 +5,6 @@ os:
|
|||
compiler:
|
||||
- gcc
|
||||
- clang
|
||||
script: mkdir build && cd build && cmake .. && make
|
||||
script: mkdir build && cd build && cmake .. -DWITH_SERVER=1 && make
|
||||
install: ./travis-dependencies.sh
|
||||
cache: apt
|
||||
|
|
|
@ -71,7 +71,7 @@ IF(MSVC)
|
|||
ELSEIF (CMAKE_COMPILER_IS_GNUCXX)
|
||||
# linux/gcc, bsd/gcc, windows/mingw
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-s -O2")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -pedantic -Werror -Wcast-align -Wmissing-declarations -Winline -Wno-long-long -Wno-error=extra -Wno-error=unused-parameter -Wno-inline -Wno-error=delete-non-virtual-dtor -W-noerror=sign-compare -Wno-error=reorder -Wno-error=missing-declarations")
|
||||
ELSE()
|
||||
# other: osx/llvm, bsd/llvm
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
|
||||
|
|
|
@ -31,7 +31,7 @@ enum ClientStatus {
|
|||
StatusConnecting,
|
||||
StatusAwaitingWelcome,
|
||||
StatusLoggingIn,
|
||||
StatusLoggedIn,
|
||||
StatusLoggedIn
|
||||
};
|
||||
|
||||
class AbstractClient : public QObject {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
const int CardDatabase::versionNeeded = 3;
|
||||
|
||||
QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardSet *set)
|
||||
static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardSet *set)
|
||||
{
|
||||
xml.writeStartElement("set");
|
||||
xml.writeTextElement("name", set->getShortName());
|
||||
|
@ -275,18 +275,18 @@ CardInfo::CardInfo(CardDatabase *_db,
|
|||
bool _cipt,
|
||||
int _tableRow,
|
||||
const SetList &_sets,
|
||||
QMap<QString, int> _muIds)
|
||||
MuidMap _muIds)
|
||||
: db(_db),
|
||||
name(_name),
|
||||
isToken(_isToken),
|
||||
sets(_sets),
|
||||
muIds(_muIds),
|
||||
manacost(_manacost),
|
||||
cardtype(_cardtype),
|
||||
powtough(_powtough),
|
||||
text(_text),
|
||||
colors(_colors),
|
||||
loyalty(_loyalty),
|
||||
muIds(_muIds),
|
||||
cipt(_cipt),
|
||||
tableRow(_tableRow),
|
||||
pixmap(NULL)
|
||||
|
@ -434,7 +434,7 @@ int CardInfo::getPreferredMuId()
|
|||
return muIds[getPreferredSet()->getShortName()];
|
||||
}
|
||||
|
||||
QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfo *info)
|
||||
static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfo *info)
|
||||
{
|
||||
xml.writeStartElement("card");
|
||||
xml.writeTextElement("name", info->getName());
|
||||
|
@ -473,7 +473,7 @@ QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfo *info)
|
|||
}
|
||||
|
||||
CardDatabase::CardDatabase(QObject *parent)
|
||||
: QObject(parent), loadStatus(NotLoaded), noCard(0)
|
||||
: QObject(parent), noCard(0), loadStatus(NotLoaded)
|
||||
{
|
||||
connect(settingsCache, SIGNAL(picsPathChanged()), this, SLOT(picsPathChanged()));
|
||||
connect(settingsCache, SIGNAL(cardDatabasePathChanged()), this, SLOT(loadCardDatabase()));
|
||||
|
@ -610,7 +610,7 @@ void CardDatabase::loadCardsFromXml(QXmlStreamReader &xml)
|
|||
if (xml.name() == "card") {
|
||||
QString name, manacost, type, pt, text;
|
||||
QStringList colors;
|
||||
QMap<QString, int> muids;
|
||||
MuidMap muids;
|
||||
SetList sets;
|
||||
int tableRow = 0;
|
||||
int loyalty = 0;
|
||||
|
|
|
@ -20,6 +20,9 @@ class QNetworkRequest;
|
|||
|
||||
typedef QMap<QString, QString> QStringMap;
|
||||
|
||||
// If we don't typedef this, CardInfo::CardInfo will refuse to compile on OS X < 10.9
|
||||
typedef QMap<QString, int> MuidMap;
|
||||
|
||||
class CardSet : public QList<CardInfo *> {
|
||||
private:
|
||||
QString shortName, longName;
|
||||
|
@ -100,7 +103,7 @@ private:
|
|||
QString text;
|
||||
QStringList colors;
|
||||
int loyalty;
|
||||
QMap<QString, int> muIds;
|
||||
MuidMap muIds;
|
||||
bool cipt;
|
||||
int tableRow;
|
||||
QPixmap *pixmap;
|
||||
|
@ -118,7 +121,7 @@ public:
|
|||
bool _cipt = false,
|
||||
int _tableRow = 0,
|
||||
const SetList &_sets = SetList(),
|
||||
QMap<QString, int> muids = QMap<QString, int>());
|
||||
MuidMap muids = MuidMap());
|
||||
~CardInfo();
|
||||
const QString &getName() const { return name; }
|
||||
bool getIsToken() const { return isToken; }
|
||||
|
|
|
@ -26,9 +26,9 @@ public:
|
|||
};
|
||||
|
||||
private:
|
||||
QString trm;
|
||||
enum Type t;
|
||||
enum Attr a;
|
||||
QString trm;
|
||||
|
||||
public:
|
||||
CardFilter(QString term, Type type, Attr attr) : trm(term), t(type), a(attr) {};
|
||||
|
|
|
@ -342,6 +342,9 @@ void DeckListModel::sort(int column, Qt::SortOrder order)
|
|||
break;
|
||||
case 2:
|
||||
sortMethod = ByPrice;
|
||||
break;
|
||||
default:
|
||||
sortMethod = ByName;
|
||||
}
|
||||
root->setSortMethod(sortMethod);
|
||||
sortHelper(root, order);
|
||||
|
|
|
@ -66,7 +66,7 @@ public:
|
|||
const CardFilter::Attr attr;
|
||||
|
||||
LogicMap(CardFilter::Attr a, FilterTree *parent)
|
||||
: attr(a), p(parent) {}
|
||||
: p(parent), attr(a) {}
|
||||
const FilterItemList *findTypeList(CardFilter::Type type) const;
|
||||
FilterItemList *typeList(CardFilter::Type type);
|
||||
FilterTreeNode *parent() const;
|
||||
|
@ -81,7 +81,7 @@ public:
|
|||
const CardFilter::Type type;
|
||||
|
||||
FilterItemList(CardFilter::Type t, LogicMap *parent)
|
||||
: type(t), p(parent) {}
|
||||
: p(parent), type(t) {}
|
||||
CardFilter::Attr attr() const { return p->attr; }
|
||||
FilterTreeNode *parent() const { return p; }
|
||||
int termIndex(const QString &term) const;
|
||||
|
|
|
@ -56,7 +56,7 @@ QString translationPath = QString();
|
|||
#endif
|
||||
|
||||
#if QT_VERSION < 0x050000
|
||||
void myMessageOutput(QtMsgType /*type*/, const char *msg)
|
||||
static void myMessageOutput(QtMsgType /*type*/, const char *msg)
|
||||
{
|
||||
QFile file("qdebug.txt");
|
||||
file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text);
|
||||
|
@ -65,7 +65,7 @@ void myMessageOutput(QtMsgType /*type*/, const char *msg)
|
|||
file.close();
|
||||
}
|
||||
#else
|
||||
void myMessageOutput(QtMsgType /*type*/, const QMessageLogContext &, const QString &msg)
|
||||
static void myMessageOutput(QtMsgType /*type*/, const QMessageLogContext &, const QString &msg)
|
||||
{
|
||||
QFile file("qdebug.txt");
|
||||
file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text);
|
||||
|
|
|
@ -13,4 +13,6 @@ extern QString translationPath;
|
|||
|
||||
void installNewTranslator();
|
||||
|
||||
bool settingsValid();
|
||||
|
||||
#endif
|
||||
|
|
|
@ -86,12 +86,16 @@ void MessageLogWidget::logLeaveSpectator(QString name)
|
|||
appendHtml(tr("%1 is not watching the game any more.").arg(sanitizeHtml(name)));
|
||||
}
|
||||
|
||||
void MessageLogWidget::logDeckSelect(Player *player, QString deckHash)
|
||||
void MessageLogWidget::logDeckSelect(Player *player, QString deckHash, int sideboardSize)
|
||||
{
|
||||
if (isFemale(player))
|
||||
appendHtml(tr("%1 has loaded a deck (%2).", "female").arg(sanitizeHtml(player->getName())).arg(deckHash));
|
||||
const char* gender = isFemale(player) ? "female" : "male";
|
||||
if (sideboardSize < 0)
|
||||
appendHtml(tr("%1 has loaded a deck (%2).", gender).arg(sanitizeHtml(player->getName())).arg(deckHash));
|
||||
else
|
||||
appendHtml(tr("%1 has loaded a deck (%2).", "male").arg(sanitizeHtml(player->getName())).arg(deckHash));
|
||||
appendHtml(tr("%1 has loaded a deck with %2 sideboard cards (%3).", gender).
|
||||
arg(sanitizeHtml(player->getName())).
|
||||
arg(sideboardSize).
|
||||
arg(deckHash));
|
||||
}
|
||||
|
||||
void MessageLogWidget::logReadyStart(Player *player)
|
||||
|
|
|
@ -48,7 +48,7 @@ public slots:
|
|||
void logKicked();
|
||||
void logJoinSpectator(QString name);
|
||||
void logLeaveSpectator(QString name);
|
||||
void logDeckSelect(Player *player, QString deckHash);
|
||||
void logDeckSelect(Player *player, QString deckHash, int sideboardSize);
|
||||
void logReadyStart(Player *player);
|
||||
void logNotReadyStart(Player *player);
|
||||
void logSetSideboardLock(Player *player, bool locked);
|
||||
|
|
|
@ -1012,7 +1012,10 @@ void TabGame::eventPlayerPropertiesChanged(const Event_PlayerPropertiesChanged &
|
|||
break;
|
||||
}
|
||||
case GameEventContext::DECK_SELECT: {
|
||||
messageLog->logDeckSelect(player, QString::fromStdString(context.GetExtension(Context_DeckSelect::ext).deck_hash()));
|
||||
Context_DeckSelect deckSelect = context.GetExtension(Context_DeckSelect::ext);
|
||||
messageLog->logDeckSelect(player,
|
||||
QString::fromStdString(deckSelect.deck_hash()),
|
||||
deckSelect.sideboard_size());
|
||||
break;
|
||||
}
|
||||
case GameEventContext::SET_SIDEBOARD_LOCK: {
|
||||
|
|
|
@ -180,6 +180,7 @@ bool InnerDecklistNode::compare(AbstractDecklistNode *other) const
|
|||
case 2:
|
||||
return comparePrice(other);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool InnerDecklistNode::compareNumber(AbstractDecklistNode *other) const
|
||||
|
@ -226,6 +227,7 @@ bool AbstractDecklistCardNode::compare(AbstractDecklistNode *other) const
|
|||
case ByPrice:
|
||||
return compareTotalPrice(other);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool AbstractDecklistCardNode::compareNumber(AbstractDecklistNode *other) const
|
||||
|
@ -351,6 +353,7 @@ DeckList::DeckList()
|
|||
root = new InnerDecklistNode;
|
||||
}
|
||||
|
||||
// TODO: http://qt-project.org/doc/qt-4.8/qobject.html#no-copy-constructor-or-assignment-operator
|
||||
DeckList::DeckList(const DeckList &other)
|
||||
: name(other.name),
|
||||
comments(other.comments),
|
||||
|
@ -630,12 +633,27 @@ QStringList DeckList::getCardList() const
|
|||
return result.toList();
|
||||
}
|
||||
|
||||
int DeckList::getSideboardSize() const
|
||||
{
|
||||
int size = 0;
|
||||
for (int i = 0; i < root->size(); ++i) {
|
||||
InnerDecklistNode *node = dynamic_cast<InnerDecklistNode *>(root->at(i));
|
||||
if (node->getName() != "side")
|
||||
continue;
|
||||
for (int j = 0; j < node->size(); j++) {
|
||||
DecklistCardNode *card = dynamic_cast<DecklistCardNode *>(node->at(j));
|
||||
size += card->getNumber();
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
DecklistCardNode *DeckList::addCard(const QString &cardName, const QString &zoneName)
|
||||
{
|
||||
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
|
||||
if (!zoneNode)
|
||||
zoneNode = new InnerDecklistNode(zoneName, root);
|
||||
|
||||
|
||||
DecklistCardNode *node = new DecklistCardNode(cardName, 1, zoneNode);
|
||||
updateDeckHash();
|
||||
return node;
|
||||
|
|
|
@ -160,7 +160,9 @@ public:
|
|||
void cleanList();
|
||||
bool isEmpty() const { return root->isEmpty() && name.isEmpty() && comments.isEmpty() && sideboardPlans.isEmpty(); }
|
||||
QStringList getCardList() const;
|
||||
|
||||
|
||||
int getSideboardSize() const;
|
||||
|
||||
QString getDeckHash() const { return deckHash; }
|
||||
void updateDeckHash();
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import "game_event_context.proto";
|
||||
|
||||
message Context_DeckSelect {
|
||||
extend GameEventContext {
|
||||
optional Context_DeckSelect ext = 1002;
|
||||
}
|
||||
optional string deck_hash = 1;
|
||||
extend GameEventContext {
|
||||
optional Context_DeckSelect ext = 1002;
|
||||
}
|
||||
optional string deck_hash = 1;
|
||||
optional int32 sideboard_size = 2 [default = -1];
|
||||
}
|
||||
|
|
|
@ -657,6 +657,7 @@ Response::ResponseCode Server_Player::cmdDeckSelect(const Command_DeckSelect &cm
|
|||
|
||||
Context_DeckSelect context;
|
||||
context.set_deck_hash(deck->getDeckHash().toStdString());
|
||||
context.set_sideboard_size(deck->getSideboardSize());
|
||||
ges.setGameEventContext(context);
|
||||
|
||||
Response_DeckDownload *re = new Response_DeckDownload;
|
||||
|
|
|
@ -64,7 +64,7 @@ void GameEventStorage::sendToGame(Server_Game *game)
|
|||
}
|
||||
|
||||
ResponseContainer::ResponseContainer(int _cmdId)
|
||||
: responseExtension(0), cmdId(_cmdId)
|
||||
: cmdId(_cmdId), responseExtension(0)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ public:
|
|||
bool getImport() const { return import; }
|
||||
void setImport(bool _import) { import = _import; }
|
||||
SetToDownload(const QString &_shortName, const QString &_longName, const QVariant &_cards, bool _import)
|
||||
: shortName(_shortName), longName(_longName), cards(_cards), import(_import) { }
|
||||
: shortName(_shortName), longName(_longName), import(_import), cards(_cards) { }
|
||||
bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; }
|
||||
};
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ logfile=server.log
|
|||
name="My Cockatrice server"
|
||||
id=1
|
||||
number_pools=1
|
||||
writelog=1
|
||||
logfilters=""
|
||||
|
||||
[servernetwork]
|
||||
active=0
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
#include <QDateTime>
|
||||
#include <QSettings>
|
||||
#include <iostream>
|
||||
#ifdef Q_OS_UNIX
|
||||
# include <sys/types.h>
|
||||
|
@ -11,99 +12,122 @@
|
|||
#endif
|
||||
|
||||
ServerLogger::ServerLogger(bool _logToConsole, QObject *parent)
|
||||
: QObject(parent), logToConsole(_logToConsole), flushRunning(false)
|
||||
: QObject(parent), logToConsole(_logToConsole), flushRunning(false)
|
||||
{
|
||||
}
|
||||
|
||||
ServerLogger::~ServerLogger()
|
||||
{
|
||||
flushBuffer();
|
||||
// This does not work with the destroyed() signal as this destructor is called after the main event loop is done.
|
||||
thread()->quit();
|
||||
flushBuffer();
|
||||
// This does not work with the destroyed() signal as this destructor is called after the main event loop is done.
|
||||
thread()->quit();
|
||||
}
|
||||
|
||||
void ServerLogger::startLog(const QString &logFileName)
|
||||
{
|
||||
if (!logFileName.isEmpty()) {
|
||||
logFile = new QFile("server.log", this);
|
||||
logFile->open(QIODevice::Append);
|
||||
if (!logFileName.isEmpty()) {
|
||||
logFile = new QFile("server.log", this);
|
||||
logFile->open(QIODevice::Append);
|
||||
#ifdef Q_OS_UNIX
|
||||
::socketpair(AF_UNIX, SOCK_STREAM, 0, sigHupFD);
|
||||
::socketpair(AF_UNIX, SOCK_STREAM, 0, sigHupFD);
|
||||
|
||||
snHup = new QSocketNotifier(sigHupFD[1], QSocketNotifier::Read, this);
|
||||
connect(snHup, SIGNAL(activated(int)), this, SLOT(handleSigHup()));
|
||||
snHup = new QSocketNotifier(sigHupFD[1], QSocketNotifier::Read, this);
|
||||
connect(snHup, SIGNAL(activated(int)), this, SLOT(handleSigHup()));
|
||||
#endif
|
||||
} else
|
||||
logFile = 0;
|
||||
|
||||
connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection);
|
||||
} else
|
||||
logFile = 0;
|
||||
|
||||
connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void ServerLogger::logMessage(QString message, void *caller)
|
||||
{
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
bufferMutex.lock();
|
||||
QString callerString;
|
||||
if (caller)
|
||||
callerString = QString::number((qulonglong) caller, 16) + " ";
|
||||
buffer.append(QDateTime::currentDateTime().toString() + " " + callerString + message);
|
||||
bufferMutex.unlock();
|
||||
|
||||
emit sigFlushBuffer();
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
QString callerString;
|
||||
if (caller)
|
||||
callerString = QString::number((qulonglong) caller, 16) + " ";
|
||||
|
||||
//filter out all log entries based on values in configuration file
|
||||
QSettings *settings = new QSettings("servatrice.ini", QSettings::IniFormat);
|
||||
bool shouldWeWriteLog = settings->value("server/writelog").toBool();
|
||||
QString logFilters = settings->value("server/logfilters").toString();
|
||||
QStringList listlogFilters = logFilters.split(",", QString::SkipEmptyParts);
|
||||
bool shouldWeSkipLine = false;
|
||||
|
||||
if (!shouldWeWriteLog)
|
||||
return;
|
||||
|
||||
if (!logFilters.trimmed().isEmpty()){
|
||||
shouldWeSkipLine = true;
|
||||
foreach(QString logFilter, listlogFilters){
|
||||
if (message.contains(logFilter, Qt::CaseInsensitive)){
|
||||
shouldWeSkipLine = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWeSkipLine)
|
||||
return;
|
||||
|
||||
bufferMutex.lock();
|
||||
buffer.append(QDateTime::currentDateTime().toString() + " " + callerString + message);
|
||||
bufferMutex.unlock();
|
||||
emit sigFlushBuffer();
|
||||
}
|
||||
|
||||
void ServerLogger::flushBuffer()
|
||||
{
|
||||
if (flushRunning)
|
||||
return;
|
||||
|
||||
flushRunning = true;
|
||||
QTextStream stream(logFile);
|
||||
forever {
|
||||
bufferMutex.lock();
|
||||
if (buffer.isEmpty()) {
|
||||
bufferMutex.unlock();
|
||||
flushRunning = false;
|
||||
return;
|
||||
}
|
||||
QString message = buffer.takeFirst();
|
||||
bufferMutex.unlock();
|
||||
|
||||
stream << message << "\n";
|
||||
stream.flush();
|
||||
|
||||
if (logToConsole)
|
||||
std::cout << message.toStdString() << std::endl;
|
||||
}
|
||||
if (flushRunning)
|
||||
return;
|
||||
|
||||
flushRunning = true;
|
||||
QTextStream stream(logFile);
|
||||
forever {
|
||||
bufferMutex.lock();
|
||||
if (buffer.isEmpty()) {
|
||||
bufferMutex.unlock();
|
||||
flushRunning = false;
|
||||
return;
|
||||
}
|
||||
QString message = buffer.takeFirst();
|
||||
bufferMutex.unlock();
|
||||
|
||||
stream << message << "\n";
|
||||
stream.flush();
|
||||
|
||||
if (logToConsole)
|
||||
std::cout << message.toStdString() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
void ServerLogger::hupSignalHandler(int /*unused*/)
|
||||
{
|
||||
#ifdef Q_OS_UNIX
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
char a = 1;
|
||||
::write(sigHupFD[0], &a, sizeof(a));
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
char a = 1;
|
||||
::write(sigHupFD[0], &a, sizeof(a));
|
||||
#endif
|
||||
}
|
||||
|
||||
void ServerLogger::handleSigHup()
|
||||
{
|
||||
#ifdef Q_OS_UNIX
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
snHup->setEnabled(false);
|
||||
char tmp;
|
||||
::read(sigHupFD[1], &tmp, sizeof(tmp));
|
||||
|
||||
logFile->close();
|
||||
logFile->open(QIODevice::Append);
|
||||
|
||||
snHup->setEnabled(true);
|
||||
if (!logFile)
|
||||
return;
|
||||
|
||||
snHup->setEnabled(false);
|
||||
char tmp;
|
||||
::read(sigHupFD[1], &tmp, sizeof(tmp));
|
||||
|
||||
logFile->close();
|
||||
logFile->open(QIODevice::Append);
|
||||
|
||||
snHup->setEnabled(true);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
if [[ $TRAVIS_OS_NAME == "osx" ]] ; then
|
||||
brew update
|
||||
brew install qt cmake protobuf
|
||||
brew install qt cmake protobuf libgcrypt
|
||||
else
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y qtmobility-dev libprotobuf-dev protobuf-compiler libqt4-dev
|
||||
|
|
Loading…
Reference in a new issue