mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
Support MTGJSONv5 format in Oracle downloader (#4162)
* Fix #4043, Support MTGJSONv5 format in Oracle downloader * Auto redirect V4 downloads to V5, as we won't support V4 after this change * clangify >_> * Remove null values and account for IDs missing * fix split cards and double faced cards somewhat * do not consider double faced cards duplicates * fix promo double sided cards * typo * fix alternative versions of cards with (letter) * zach says this is more readable * pre qt 5.10 compatibility Co-authored-by: ebbit1q <ebbit1q@gmail.com>
This commit is contained in:
parent
f3cf1f0dde
commit
9f9581c2be
5 changed files with 118 additions and 76 deletions
|
|
@ -3,16 +3,15 @@
|
|||
#include "carddbparser/cockatricexml4.h"
|
||||
#include "qt-json/json.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QtWidgets>
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
SplitCardPart::SplitCardPart(const int _index,
|
||||
SplitCardPart::SplitCardPart(const QString &_name,
|
||||
const QString &_text,
|
||||
const QVariantHash &_properties,
|
||||
const CardInfoPerSet _setInfo)
|
||||
: index(_index), text(_text), properties(_properties), setInfo(_setInfo)
|
||||
: name(_name), text(_text), properties(_properties), setInfo(_setInfo)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
|
|||
QList<SetToDownload> newSetList;
|
||||
|
||||
bool ok;
|
||||
setsMap = QtJson::Json::parse(QString(data), ok).toMap();
|
||||
setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap();
|
||||
if (!ok) {
|
||||
qDebug() << "error: QtJson::Json::parse()";
|
||||
return false;
|
||||
|
|
@ -162,14 +161,9 @@ CardInfoPtr OracleImporter::addCard(QString name,
|
|||
|
||||
// upsideDown (flip cards)
|
||||
bool upsideDown = false;
|
||||
QStringList additionalNames = properties.value("names").toStringList();
|
||||
QString layout = properties.value("layout").toString();
|
||||
if (layout == "flip") {
|
||||
if (properties.value("side").toString() != "front") {
|
||||
upsideDown = true;
|
||||
}
|
||||
// reset the side property, since the card has no back image
|
||||
properties.insert("side", "front");
|
||||
upsideDown = properties.value("side").toString() != "a";
|
||||
}
|
||||
|
||||
// insert the card and its properties
|
||||
|
|
@ -179,36 +173,41 @@ CardInfoPtr OracleImporter::addCard(QString name,
|
|||
CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards,
|
||||
setsInfo, cipt, tableRow, upsideDown);
|
||||
|
||||
if (name.isEmpty()) {
|
||||
qDebug() << "warning: an empty card was added to set" << setInfo.getPtr()->getShortName();
|
||||
}
|
||||
cards.insert(name, newCard);
|
||||
|
||||
return newCard;
|
||||
}
|
||||
|
||||
QString OracleImporter::getStringPropertyFromMap(QVariantMap card, QString propertyName)
|
||||
QString OracleImporter::getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName)
|
||||
{
|
||||
return card.contains(propertyName) ? card.value(propertyName).toString() : QString("");
|
||||
}
|
||||
|
||||
int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVariant> &cardsList, bool skipSpecialCards)
|
||||
int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet,
|
||||
const QList<QVariant> &cardsList,
|
||||
bool skipSpecialCards)
|
||||
{
|
||||
static const QMap<QString, QString> cardProperties{
|
||||
// mtgjson name => xml name
|
||||
static const QMap<QString, QString> cardProperties{
|
||||
{"manaCost", "manacost"}, {"convertedManaCost", "cmc"}, {"type", "type"},
|
||||
{"loyalty", "loyalty"}, {"layout", "layout"}, {"side", "side"},
|
||||
};
|
||||
|
||||
static const QMap<QString, QString> setInfoProperties{// mtgjson name => xml name
|
||||
{"multiverseId", "muid"},
|
||||
{"scryfallId", "uuid"},
|
||||
{"number", "num"},
|
||||
{"rarity", "rarity"}};
|
||||
// mtgjson name => xml name
|
||||
static const QMap<QString, QString> setInfoProperties{{"number", "num"}, {"rarity", "rarity"}};
|
||||
|
||||
// mtgjson name => xml name
|
||||
static const QMap<QString, QString> identifierProperties{{"multiverseId", "muid"}, {"scryfallId", "uuid"}};
|
||||
|
||||
int numCards = 0;
|
||||
QMultiMap<QString, SplitCardPart> splitCards;
|
||||
QString ptSeparator("/");
|
||||
QVariantMap card;
|
||||
QString layout, name, text, colors, colorIdentity, maintype, power, toughness;
|
||||
QString layout, name, text, colors, colorIdentity, maintype, power, toughness, faceName;
|
||||
static const bool isToken = false;
|
||||
QStringList additionalNames;
|
||||
QVariantHash properties;
|
||||
CardInfoPerSet setInfo;
|
||||
QList<CardRelation *> relatedCards;
|
||||
|
|
@ -219,6 +218,11 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
for (const QVariant &cardVar : cardsList) {
|
||||
card = cardVar.toMap();
|
||||
|
||||
// skip alternatives
|
||||
if (getStringPropertyFromMap(card, "isAlternative") == "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Currently used layouts are:
|
||||
* augment, double_faced_token, flip, host, leveler, meld, normal, planar,
|
||||
* saga, scheme, split, token, transform, vanguard
|
||||
|
|
@ -233,6 +237,10 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
// normal cards handling
|
||||
name = getStringPropertyFromMap(card, "name");
|
||||
text = getStringPropertyFromMap(card, "text");
|
||||
faceName = getStringPropertyFromMap(card, "faceName");
|
||||
if (faceName.isEmpty()) {
|
||||
faceName = name;
|
||||
}
|
||||
|
||||
// card properties
|
||||
properties.clear();
|
||||
|
|
@ -258,21 +266,40 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
setInfo.setProperty(xmlPropertyName, propertyValue);
|
||||
}
|
||||
|
||||
// skip alternatives
|
||||
if (getStringPropertyFromMap(card, "isAlternative") == "true") {
|
||||
continue;
|
||||
// Identifiers
|
||||
QMapIterator<QString, QString> it3(identifierProperties);
|
||||
while (it3.hasNext()) {
|
||||
it3.next();
|
||||
auto mtgjsonProperty = it3.key();
|
||||
auto xmlPropertyName = it3.value();
|
||||
auto propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty);
|
||||
if (!propertyValue.isEmpty()) {
|
||||
setInfo.setProperty(xmlPropertyName, propertyValue);
|
||||
}
|
||||
}
|
||||
|
||||
QString numComponent{};
|
||||
if (skipSpecialCards) {
|
||||
// skip promo cards if it's not the only print
|
||||
if (allNameProps.contains(name)) {
|
||||
QString numProperty = setInfo.getProperty("num");
|
||||
// skip promo cards if it's not the only print, cards with two faces are different cards
|
||||
if (allNameProps.contains(faceName)) {
|
||||
// check for alternative versions
|
||||
if (layout != "normal")
|
||||
continue;
|
||||
|
||||
// alternative versions have a letter in the end of num like abc
|
||||
// note this will also catch p and s, those will get removed later anyway
|
||||
QChar lastChar = numProperty.at(numProperty.size() - 1);
|
||||
if (!lastChar.isLetter())
|
||||
continue;
|
||||
|
||||
numComponent = " (" + QString(lastChar) + ")";
|
||||
faceName += numComponent; // add to facename to make it unique
|
||||
}
|
||||
if (getStringPropertyFromMap(card, "isPromo") == "true") {
|
||||
specialPromoCards.insert(name, cardVar);
|
||||
specialPromoCards.insert(faceName, cardVar);
|
||||
continue;
|
||||
}
|
||||
QString numProperty = setInfo.getProperty("num");
|
||||
bool skip = false;
|
||||
// skip cards containing special stuff in the collectors number like promo cards
|
||||
for (const QString &specialChar : specialNumChars) {
|
||||
|
|
@ -282,10 +309,10 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
}
|
||||
}
|
||||
if (skip) {
|
||||
specialPromoCards.insert(name, cardVar);
|
||||
specialPromoCards.insert(faceName, cardVar);
|
||||
continue;
|
||||
} else {
|
||||
allNameProps.append(name);
|
||||
allNameProps.append(faceName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,8 +341,6 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
properties.insert("pt", power + ptSeparator + toughness);
|
||||
}
|
||||
|
||||
additionalNames = card.value("names").toStringList();
|
||||
|
||||
auto legalities = card.value("legalities").toMap();
|
||||
for (const QString &fmtName : legalities.keys()) {
|
||||
properties.insert(QString("format-%1").arg(fmtName), legalities.value(fmtName).toString().toLower());
|
||||
|
|
@ -323,23 +348,33 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
|
||||
// split cards are considered a single card, enqueue for later merging
|
||||
if (layout == "split" || layout == "aftermath" || layout == "adventure") {
|
||||
// get the position of this card part
|
||||
int index = additionalNames.indexOf(name);
|
||||
// construct full card name
|
||||
name = additionalNames.join(QString(" // "));
|
||||
SplitCardPart split(index, text, properties, setInfo);
|
||||
auto faceName = getStringPropertyFromMap(card, "faceName");
|
||||
SplitCardPart split(faceName, text, properties, setInfo);
|
||||
splitCards.insert(name, split);
|
||||
} else {
|
||||
// relations
|
||||
relatedCards.clear();
|
||||
if (additionalNames.size() > 1) {
|
||||
for (const QString &additionalName : additionalNames) {
|
||||
if (additionalName != name)
|
||||
|
||||
// add other face for split cards as card relation
|
||||
if (!getStringPropertyFromMap(card, "side").isEmpty()) {
|
||||
properties["cmc"] = getStringPropertyFromMap(card, "faceConvertedManaCost");
|
||||
if (layout == "meld") { // meld cards don't work
|
||||
QRegularExpression meldNameRegex{"then meld them into ([\\.]*)"};
|
||||
QString additionalName = meldNameRegex.match(text).captured(1);
|
||||
if (!additionalName.isNull()) {
|
||||
relatedCards.append(new CardRelation(additionalName, true));
|
||||
}
|
||||
} else {
|
||||
for (const QString &additionalName : name.split(" // ")) {
|
||||
if (additionalName != faceName) {
|
||||
relatedCards.append(new CardRelation(additionalName, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
name = faceName;
|
||||
}
|
||||
|
||||
CardInfoPtr newCard = addCard(name, text, isToken, properties, relatedCards, setInfo);
|
||||
CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, setInfo);
|
||||
numCards++;
|
||||
}
|
||||
}
|
||||
|
|
@ -350,21 +385,22 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
for (const QString &nameSplit : splitCards.uniqueKeys()) {
|
||||
// get all parts for this specific card
|
||||
QList<SplitCardPart> splitCardParts = splitCards.values(nameSplit);
|
||||
// sort them by index (aka position)
|
||||
// sort them by face name
|
||||
std::sort(splitCardParts.begin(), splitCardParts.end(),
|
||||
[](const SplitCardPart &a, const SplitCardPart &b) -> bool { return a.getIndex() < b.getIndex(); });
|
||||
[](const SplitCardPart &a, const SplitCardPart &b) -> bool { return a.getName() < b.getName(); });
|
||||
|
||||
text = QString("");
|
||||
properties.clear();
|
||||
relatedCards.clear();
|
||||
|
||||
int lastIndex = -1;
|
||||
QString lastName{};
|
||||
for (const SplitCardPart &tmp : splitCardParts) {
|
||||
// some sets have 2 different variations of the same split card,
|
||||
// eg. Fire // Ice in WC02. Avoid adding duplicates.
|
||||
if (lastIndex == tmp.getIndex())
|
||||
if (lastName == tmp.getName())
|
||||
continue;
|
||||
lastIndex = tmp.getIndex();
|
||||
|
||||
lastName = tmp.getName();
|
||||
|
||||
if (!text.isEmpty())
|
||||
text.append(splitCardTextSeparator);
|
||||
|
|
@ -375,7 +411,6 @@ int OracleImporter::importCardsFromSet(CardSetPtr currentSet, const QList<QVaria
|
|||
setInfo = tmp.getSetInfo();
|
||||
} else {
|
||||
const QVariantHash &props = tmp.getProperties();
|
||||
layout = properties.value("layout").toString();
|
||||
for (const QString &prop : props.keys()) {
|
||||
QString originalPropertyValue = properties.value(prop).toString();
|
||||
QString thisCardPropertyValue = props.value(prop).toString();
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ public:
|
|||
class SplitCardPart
|
||||
{
|
||||
public:
|
||||
SplitCardPart(int _index, const QString &_text, const QVariantHash &_properties, CardInfoPerSet setInfo);
|
||||
inline const int &getIndex() const
|
||||
SplitCardPart(const QString &_name, const QString &_text, const QVariantHash &_properties, CardInfoPerSet setInfo);
|
||||
inline const QString &getName() const
|
||||
{
|
||||
return index;
|
||||
return name;
|
||||
}
|
||||
inline const QString &getText() const
|
||||
{
|
||||
|
|
@ -79,7 +79,7 @@ public:
|
|||
}
|
||||
|
||||
private:
|
||||
int index;
|
||||
QString name;
|
||||
QString text;
|
||||
QVariantHash properties;
|
||||
CardInfoPerSet setInfo;
|
||||
|
|
@ -111,7 +111,7 @@ public:
|
|||
bool readSetsFromByteArray(const QByteArray &data);
|
||||
int startImport();
|
||||
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
|
||||
int importCardsFromSet(CardSetPtr currentSet, const QList<QVariant> &cards, bool skipSpecialNums = true);
|
||||
int importCardsFromSet(const CardSetPtr ¤tSet, const QList<QVariant> &cards, bool skipSpecialNums = true);
|
||||
QList<SetToDownload> &getSets()
|
||||
{
|
||||
return allSets;
|
||||
|
|
@ -123,7 +123,7 @@ public:
|
|||
void clear();
|
||||
|
||||
protected:
|
||||
inline QString getStringPropertyFromMap(QVariantMap card, QString propertyName);
|
||||
inline QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName);
|
||||
void sortAndReduceColors(QString &colors);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
#include <QProgressBar>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextEdit>
|
||||
|
|
@ -38,15 +37,16 @@
|
|||
#define ZIP_SIGNATURE "PK"
|
||||
// Xz stream header: 0xFD + "7zXZ"
|
||||
#define XZ_SIGNATURE "\xFD\x37\x7A\x58\x5A"
|
||||
#define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/files/AllPrintings.json"
|
||||
#define MTGJSON_VERSION_URL "https://www.mtgjson.com/files/version.json"
|
||||
#define MTGJSON_V4_URL_COMPONENT "mtgjson.com/files/"
|
||||
#define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/api/v5/AllPrintings.json"
|
||||
#define MTGJSON_VERSION_URL "https://www.mtgjson.com/api/v5/Meta.json"
|
||||
|
||||
#ifdef HAS_LZMA
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/files/AllPrintings.json.xz"
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.xz"
|
||||
#elif defined(HAS_ZLIB)
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/files/AllPrintings.json.zip"
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.zip"
|
||||
#else
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/files/AllPrintings.json"
|
||||
#define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json"
|
||||
#endif
|
||||
|
||||
#define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml"
|
||||
|
|
@ -299,7 +299,13 @@ bool LoadSetsPage::validatePage()
|
|||
|
||||
// else, try to import sets
|
||||
if (urlRadioButton->isChecked()) {
|
||||
QUrl url = QUrl::fromUserInput(urlLineEdit->text());
|
||||
// If a user attempts to download from V4, redirect them to V5
|
||||
if (urlLineEdit->text().contains(MTGJSON_V4_URL_COMPONENT)) {
|
||||
actRestoreDefaultUrl();
|
||||
}
|
||||
|
||||
const auto url = QUrl::fromUserInput(urlLineEdit->text());
|
||||
|
||||
if (!url.isValid()) {
|
||||
QMessageBox::critical(this, tr("Error"), tr("The provided URL is not valid."));
|
||||
return false;
|
||||
|
|
@ -342,23 +348,24 @@ bool LoadSetsPage::validatePage()
|
|||
}
|
||||
|
||||
#include <iostream>
|
||||
void LoadSetsPage::downloadSetsFile(QUrl url)
|
||||
void LoadSetsPage::downloadSetsFile(const QUrl &url)
|
||||
{
|
||||
wizard()->setCardSourceVersion("unknown");
|
||||
|
||||
QString urlString = url.toString();
|
||||
const auto urlString = url.toString();
|
||||
if (urlString == ALLSETS_URL || urlString == ALLSETS_URL_FALLBACK) {
|
||||
QUrl versionUrl = QUrl::fromUserInput(MTGJSON_VERSION_URL);
|
||||
QNetworkReply *versionReply = wizard()->nam->get(QNetworkRequest(versionUrl));
|
||||
const auto versionUrl = QUrl::fromUserInput(MTGJSON_VERSION_URL);
|
||||
auto *versionReply = wizard()->nam->get(QNetworkRequest(versionUrl));
|
||||
connect(versionReply, &QNetworkReply::finished, [this, versionReply]() {
|
||||
if (versionReply->error() == QNetworkReply::NoError) {
|
||||
QByteArray jsonData = versionReply->readAll();
|
||||
QJsonParseError jsonError;
|
||||
QJsonDocument jsonResponse = QJsonDocument::fromJson(jsonData, &jsonError);
|
||||
auto jsonData = versionReply->readAll();
|
||||
QJsonParseError jsonError{};
|
||||
auto jsonResponse = QJsonDocument::fromJson(jsonData, &jsonError);
|
||||
|
||||
if (jsonError.error == QJsonParseError::NoError) {
|
||||
QVariantMap jsonMap = jsonResponse.toVariant().toMap();
|
||||
QString versionString = jsonMap["version"].toString();
|
||||
const auto jsonMap = jsonResponse.toVariant().toMap();
|
||||
|
||||
auto versionString = jsonMap.value("meta").toMap().value("version").toString();
|
||||
if (versionString.isEmpty()) {
|
||||
versionString = "unknown";
|
||||
}
|
||||
|
|
@ -372,7 +379,7 @@ void LoadSetsPage::downloadSetsFile(QUrl url)
|
|||
|
||||
wizard()->setCardSourceUrl(url.toString());
|
||||
|
||||
QNetworkReply *reply = wizard()->nam->get(QNetworkRequest(url));
|
||||
auto *reply = wizard()->nam->get(QNetworkRequest(url));
|
||||
|
||||
connect(reply, SIGNAL(finished()), this, SLOT(actDownloadFinishedSetsFile()));
|
||||
connect(reply, SIGNAL(downloadProgress(qint64, qint64)), this, SLOT(actDownloadProgressSetsFile(qint64, qint64)));
|
||||
|
|
@ -391,7 +398,7 @@ void LoadSetsPage::actDownloadFinishedSetsFile()
|
|||
{
|
||||
// check for a reply
|
||||
auto *reply = dynamic_cast<QNetworkReply *>(sender());
|
||||
QNetworkReply::NetworkError errorCode = reply->error();
|
||||
auto errorCode = reply->error();
|
||||
if (errorCode != QNetworkReply::NoError) {
|
||||
QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString()));
|
||||
|
||||
|
|
@ -402,9 +409,9 @@ void LoadSetsPage::actDownloadFinishedSetsFile()
|
|||
return;
|
||||
}
|
||||
|
||||
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
auto statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (statusCode == 301 || statusCode == 302) {
|
||||
QUrl redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl();
|
||||
const auto redirectUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl();
|
||||
qDebug() << "following redirect url:" << redirectUrl.toString();
|
||||
downloadSetsFile(redirectUrl);
|
||||
reply->deleteLater();
|
||||
|
|
@ -414,7 +421,7 @@ void LoadSetsPage::actDownloadFinishedSetsFile()
|
|||
progressLabel->hide();
|
||||
progressBar->hide();
|
||||
|
||||
// save allsets.json url, but only if the user customized it and download was successfull
|
||||
// save AllPrintings.json url, but only if the user customized it and download was successful
|
||||
if (urlLineEdit->text() != QString(ALLSETS_URL)) {
|
||||
wizard()->settings->setValue("allsetsurl", urlLineEdit->text());
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ protected:
|
|||
void initializePage() override;
|
||||
bool validatePage() override;
|
||||
void readSetsFromByteArray(QByteArray data);
|
||||
void downloadSetsFile(QUrl url);
|
||||
void downloadSetsFile(const QUrl &url);
|
||||
|
||||
private:
|
||||
QRadioButton *urlRadioButton;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ void SimpleDownloadFilePage::actDownloadFinished()
|
|||
return;
|
||||
}
|
||||
|
||||
// save downlaoded file url, but only if the user customized it and download was successfull
|
||||
// save downloaded file url, but only if the user customized it and download was successful
|
||||
if (urlLineEdit->text() != getDefaultUrl()) {
|
||||
wizard()->settings->setValue(getCustomUrlSettingsKey(), urlLineEdit->text());
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue