mirror of
https://github.com/go-shiori/shiori.git
synced 2025-01-16 21:09:44 +08:00
Add bookmarklet
This commit is contained in:
parent
b1ae14e079
commit
4b939379e8
12 changed files with 355 additions and 25 deletions
File diff suppressed because one or more lines are too long
|
@ -40,6 +40,7 @@ func NewServeCmd(db dt.Database, dataDir string) *cobra.Command {
|
||||||
router.GET("/login", hdl.serveLoginPage)
|
router.GET("/login", hdl.serveLoginPage)
|
||||||
router.GET("/bookmark/:id", hdl.serveBookmarkCache)
|
router.GET("/bookmark/:id", hdl.serveBookmarkCache)
|
||||||
router.GET("/thumb/:id", hdl.serveThumbnailImage)
|
router.GET("/thumb/:id", hdl.serveThumbnailImage)
|
||||||
|
router.GET("/submit", hdl.serveSubmitPage)
|
||||||
|
|
||||||
router.POST("/api/login", hdl.apiLogin)
|
router.POST("/api/login", hdl.apiLogin)
|
||||||
router.GET("/api/bookmarks", hdl.apiGetBookmarks)
|
router.GET("/api/bookmarks", hdl.apiGetBookmarks)
|
||||||
|
|
|
@ -96,6 +96,11 @@ func (h *webHandler) apiGetTags(w http.ResponseWriter, r *http.Request, ps httpr
|
||||||
|
|
||||||
// apiInsertBookmark is handler for POST /api/bookmark
|
// apiInsertBookmark is handler for POST /api/bookmark
|
||||||
func (h *webHandler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (h *webHandler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
|
// Enable CORS for this endpoint
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
// Check token
|
// Check token
|
||||||
err := h.checkAPIToken(r)
|
err := h.checkAPIToken(r)
|
||||||
checkError(err)
|
checkError(err)
|
||||||
|
@ -157,7 +162,14 @@ func (h *webHandler) apiInsertBookmark(w http.ResponseWriter, r *http.Request, p
|
||||||
|
|
||||||
// Save bookmark to database
|
// Save bookmark to database
|
||||||
_, err = h.db.InsertBookmark(book)
|
_, err = h.db.InsertBookmark(book)
|
||||||
checkError(err)
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
book.ID = h.db.GetBookmarkID(book.URL)
|
||||||
|
book.Modified = time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
|
fmt.Println(book.ID, book.Modified)
|
||||||
|
_, err = h.db.UpdateBookmarks(book)
|
||||||
|
checkError(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Return new saved result
|
// Return new saved result
|
||||||
err = json.NewEncoder(w).Encode(&book)
|
err = json.NewEncoder(w).Encode(&book)
|
||||||
|
|
|
@ -34,6 +34,12 @@ func (h *webHandler) serveIndexPage(w http.ResponseWriter, r *http.Request, ps h
|
||||||
checkError(err)
|
checkError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveSubmitPage is handler for GET /submit
|
||||||
|
func (h *webHandler) serveSubmitPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
|
err := serveFile(w, "submit.html")
|
||||||
|
checkError(err)
|
||||||
|
}
|
||||||
|
|
||||||
// serveLoginPage is handler for GET /login
|
// serveLoginPage is handler for GET /login
|
||||||
func (h *webHandler) serveLoginPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
func (h *webHandler) serveLoginPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||||
// Check token
|
// Check token
|
||||||
|
|
|
@ -42,16 +42,21 @@ func newWebHandler(db dt.Database, dataDir string) (*webHandler, error) {
|
||||||
func (h *webHandler) checkToken(r *http.Request) error {
|
func (h *webHandler) checkToken(r *http.Request) error {
|
||||||
tokenCookie, err := r.Cookie("token")
|
tokenCookie, err := r.Cookie("token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Token does not exist")
|
return fmt.Errorf("Token error: Token does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.Parse(tokenCookie.Value, h.jwtKeyFunc)
|
token, err := jwt.Parse(tokenCookie.Value, h.jwtKeyFunc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("Token error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
return claims.Valid()
|
err = claims.Valid()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Token error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) checkAPIToken(r *http.Request) error {
|
func (h *webHandler) checkAPIToken(r *http.Request) error {
|
||||||
|
@ -59,11 +64,17 @@ func (h *webHandler) checkAPIToken(r *http.Request) error {
|
||||||
request.AuthorizationHeaderExtractor,
|
request.AuthorizationHeaderExtractor,
|
||||||
h.jwtKeyFunc)
|
h.jwtKeyFunc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
// Try to check in cookie
|
||||||
|
return h.checkToken(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
return claims.Valid()
|
err = claims.Valid()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Token error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) jwtKeyFunc(token *jwt.Token) (interface{}, error) {
|
func (h *webHandler) jwtKeyFunc(token *jwt.Token) (interface{}, error) {
|
||||||
|
|
|
@ -40,6 +40,9 @@ type Database interface {
|
||||||
|
|
||||||
// DeleteAccounts removes all record with matching usernames
|
// DeleteAccounts removes all record with matching usernames
|
||||||
DeleteAccounts(usernames ...string) error
|
DeleteAccounts(usernames ...string) error
|
||||||
|
|
||||||
|
// GetBookmarkID fetchs bookmark ID based by its url
|
||||||
|
GetBookmarkID(url string) int
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkError(err error) {
|
func checkError(err error) {
|
||||||
|
|
|
@ -324,7 +324,7 @@ func (db *SQLiteDatabase) SearchBookmarks(orderLatest bool, keyword string, tags
|
||||||
|
|
||||||
// Set order clause
|
// Set order clause
|
||||||
if orderLatest {
|
if orderLatest {
|
||||||
query += ` ORDER BY id DESC`
|
query += ` ORDER BY modified DESC`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch bookmarks
|
// Fetch bookmarks
|
||||||
|
@ -552,3 +552,10 @@ func (db *SQLiteDatabase) GetNewID(table string) (int, error) {
|
||||||
|
|
||||||
return tableID, nil
|
return tableID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBookmarkID fetchs bookmark ID based by its url
|
||||||
|
func (db *SQLiteDatabase) GetBookmarkID(url string) int {
|
||||||
|
var bookmarkID int
|
||||||
|
db.Get(&bookmarkID, `SELECT id FROM bookmark WHERE url = ?`, url)
|
||||||
|
return bookmarkID
|
||||||
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -33,7 +33,8 @@
|
||||||
</a>
|
</a>
|
||||||
</yla-tooltip>
|
</yla-tooltip>
|
||||||
<yla-tooltip placement="right" content="Add new bookmark">
|
<yla-tooltip placement="right" content="Add new bookmark">
|
||||||
<a @click="showDialogAdd" v-show="!editMode && !loading">
|
<a :href="bookmarklet" @click="showDialogAdd" v-show="!editMode && !loading">
|
||||||
|
<span>+Shiori</span>
|
||||||
<i class="fas fa-plus fa-fw"></i>
|
<i class="fas fa-plus fa-fw"></i>
|
||||||
</a>
|
</a>
|
||||||
</yla-tooltip>
|
</yla-tooltip>
|
||||||
|
@ -197,6 +198,7 @@
|
||||||
nightMode: false,
|
nightMode: false,
|
||||||
editMode: false,
|
editMode: false,
|
||||||
selected: [],
|
selected: [],
|
||||||
|
bookmarklet: '',
|
||||||
dialogTags: {
|
dialogTags: {
|
||||||
visible: false,
|
visible: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
@ -318,7 +320,8 @@
|
||||||
this.dialogTags.visible = false;
|
this.dialogTags.visible = false;
|
||||||
this.loadData();
|
this.loadData();
|
||||||
},
|
},
|
||||||
showDialogAdd() {
|
showDialogAdd(e) {
|
||||||
|
e.preventDefault();
|
||||||
this.showDialog({
|
this.showDialog({
|
||||||
title: 'New Bookmark',
|
title: 'New Bookmark',
|
||||||
content: 'Create a new bookmark',
|
content: 'Create a new bookmark',
|
||||||
|
@ -643,6 +646,42 @@
|
||||||
this.listView = listView === '1';
|
this.listView = listView === '1';
|
||||||
this.nightMode = nightMode === '1';
|
this.nightMode = nightMode === '1';
|
||||||
|
|
||||||
|
// Create bookmarklet
|
||||||
|
var shioriURL = location.href.replace(/\/+$/g, ''),
|
||||||
|
baseBookmarklet = `(function () {
|
||||||
|
var shioriURL = '$SHIORI_URL',
|
||||||
|
bookmarkURL = location.href,
|
||||||
|
submitURL = shioriURL + '/submit?url=' + encodeURIComponent(bookmarkURL);
|
||||||
|
|
||||||
|
if (bookmarkURL.startsWith('https://') && !shioriURL.startsWith('https://')) {
|
||||||
|
window.open(submitURL, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var i = document.createElement('iframe');
|
||||||
|
i.src = submitURL;
|
||||||
|
i.frameBorder = '0';
|
||||||
|
i.allowTransparency = true;
|
||||||
|
i.style.position = 'fixed';
|
||||||
|
i.style.top = 0;
|
||||||
|
i.style.left = 0;
|
||||||
|
i.style.width = '100%';
|
||||||
|
i.style.height = '100%';
|
||||||
|
i.style.zIndex = 99999;
|
||||||
|
document.body.appendChild(i);
|
||||||
|
|
||||||
|
window.addEventListener('message', function onMessage(e) {
|
||||||
|
if (e.origin !== shioriURL) return;
|
||||||
|
if (e.data !== 'finished') return;
|
||||||
|
window.removeEventListener('message', onMessage);
|
||||||
|
document.body.removeChild(i);
|
||||||
|
});
|
||||||
|
}())`;
|
||||||
|
|
||||||
|
this.bookmarklet = 'javascript:' + baseBookmarklet
|
||||||
|
.replace('$SHIORI_URL', shioriURL)
|
||||||
|
.replace(/\s+/gm, ' ');
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,6 +98,12 @@ body {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--colorSidebar);
|
color: var(--colorSidebar);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
>span {
|
||||||
|
display: block;
|
||||||
|
height: 0;
|
||||||
|
line-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
background-color: #232323;
|
background-color: #232323;
|
||||||
|
@ -680,6 +686,22 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#submit-page {
|
||||||
|
background-color: transparent;
|
||||||
|
.yla-dialog__header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
&:not(.iframe) {
|
||||||
|
background-color: var(--bg);
|
||||||
|
.yla-dialog__overlay {
|
||||||
|
background-color: transparent;
|
||||||
|
.yla-dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.yla-tooltip {
|
.yla-tooltip {
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
@ -83,12 +83,22 @@
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
})
|
})
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
|
// Save token
|
||||||
var token = response.data;
|
var token = response.data;
|
||||||
Cookies.set('token', token, {
|
Cookies.set('token', token, {
|
||||||
expires: this.rememberMe ? 7 : 1
|
expires: this.rememberMe ? 7 : 1
|
||||||
});
|
});
|
||||||
|
|
||||||
location.href = '/';
|
// Set destination URL
|
||||||
|
var rx = /[&?]dst=([^&]+)(&|$)/g,
|
||||||
|
match = rx.exec(location.href);
|
||||||
|
|
||||||
|
if (match == null) {
|
||||||
|
location.href = '/';
|
||||||
|
} else {
|
||||||
|
var dst = match[1];
|
||||||
|
location.href = decodeURIComponent(dst);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
var errorMsg = (error.response ? error.response.data : error.message).trim();
|
var errorMsg = (error.response ? error.response.data : error.message).trim();
|
||||||
|
|
211
view/submit.html
Normal file
211
view/submit.html
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<link rel="stylesheet" href="/css/fontawesome.css">
|
||||||
|
<link rel="stylesheet" href="/css/source-sans-pro.css">
|
||||||
|
<link rel="stylesheet" href="/css/yla-dialog.css">
|
||||||
|
<link rel="stylesheet" href="/css/stylesheet.css">
|
||||||
|
<script src="/js/vue.js"></script>
|
||||||
|
<script src="/js/axios.js"></script>
|
||||||
|
<script src="/js/js-cookie.js"></script>
|
||||||
|
<script src="/js/component/yla-dialog.js"></script>
|
||||||
|
<script src="/js/page/base.js"></script>
|
||||||
|
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="/res/apple-touch-icon-144x144.png" />
|
||||||
|
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/res/apple-touch-icon-152x152.png" />
|
||||||
|
<link rel="icon" type="image/png" href="/res/favicon-32x32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="/res/favicon-16x16.png" sizes="16x16" />
|
||||||
|
<title>Submit New URL - Shiori - Bookmarks Manager</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="submit-page" class="page" :class="{night: nightMode, iframe: inIframe}">
|
||||||
|
<yla-dialog v-bind="dialog"></yla-dialog>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Create private function
|
||||||
|
function _inIframe() {
|
||||||
|
try {
|
||||||
|
return window.self !== window.top;
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Vue component
|
||||||
|
Vue.component('yla-dialog', new YlaDialog());
|
||||||
|
|
||||||
|
// Prepare axios instance
|
||||||
|
var token = Cookies.get('token'),
|
||||||
|
rest = axios.create();
|
||||||
|
|
||||||
|
rest.defaults.timeout = 60000;
|
||||||
|
rest.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||||||
|
|
||||||
|
var app = new Vue({
|
||||||
|
el: '#submit-page',
|
||||||
|
mixins: [new Base()],
|
||||||
|
data: {
|
||||||
|
targetURL: '',
|
||||||
|
nightMode: false,
|
||||||
|
inIframe: false,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showDialogLogin() {
|
||||||
|
this.showDialog({
|
||||||
|
title: 'Login',
|
||||||
|
content: 'Please input username and password',
|
||||||
|
fields: [{
|
||||||
|
name: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
}, {
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password'
|
||||||
|
}],
|
||||||
|
mainText: 'OK',
|
||||||
|
secondText: this.inIframe ? 'Cancel' : '',
|
||||||
|
mainClick: (data) => {
|
||||||
|
// Validate input
|
||||||
|
if (data.username.trim() === '') return;
|
||||||
|
|
||||||
|
// Send data
|
||||||
|
this.dialog.loading = true;
|
||||||
|
rest.post('/api/login', {
|
||||||
|
username: data.username.trim(),
|
||||||
|
password: data.password,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// Save token
|
||||||
|
var token = response.data;
|
||||||
|
Cookies.set('token', token, {
|
||||||
|
expires: this.rememberMe ? 7 : 1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showDialogAdd();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = (error.response ? error.response.data : error.message).trim();
|
||||||
|
this.showErrorDialog(errorMsg);
|
||||||
|
this.dialog.mainClick = () => {
|
||||||
|
this.showDialogLogin();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
secondClick: () => {
|
||||||
|
window.top.postMessage('finished', '*');
|
||||||
|
this.dialog.visible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
showDialogAdd() {
|
||||||
|
this.showDialog({
|
||||||
|
title: 'New Bookmark',
|
||||||
|
content: 'Create a new bookmark',
|
||||||
|
fields: [{
|
||||||
|
name: 'url',
|
||||||
|
label: 'Url, start with http://...',
|
||||||
|
value: this.targetURL,
|
||||||
|
}, {
|
||||||
|
name: 'title',
|
||||||
|
label: 'Custom title (optional)'
|
||||||
|
}, {
|
||||||
|
name: 'excerpt',
|
||||||
|
label: 'Custom excerpt (optional)',
|
||||||
|
type: 'area'
|
||||||
|
}, {
|
||||||
|
name: 'tags',
|
||||||
|
label: 'Comma separated tags (optional)'
|
||||||
|
}, ],
|
||||||
|
mainText: 'Save',
|
||||||
|
secondText: this.inIframe ? 'Cancel' : '',
|
||||||
|
mainClick: (data) => {
|
||||||
|
// Prepare tags
|
||||||
|
var tags = data.tags
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.split(/\s*,\s*/g)
|
||||||
|
.filter(tag => tag !== '')
|
||||||
|
.map(tag => {
|
||||||
|
return {
|
||||||
|
name: tag
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (data.url.trim() === '') return;
|
||||||
|
|
||||||
|
// Send data
|
||||||
|
this.dialog.loading = true;
|
||||||
|
rest.post('/api/bookmarks', {
|
||||||
|
url: data.url.trim(),
|
||||||
|
title: data.title.trim(),
|
||||||
|
excerpt: data.excerpt.trim(),
|
||||||
|
tags: tags
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!this.inIframe) {
|
||||||
|
location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showDialog({
|
||||||
|
title: 'New Bookmark',
|
||||||
|
content: 'The new bookmark has been saved successfully.',
|
||||||
|
mainText: 'OK',
|
||||||
|
mainClick: () => {
|
||||||
|
window.top.postMessage('finished', '*');
|
||||||
|
this.dialog.visible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = (error.response ? error.response.data : error.message).trim();
|
||||||
|
if (errorMsg.startsWith('Token error:')) {
|
||||||
|
this.showDialogLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showErrorDialog(errorMsg);
|
||||||
|
this.dialog.mainClick = () => {
|
||||||
|
this.showDialogAdd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
secondClick: () => {
|
||||||
|
window.top.postMessage('finished', '*');
|
||||||
|
this.dialog.visible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Check if in iframe
|
||||||
|
this.inIframe = _inIframe();
|
||||||
|
|
||||||
|
// Read config from local storage
|
||||||
|
var nightMode = localStorage.getItem('shiori-night-mode');
|
||||||
|
this.nightMode = nightMode === '1';
|
||||||
|
|
||||||
|
// Get target URL
|
||||||
|
var rxURL = /[&?]url=([^&]+)(&|$)/g,
|
||||||
|
match = rxURL.exec(location.href);
|
||||||
|
|
||||||
|
if (match != null) {
|
||||||
|
var dst = match[1];
|
||||||
|
this.targetURL = decodeURIComponent(dst);
|
||||||
|
} else {
|
||||||
|
this.targetURL = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dialog
|
||||||
|
this.showDialogAdd();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in a new issue