forked from len0rd/rockbox
Theme Editor: Added target database, now populates combo box in new project dialog but otherwise not used yet
git-svn-id: svn://svn.rockbox.org/rockbox/trunk@27450 a1c6a512-1295-4272-9138-f99709370657
This commit is contained in:
parent
1c1d10b9fd
commit
025147effb
9 changed files with 424 additions and 11 deletions
|
@ -35,6 +35,7 @@
|
||||||
#include "skinviewer.h"
|
#include "skinviewer.h"
|
||||||
#include "devicestate.h"
|
#include "devicestate.h"
|
||||||
#include "skintimer.h"
|
#include "skintimer.h"
|
||||||
|
#include "targetdata.h"
|
||||||
|
|
||||||
class ProjectModel;
|
class ProjectModel;
|
||||||
class TabContent;
|
class TabContent;
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
#include "newprojectdialog.h"
|
#include "newprojectdialog.h"
|
||||||
#include "ui_newprojectdialog.h"
|
#include "ui_newprojectdialog.h"
|
||||||
|
#include "targetdata.h"
|
||||||
|
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
@ -42,6 +43,13 @@ NewProjectDialog::NewProjectDialog(QWidget *parent) :
|
||||||
|
|
||||||
settings.endGroup();
|
settings.endGroup();
|
||||||
|
|
||||||
|
/* Populating the target box */
|
||||||
|
TargetData targets;
|
||||||
|
for(int i = 0; i < targets.count(); i++)
|
||||||
|
{
|
||||||
|
ui->targetBox->insertItem(i, QIcon(), targets.name(i), targets.id(i));
|
||||||
|
}
|
||||||
|
|
||||||
/* Connecting the browse button */
|
/* Connecting the browse button */
|
||||||
QObject::connect(ui->browseButton, SIGNAL(clicked()),
|
QObject::connect(ui->browseButton, SIGNAL(clicked()),
|
||||||
this, SLOT(browse()));
|
this, SLOT(browse()));
|
||||||
|
@ -56,6 +64,8 @@ void NewProjectDialog::accept()
|
||||||
{
|
{
|
||||||
status.name = ui->nameBox->text();
|
status.name = ui->nameBox->text();
|
||||||
status.path = ui->locationBox->text();
|
status.path = ui->locationBox->text();
|
||||||
|
status.target = ui->targetBox->itemData(ui->targetBox->currentIndex())
|
||||||
|
.toString();
|
||||||
status.sbs = ui->sbsBox->isChecked();
|
status.sbs = ui->sbsBox->isChecked();
|
||||||
status.wps = ui->wpsBox->isChecked();
|
status.wps = ui->wpsBox->isChecked();
|
||||||
status.fms = ui->fmsBox->isChecked();
|
status.fms = ui->fmsBox->isChecked();
|
||||||
|
@ -77,6 +87,7 @@ void NewProjectDialog::reject()
|
||||||
{
|
{
|
||||||
ui->nameBox->setText(status.name);
|
ui->nameBox->setText(status.name);
|
||||||
ui->locationBox->setText(status.path);
|
ui->locationBox->setText(status.path);
|
||||||
|
ui->targetBox->setCurrentIndex(0);
|
||||||
ui->sbsBox->setChecked(status.sbs);
|
ui->sbsBox->setChecked(status.sbs);
|
||||||
ui->wpsBox->setChecked(status.wps);
|
ui->wpsBox->setChecked(status.wps);
|
||||||
ui->fmsBox->setChecked(status.fms);
|
ui->fmsBox->setChecked(status.fms);
|
||||||
|
|
|
@ -35,6 +35,7 @@ public:
|
||||||
{
|
{
|
||||||
QString name;
|
QString name;
|
||||||
QString path;
|
QString path;
|
||||||
|
QString target;
|
||||||
bool sbs;
|
bool sbs;
|
||||||
bool wps;
|
bool wps;
|
||||||
bool fms;
|
bool fms;
|
||||||
|
@ -46,6 +47,7 @@ public:
|
||||||
{
|
{
|
||||||
name = "";
|
name = "";
|
||||||
path = "";
|
path = "";
|
||||||
|
target = "";
|
||||||
sbs = true;
|
sbs = true;
|
||||||
wps = true;
|
wps = true;
|
||||||
fms = false;
|
fms = false;
|
||||||
|
@ -63,6 +65,7 @@ public:
|
||||||
{
|
{
|
||||||
name = other.name;
|
name = other.name;
|
||||||
path = other.path;
|
path = other.path;
|
||||||
|
target = other.target;
|
||||||
sbs = other.sbs;
|
sbs = other.sbs;
|
||||||
wps = other.wps;
|
wps = other.wps;
|
||||||
fms = other.fms;
|
fms = other.fms;
|
||||||
|
|
|
@ -63,18 +63,12 @@
|
||||||
<string>Target:</string>
|
<string>Target:</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="buddy">
|
<property name="buddy">
|
||||||
<cstring>comboBox</cstring>
|
<cstring>targetBox</cstring>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="1">
|
<item row="2" column="1">
|
||||||
<widget class="QComboBox" name="comboBox">
|
<widget class="QComboBox" name="targetBox"/>
|
||||||
<item>
|
|
||||||
<property name="text">
|
|
||||||
<string>Not Yet Available</string>
|
|
||||||
</property>
|
|
||||||
</item>
|
|
||||||
</widget>
|
|
||||||
</item>
|
</item>
|
||||||
<item row="4" column="0" colspan="2">
|
<item row="4" column="0" colspan="2">
|
||||||
<widget class="QGroupBox" name="groupBox">
|
<widget class="QGroupBox" name="groupBox">
|
||||||
|
|
245
utils/themeeditor/models/targetdata.cpp
Normal file
245
utils/themeeditor/models/targetdata.cpp
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
/***************************************************************************
|
||||||
|
* __________ __ ___.
|
||||||
|
* Open \______ \ ____ ____ | | _\_ |__ _______ ___
|
||||||
|
* Source | _// _ \_/ ___\| |/ /| __ \ / _ \ \/ /
|
||||||
|
* Jukebox | | ( <_> ) \___| < | \_\ ( <_> > < <
|
||||||
|
* Firmware |____|_ /\____/ \___ >__|_ \|___ /\____/__/\_ \
|
||||||
|
* \/ \/ \/ \/ \/
|
||||||
|
* $Id$
|
||||||
|
*
|
||||||
|
* Copyright (C) 2010 Robert Bieber
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 2
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||||
|
* KIND, either express or implied.
|
||||||
|
*
|
||||||
|
****************************************************************************/
|
||||||
|
|
||||||
|
#include "targetdata.h"
|
||||||
|
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
const QString TargetData::reserved = "{}:#\n";
|
||||||
|
|
||||||
|
TargetData::TargetData(QString file)
|
||||||
|
{
|
||||||
|
if(!QFile::exists(file))
|
||||||
|
file = ":/targets/targetdb";
|
||||||
|
|
||||||
|
QFile fin(file);
|
||||||
|
fin.open(QFile::ReadOnly | QFile::Text);
|
||||||
|
|
||||||
|
/* Reading the database */
|
||||||
|
QString data = QString(fin.readAll());
|
||||||
|
|
||||||
|
fin.close();
|
||||||
|
|
||||||
|
int cursor = 0;
|
||||||
|
int index = 0;
|
||||||
|
while(cursor < data.count())
|
||||||
|
{
|
||||||
|
QString id = scanString(data, cursor);
|
||||||
|
QString name = "";
|
||||||
|
QRect size(0, 0, 0, 0);
|
||||||
|
ScreenDepth depth = None;
|
||||||
|
QRect rSize(0, 0, 0, 0);
|
||||||
|
ScreenDepth rDepth = None;
|
||||||
|
bool fm = false;
|
||||||
|
|
||||||
|
if(id == "")
|
||||||
|
break;
|
||||||
|
|
||||||
|
if(!require('{', data, cursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Now we have to parse each of the arguments */
|
||||||
|
while(cursor < data.count())
|
||||||
|
{
|
||||||
|
QString key = scanString(data, cursor);
|
||||||
|
if(key == "")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(!require(':', data, cursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if(key.toLower() == "name")
|
||||||
|
{
|
||||||
|
name = scanString(data, cursor);
|
||||||
|
}
|
||||||
|
else if(key.toLower() == "screen")
|
||||||
|
{
|
||||||
|
QString s = scanString(data, cursor);
|
||||||
|
if(s[0].toLower() != 'n')
|
||||||
|
{
|
||||||
|
int subCursor = 0;
|
||||||
|
int width = scanInt(s, subCursor);
|
||||||
|
|
||||||
|
if(!require('x', s, subCursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
int height = scanInt(s, subCursor);
|
||||||
|
|
||||||
|
if(!require('@', s, subCursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
size = QRect(0, 0, width, height);
|
||||||
|
depth = scanDepth(s, subCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(key.toLower() == "remote")
|
||||||
|
{
|
||||||
|
QString s = scanString(data, cursor);
|
||||||
|
if(s[0].toLower() != 'n')
|
||||||
|
{
|
||||||
|
int subCursor = 0;
|
||||||
|
int width = scanInt(s, subCursor);
|
||||||
|
|
||||||
|
if(!require('x', s, subCursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
int height = scanInt(s, subCursor);
|
||||||
|
|
||||||
|
if(!require('@', s, subCursor))
|
||||||
|
break;
|
||||||
|
|
||||||
|
rSize = QRect(0, 0, width, height);
|
||||||
|
rDepth = scanDepth(s, subCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(key.toLower() == "fm")
|
||||||
|
{
|
||||||
|
QString s = scanString(data, cursor);
|
||||||
|
if(s.toLower() == "yes")
|
||||||
|
fm = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checking for the closing '}' and adding the entry */
|
||||||
|
if(require('}', data, cursor))
|
||||||
|
{
|
||||||
|
entries.append(Entry(name, size, depth, rSize, rDepth, fm));
|
||||||
|
indices.insert(id, index);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TargetData::~TargetData()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TargetData::scanString(QString data, int &index)
|
||||||
|
{
|
||||||
|
QString retval;
|
||||||
|
|
||||||
|
/* Skipping whitespace and comments */
|
||||||
|
while(index < data.count() && (data[index].isSpace() || data[index] == '#'))
|
||||||
|
{
|
||||||
|
if(data[index] == '#')
|
||||||
|
skipComment(data, index);
|
||||||
|
else
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while(index < data.count() && !reserved.contains(data[index]))
|
||||||
|
{
|
||||||
|
if(data[index] == '%')
|
||||||
|
{
|
||||||
|
retval.append(data[index + 1]);
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
retval.append(data[index]);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
int TargetData::scanInt(QString data, int &index)
|
||||||
|
{
|
||||||
|
/* Skipping whitespace and comments */
|
||||||
|
while(index < data.count() && (data[index].isSpace() || data[index] == '#'))
|
||||||
|
{
|
||||||
|
if(data[index] == '#')
|
||||||
|
skipComment(data, index);
|
||||||
|
else
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString number;
|
||||||
|
while(index < data.count() && data[index].isDigit())
|
||||||
|
number.append(data[index++]);
|
||||||
|
|
||||||
|
return number.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
TargetData::ScreenDepth TargetData::scanDepth(QString data, int &index)
|
||||||
|
{
|
||||||
|
QString depth = scanString(data, index);
|
||||||
|
|
||||||
|
if(depth.toLower() == "grey")
|
||||||
|
return Grey;
|
||||||
|
else if(depth.toLower() == "rgb")
|
||||||
|
return RGB;
|
||||||
|
else if(depth.toLower() == "mono")
|
||||||
|
return Mono;
|
||||||
|
else
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TargetData::skipComment(QString data, int &index)
|
||||||
|
{
|
||||||
|
if(data[index] != '#')
|
||||||
|
return;
|
||||||
|
|
||||||
|
while(index < data.count() && data[index] != '\n')
|
||||||
|
index++;
|
||||||
|
|
||||||
|
if(index < data.count())
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TargetData::require(QChar required, QString data, int &index)
|
||||||
|
{
|
||||||
|
/* Skipping whitespace and comments */
|
||||||
|
while(index < data.count() && (data[index].isSpace() || data[index] == '#'))
|
||||||
|
{
|
||||||
|
if(data[index] == '#')
|
||||||
|
skipComment(data, index);
|
||||||
|
else
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(index == data.count())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if(data[index] == required)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
80
utils/themeeditor/models/targetdata.h
Normal file
80
utils/themeeditor/models/targetdata.h
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
/***************************************************************************
|
||||||
|
* __________ __ ___.
|
||||||
|
* Open \______ \ ____ ____ | | _\_ |__ _______ ___
|
||||||
|
* Source | _// _ \_/ ___\| |/ /| __ \ / _ \ \/ /
|
||||||
|
* Jukebox | | ( <_> ) \___| < | \_\ ( <_> > < <
|
||||||
|
* Firmware |____|_ /\____/ \___ >__|_ \|___ /\____/__/\_ \
|
||||||
|
* \/ \/ \/ \/ \/
|
||||||
|
* $Id$
|
||||||
|
*
|
||||||
|
* Copyright (C) 2010 Robert Bieber
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or
|
||||||
|
* modify it under the terms of the GNU General Public License
|
||||||
|
* as published by the Free Software Foundation; either version 2
|
||||||
|
* of the License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
|
||||||
|
* KIND, either express or implied.
|
||||||
|
*
|
||||||
|
****************************************************************************/
|
||||||
|
|
||||||
|
#ifndef TARGETDATA_H
|
||||||
|
#define TARGETDATA_H
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QString>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QRect>
|
||||||
|
|
||||||
|
class TargetData
|
||||||
|
{
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum ScreenDepth
|
||||||
|
{
|
||||||
|
RGB,
|
||||||
|
Grey,
|
||||||
|
Mono,
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
TargetData(QString file = "");
|
||||||
|
virtual ~TargetData();
|
||||||
|
|
||||||
|
int count(){ return indices.count(); }
|
||||||
|
int index(QString id){ return indices.value(id, -1); }
|
||||||
|
|
||||||
|
QString id(int index){ return indices.key(index, ""); }
|
||||||
|
QString name(int index){ return entries[index].name; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Entry
|
||||||
|
{
|
||||||
|
Entry(QString name, QRect size, ScreenDepth depth, QRect rSize,
|
||||||
|
ScreenDepth rDepth, bool fm)
|
||||||
|
: name(name), size(size), depth(depth), rSize(rSize),
|
||||||
|
rDepth(rDepth), fm(fm){ }
|
||||||
|
|
||||||
|
QString name;
|
||||||
|
QRect size;
|
||||||
|
ScreenDepth depth;
|
||||||
|
QRect rSize;
|
||||||
|
ScreenDepth rDepth;
|
||||||
|
bool fm;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const QString reserved;
|
||||||
|
|
||||||
|
QString scanString(QString data, int& index);
|
||||||
|
int scanInt(QString data, int& index);
|
||||||
|
ScreenDepth scanDepth(QString data, int& index);
|
||||||
|
void skipComment(QString data, int& index);
|
||||||
|
bool require(QChar required, QString data, int& index);
|
||||||
|
|
||||||
|
QHash<QString, int> indices;
|
||||||
|
QList<Entry> entries;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // TARGETDATA_H
|
|
@ -24,4 +24,7 @@
|
||||||
<qresource prefix="/fonts">
|
<qresource prefix="/fonts">
|
||||||
<file alias="08-Schumacher-Clean.fnt">resources/fonts/08-Schumacher-Clean.fnt</file>
|
<file alias="08-Schumacher-Clean.fnt">resources/fonts/08-Schumacher-Clean.fnt</file>
|
||||||
</qresource>
|
</qresource>
|
||||||
|
<qresource prefix="/targets">
|
||||||
|
<file alias="targetdb">resources/targetdb</file>
|
||||||
|
</qresource>
|
||||||
</RCC>
|
</RCC>
|
||||||
|
|
73
utils/themeeditor/resources/targetdb
Normal file
73
utils/themeeditor/resources/targetdb
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
ipod12
|
||||||
|
{
|
||||||
|
name : iPod 1st/2nd Gen
|
||||||
|
screen : 160 x 128 @ grey
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipod3
|
||||||
|
{
|
||||||
|
name : iPod 3rd Gen
|
||||||
|
screen : 160 x 128 @ grey
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipod4
|
||||||
|
{
|
||||||
|
name : iPod 4th Gen
|
||||||
|
screen : 160 x 128 @ grey
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipodmini12
|
||||||
|
{
|
||||||
|
name : iPod Mini 1st/2nd Gen
|
||||||
|
screen : 138 x 110 @ grey
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipodcolor
|
||||||
|
{
|
||||||
|
name : iPod Color/Photo
|
||||||
|
screen : 220 x 176 @ rgb
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipodnano1
|
||||||
|
{
|
||||||
|
name : iPod Nano 1st Gen
|
||||||
|
screen : 176 x 132 @ rgb
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
ipodvideo
|
||||||
|
{
|
||||||
|
name : iPod Video
|
||||||
|
screen : 320 x 240 @ rgb
|
||||||
|
fm : no
|
||||||
|
remote : no
|
||||||
|
}
|
||||||
|
|
||||||
|
# Olympus units
|
||||||
|
|
||||||
|
mrobe100
|
||||||
|
{
|
||||||
|
name : m%:robe 100
|
||||||
|
screen : 160 x 128 @ mono
|
||||||
|
fm : no
|
||||||
|
remote : 79 x 16 @ mono
|
||||||
|
}
|
||||||
|
|
||||||
|
mrobe500
|
||||||
|
{
|
||||||
|
name : m%:robe 500
|
||||||
|
screen : 640 x 480 @ rgb
|
||||||
|
fm : no
|
||||||
|
remote : 79 x 16 @ mono
|
||||||
|
}
|
|
@ -55,7 +55,8 @@ HEADERS += models/parsetreemodel.h \
|
||||||
graphics/rbtextcache.h \
|
graphics/rbtextcache.h \
|
||||||
gui/skintimer.h \
|
gui/skintimer.h \
|
||||||
graphics/rbtoucharea.h \
|
graphics/rbtoucharea.h \
|
||||||
gui/newprojectdialog.h
|
gui/newprojectdialog.h \
|
||||||
|
models/targetdata.h
|
||||||
SOURCES += main.cpp \
|
SOURCES += main.cpp \
|
||||||
models/parsetreemodel.cpp \
|
models/parsetreemodel.cpp \
|
||||||
models/parsetreenode.cpp \
|
models/parsetreenode.cpp \
|
||||||
|
@ -81,7 +82,8 @@ SOURCES += main.cpp \
|
||||||
graphics/rbtextcache.cpp \
|
graphics/rbtextcache.cpp \
|
||||||
gui/skintimer.cpp \
|
gui/skintimer.cpp \
|
||||||
graphics/rbtoucharea.cpp \
|
graphics/rbtoucharea.cpp \
|
||||||
gui/newprojectdialog.cpp
|
gui/newprojectdialog.cpp \
|
||||||
|
models/targetdata.cpp
|
||||||
OTHER_FILES += README \
|
OTHER_FILES += README \
|
||||||
resources/windowicon.png \
|
resources/windowicon.png \
|
||||||
resources/appicon.xcf \
|
resources/appicon.xcf \
|
||||||
|
@ -102,7 +104,8 @@ OTHER_FILES += README \
|
||||||
resources/lines.xcf \
|
resources/lines.xcf \
|
||||||
resources/lines.png \
|
resources/lines.png \
|
||||||
resources/cursor.xcf \
|
resources/cursor.xcf \
|
||||||
resources/cursor.png
|
resources/cursor.png \
|
||||||
|
resources/targetdb
|
||||||
FORMS += gui/editorwindow.ui \
|
FORMS += gui/editorwindow.ui \
|
||||||
gui/preferencesdialog.ui \
|
gui/preferencesdialog.ui \
|
||||||
gui/configdocument.ui \
|
gui/configdocument.ui \
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue