mirror of https://github.com/Jackett/Jackett
Web UI working
This commit is contained in:
parent
a4f18471a8
commit
e27b696c61
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jackett
|
||||
{
|
||||
public class ApiKey
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
public static string Generate()
|
||||
{
|
||||
var randBytes = new byte[32];
|
||||
var rngCsp = new RNGCryptoServiceProvider();
|
||||
rngCsp.GetBytes(randBytes);
|
||||
var key = "";
|
||||
foreach (var b in randBytes)
|
||||
{
|
||||
key += chars[b % chars.Length];
|
||||
}
|
||||
return key;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -58,7 +58,7 @@ namespace Jackett
|
|||
client = new HttpClient(handler);
|
||||
}
|
||||
|
||||
public string DisplayName { get { return "BitMeTV.org"; } }
|
||||
public string DisplayName { get { return "BitMeTV"; } }
|
||||
public string DisplayDescription { get { return "TV Episode specialty tracker"; } }
|
||||
public Uri SiteLink { get { return new Uri("https://bitmetv.org"); } }
|
||||
|
||||
|
@ -102,6 +102,7 @@ namespace Jackett
|
|||
var errorMessage = messageEl.Text();
|
||||
var captchaImage = await client.GetByteArrayAsync(CaptchaUrl);
|
||||
config.CaptchaImage.Value = captchaImage;
|
||||
config.CaptchaText.Value = "";
|
||||
throw new ExceptionWithConfigData(errorMessage, (ConfigurationData)config);
|
||||
}
|
||||
else
|
||||
|
@ -114,6 +115,8 @@ namespace Jackett
|
|||
|
||||
if (OnSaveConfigurationRequested != null)
|
||||
OnSaveConfigurationRequested(configSaveData);
|
||||
|
||||
IsConfigured = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -123,6 +126,8 @@ namespace Jackett
|
|||
return Task.Run(async () =>
|
||||
{
|
||||
var result = await client.GetStringAsync(new Uri(SearchUrl));
|
||||
if (result.Contains("<h1>Not logged in!</h1>"))
|
||||
throw new Exception("Detected as not logged in");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="ApiKey.cs" />
|
||||
<Compile Include="ChannelInfo.cs" />
|
||||
<Compile Include="ConfigurationData.cs" />
|
||||
<Compile Include="DataUrl.cs" />
|
||||
|
@ -91,7 +92,7 @@
|
|||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
<None Include="packages.config" />
|
||||
<None Include="WebContent\bootstrap\glyphicons-halflings-regular.woff">
|
||||
<None Include="WebContent\fonts\glyphicons-halflings-regular.woff">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
@ -105,15 +106,15 @@
|
|||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="WebContent\congruent_outline.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\handlebars-v3.0.1.js">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\logos\bitmetv.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\logos\freshon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\bootstrap\bootstrap.min.css">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
@ -129,6 +130,9 @@
|
|||
<Content Include="WebContent\jquery-2.1.3.min.js">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\logos\freshon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="WebContent\setup_indexer.html">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
|
|
@ -4,6 +4,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
|
@ -19,13 +20,15 @@ namespace Jackett
|
|||
{
|
||||
GetConfigForm,
|
||||
ConfigureIndexer,
|
||||
GetIndexers
|
||||
GetIndexers,
|
||||
TestIndexer
|
||||
}
|
||||
static Dictionary<string, WebApiMethod> WebApiMethods = new Dictionary<string, WebApiMethod>
|
||||
{
|
||||
{ "get_config_form", WebApiMethod.GetConfigForm },
|
||||
{ "configure_indexer", WebApiMethod.ConfigureIndexer },
|
||||
{ "get_indexers", WebApiMethod.GetIndexers }
|
||||
{ "get_indexers", WebApiMethod.GetIndexers },
|
||||
{ "test_indexer", WebApiMethod.TestIndexer}
|
||||
};
|
||||
|
||||
IndexerManager indexerManager;
|
||||
|
@ -63,8 +66,12 @@ namespace Jackett
|
|||
var contentFile = File.ReadAllBytes(Path.Combine(WebContentFolder, file));
|
||||
context.Response.ContentType = MimeMapping.GetMimeMapping(file);
|
||||
context.Response.StatusCode = (int)HttpStatusCode.OK;
|
||||
await context.Response.OutputStream.WriteAsync(contentFile, 0, contentFile.Length);
|
||||
context.Response.OutputStream.Close();
|
||||
try
|
||||
{
|
||||
await context.Response.OutputStream.WriteAsync(contentFile, 0, contentFile.Length);
|
||||
context.Response.OutputStream.Close();
|
||||
}
|
||||
catch (HttpListenerException) { }
|
||||
}
|
||||
|
||||
async Task<JToken> ReadPostDataJson(Stream stream)
|
||||
|
@ -92,6 +99,7 @@ namespace Jackett
|
|||
var indexer = indexerManager.GetIndexer(indexerString);
|
||||
var config = await indexer.GetConfigurationForSetup();
|
||||
jsonReply["config"] = config.ToJson();
|
||||
jsonReply["name"] = indexer.DisplayName;
|
||||
jsonReply["result"] = "success";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -106,6 +114,7 @@ namespace Jackett
|
|||
var postData = await ReadPostDataJson(context.Request.InputStream);
|
||||
string indexerString = (string)postData["indexer"];
|
||||
var indexer = indexerManager.GetIndexer(indexerString);
|
||||
jsonReply["name"] = indexer.DisplayName;
|
||||
await indexer.ApplyConfiguration(postData["config"]);
|
||||
await indexer.VerifyConnection();
|
||||
jsonReply["result"] = "success";
|
||||
|
@ -124,15 +133,16 @@ namespace Jackett
|
|||
try
|
||||
{
|
||||
jsonReply["result"] = "success";
|
||||
jsonReply["api_key"] = ApiKey.Generate();
|
||||
JArray items = new JArray();
|
||||
foreach (var i in indexerManager.Indexers)
|
||||
{
|
||||
var indexer = i.Value;
|
||||
var item = new JObject();
|
||||
item["id"] = i.Key;
|
||||
item["display_name"] = indexer.DisplayName;
|
||||
item["display_description"] = indexer.DisplayDescription;
|
||||
item["is_configured"] = indexer.IsConfigured;
|
||||
item["name"] = indexer.DisplayName;
|
||||
item["description"] = indexer.DisplayDescription;
|
||||
item["configured"] = indexer.IsConfigured;
|
||||
item["site_link"] = indexer.SiteLink;
|
||||
items.Add(item);
|
||||
}
|
||||
|
@ -144,6 +154,22 @@ namespace Jackett
|
|||
jsonReply["error"] = ex.Message;
|
||||
}
|
||||
break;
|
||||
case WebApiMethod.TestIndexer:
|
||||
try
|
||||
{
|
||||
var postData = await ReadPostDataJson(context.Request.InputStream);
|
||||
string indexerString = (string)postData["indexer"];
|
||||
var indexer = indexerManager.GetIndexer(indexerString);
|
||||
jsonReply["name"] = indexer.DisplayName;
|
||||
await indexer.VerifyConnection();
|
||||
jsonReply["result"] = "success";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
jsonReply["result"] = "error";
|
||||
jsonReply["error"] = ex.Message;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
jsonReply["result"] = "error";
|
||||
jsonReply["error"] = "Invalid API method";
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
|
@ -9,36 +9,353 @@
|
|||
<link href="bootstrap/bootstrap.min.css" rel="stylesheet">
|
||||
<title>Jackett</title>
|
||||
<style>
|
||||
body {
|
||||
background-image: url("congruent_outline.png");
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
#page {
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container-fluid {
|
||||
}
|
||||
|
||||
#templates {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#api-key-input {
|
||||
width: 300px;
|
||||
display: inline-block;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#api-key-header {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 1px 1px 5px 2px #cdcdcd;
|
||||
padding: 10px;
|
||||
width: 225px;
|
||||
display: inline-block;
|
||||
margin-right: 30px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.unconfigured-indexer {
|
||||
height: 170px;
|
||||
}
|
||||
|
||||
.indexer {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
#add-indexer {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.indexer-logo {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.indexer-logo > img {
|
||||
border: 1px solid #828282;
|
||||
}
|
||||
|
||||
.indexer-name > h3 {
|
||||
margin-top: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.indexer-buttons {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.indexer-add-content {
|
||||
color: gray;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.indexer-add-content > .glyphicon {
|
||||
font-size: 50px;
|
||||
margin-top: 35%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.indexer-add-content > .light-text {
|
||||
margin-top: 11px;
|
||||
font-size: 18px;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.indexer-host {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.indexer-host > input {
|
||||
font-size: 12px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.setup-item-inputstring {
|
||||
max-width: 260px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div id="indexers"></div>
|
||||
<div id="page">
|
||||
|
||||
<div id="api-key">
|
||||
<span id="api-key-header">API Key: </span>
|
||||
<input id="api-key-input" class="form-control" type="text" value="" placeholder="API Key" readonly="">
|
||||
<span>(Same key works for all indexers)</span>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<hr />
|
||||
|
||||
<h3>Configured Indexers</h3>
|
||||
<div id="indexers">
|
||||
|
||||
<a class="indexer card" id="add-indexer" href="#" data-toggle="modal" data-target="#select-indexer-modal">
|
||||
<div class="indexer-add-content">
|
||||
<span class="glyphicon glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
<div class="light-text">Add</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="templates">
|
||||
<div class="indexer">
|
||||
<div class="indexer-name">{{name}}</div>
|
||||
<div class="indexer-description">{{description}}</div>
|
||||
<div class="indexer-link"><a href="{{link}}">{{link}}</a></div>
|
||||
<div class="indexer-configured">
|
||||
{{#if configured}}
|
||||
<span>Configred</span>
|
||||
{{else}}
|
||||
<span>Not configured</span>
|
||||
{{/if}}
|
||||
<div id="select-indexer-modal" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Select an indexer to setup</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="unconfigured-indexers">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="setup-indexer-modal" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Setup indexer</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="indexer-setup-form"></form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="setup-indexer-go">Okay</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="templates">
|
||||
|
||||
<div class="indexer card">
|
||||
<div class="indexer-logo"><img src="logos/{{id}}.png" /></div>
|
||||
<div class="indexer-name"><h3>{{name}}</h3></div>
|
||||
<div class="indexer-buttons">
|
||||
<a class="btn btn-info btn-sm" target="_blank" href="{{site_link}}">Visit <span class="glyphicon glyphicon-new-window" aria-hidden="true"></span></a>
|
||||
<a class="btn btn-danger btn-sm" href="#">Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
<a class="btn btn-primary btn-sm indexer-button-test" href="#" data-id="{{id}}">Test <span class="glyphicon glyphicon-screenshot" aria-hidden="true"></span></a>
|
||||
</div>
|
||||
<div class="indexer-host">
|
||||
<b>Torznab Host:</b>
|
||||
<input class="form-control" type="text" value="{{torznab_host}}" placeholder="Torznab Host" readonly="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="unconfigured-indexer card">
|
||||
<div class="indexer-logo"><img src="logos/{{id}}.png" /></div>
|
||||
<div class="indexer-name"><h3>{{name}}</h3></div>
|
||||
<div class="indexer-buttons">
|
||||
<a class="btn btn-info" target="_blank" href="{{site_link}}">Visit <span class="glyphicon glyphicon-new-window" aria-hidden="true"></span></a>
|
||||
<button class="indexer-setup btn btn-success" data-id="{{id}}">Setup <span class="glyphicon glyphicon-ok" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setup-item form-group" data-id="{{id}}" data-value="{{value}}" data-type="{{type}}">
|
||||
<div class="setup-item-label">{{name}}</div>
|
||||
<div class="setup-item-value">{{{value_element}}}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="setup-item-header" data-type="indexer" data-name="{{name}}" data-indexer="{{indexer}}">{{name}}</h4>
|
||||
<input class="setup-item-inputstring form-control" type="text" value="{{{value}}}"></input>
|
||||
<div class="setup-item-checkbox">
|
||||
{{#if value}}
|
||||
<input type="checkbox" class="form-control" checked></input>
|
||||
{{else}}
|
||||
<input type="checkbox" class="form-control"></input>
|
||||
{{/if}}
|
||||
</div>
|
||||
<img class="setup-item-displayimage" src="{{{value}}}" />
|
||||
<span class="setup-item-displayinfo">{{{value}}}</span>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var indexerTemplate = Handlebars.compile($("#templates > .indexer").html());
|
||||
$('#indexers').append(indexerTemplate({ name: "n", description: "desc", link: "http://google.com", configured: true }));
|
||||
|
||||
function reloadIndexers() {
|
||||
$('#indexers').hide();
|
||||
$('#indexers > div.indexer').remove();
|
||||
$('#unconfigured-indexers').empty();
|
||||
var jqxhr = $.get("get_indexers", function (data) {
|
||||
$("#api-key-input").val(data.api_key);
|
||||
displayIndexers(data.items);
|
||||
}).fail(function () {
|
||||
alert("error");
|
||||
});
|
||||
}
|
||||
|
||||
function displayIndexers(items) {
|
||||
var indexerTemplate = Handlebars.compile($("#templates > .indexer")[0].outerHTML);
|
||||
var unconfiguredIndexerTemplate = Handlebars.compile($("#templates > .unconfigured-indexer")[0].outerHTML);
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
item.torznab_host = resolveUrl("/torznab/" + item.id);
|
||||
if (item.configured)
|
||||
$('#indexers').prepend(indexerTemplate(item));
|
||||
else
|
||||
$('#unconfigured-indexers').prepend($(unconfiguredIndexerTemplate(item)));
|
||||
}
|
||||
$('#indexers').fadeIn();
|
||||
prepareSetupButtons();
|
||||
prepareTestButtons();
|
||||
}
|
||||
|
||||
function prepareSetupButtons() {
|
||||
$('.indexer-setup').each(function (i, btn) {
|
||||
var $btn = $(btn);
|
||||
var id = $btn.data("id");
|
||||
$btn.click(function () {
|
||||
displayIndexerSetup(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function prepareTestButtons() {
|
||||
$(".indexer-button-test").each(function (i, btn) {
|
||||
var $btn = $(btn);
|
||||
var id = $btn.data("id");
|
||||
$btn.click(function () {
|
||||
var jqxhr = $.post("test_indexer", JSON.stringify({ indexer: id }), function (data) {
|
||||
if (data.result == "error") {
|
||||
alert("Test failed for " + data.name + "\n" + data.error);
|
||||
}
|
||||
else {
|
||||
alert("Test successful for " + data.name);
|
||||
}
|
||||
}).fail(function () {
|
||||
alert("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function displayIndexerSetup(id) {
|
||||
|
||||
var jqxhr = $.post("get_config_form", JSON.stringify({ indexer: id }), function (data) {
|
||||
if (data.result == "error") {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
populateSetupForm(id, data.name, data.config);
|
||||
$("#setup-indexer-modal").modal("show");
|
||||
}).fail(function () {
|
||||
alert("error");
|
||||
});
|
||||
|
||||
$("#select-indexer-modal").modal("hide");
|
||||
}
|
||||
|
||||
function populateSetupForm(indexerId, name, config) {
|
||||
$("#indexer-setup-form").empty();
|
||||
var setupItemHeader = Handlebars.compile($("#templates > .setup-item-header")[0].outerHTML);
|
||||
$("#indexer-setup-form").append(setupItemHeader({ indexer: indexerId, name: name }));
|
||||
var setupItemTemplate = Handlebars.compile($("#templates > .setup-item")[0].outerHTML);
|
||||
for (var i = 0; i < config.length; i++) {
|
||||
var item = config[i];
|
||||
var setupValueTemplate = Handlebars.compile($("#templates > .setup-item-" + item.type)[0].outerHTML);
|
||||
item.value_element = setupValueTemplate(item);
|
||||
$("#indexer-setup-form").append(setupItemTemplate(item));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(url) {
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
url = a.href;
|
||||
return url;
|
||||
}
|
||||
|
||||
$("#setup-indexer-go").click(function () {
|
||||
var data = { config: {} };
|
||||
$("#indexer-setup-form").children().each(function (i, el) {
|
||||
$el = $(el);
|
||||
var type = $el.data("type");
|
||||
var id = $el.data("id");
|
||||
switch (type) {
|
||||
case "indexer":
|
||||
data.indexer = $el.data("indexer");
|
||||
data.name = $el.data("name")
|
||||
return;
|
||||
case "inputstring":
|
||||
data.config[id] = $el.find(".setup-item-inputstring").val();
|
||||
break;
|
||||
case "inputbool":
|
||||
data.config[id] = $el.find(".setup-item-checkbox").val();
|
||||
break;
|
||||
}
|
||||
});
|
||||
sendSetupData(data.indexer, data.name, data);
|
||||
});
|
||||
|
||||
function sendSetupData(indexerId, name, data) {
|
||||
var jqxhr = $.post("configure_indexer", JSON.stringify(data), function (data) {
|
||||
if (data.result == "error") {
|
||||
if (data.config) {
|
||||
populateSetupForm(indexerId, name, data.config);
|
||||
}
|
||||
alert(data.error);
|
||||
}
|
||||
else {
|
||||
$("#setup-indexer-modal").modal("hide");
|
||||
reloadIndexers();
|
||||
alert("success");
|
||||
}
|
||||
}).fail(function () {
|
||||
alert("error");
|
||||
});
|
||||
}
|
||||
|
||||
reloadIndexers();
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 28 KiB |
Loading…
Reference in New Issue