Feature/plugin v2 (#284)

* Add "title_title" to slim JSON

* WIP

* WIP

* WIP

* WIP

* Add plugin subscription types

* Revert "Subscription manager"

This reverts commit a612500b0fabf7259a5ee0c841b0157d191e5bdd.

* Use auto overflow tables

cherry-picked from a612500b0fabf7259a5ee0c841b0157d191e5bdd

* Add endpoint for plugin subscription

* WIP

* WIP

* Simplify subscription JSON parsing

* Remove MangaDex files that are no longer needed

* Fix linter

* Refactor date filtering and use native date picker

* Delete unnecessary raise for debugging

* Subscription management API endpoints

* Store manga ID with subscriptions

* Add subscription manager page (WIP)

* Finish subscription manager page

* WIP

* Finish plugin updater

* Base64 encode chapter IDs

* Fix actions on download manager

* Trigger subscription update from manager page

* Fix timestamp precision issue in plugin

* Show target API version

* Update last checked from manager page

* Update last checked even when no chapters found

* Fix null pid

* Clean up

* Document the subscription endpoints

* Fix BigFloat conversion issue

* Confirmation before deleting subscriptions

* Reset table sort options

* Show manga title on subscription manager
This commit is contained in:
Alex Ling 2022-03-22 16:30:01 +08:00 committed by GitHub
parent 1817efe608
commit 461398d219
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1473 additions and 568 deletions

View File

@ -1,144 +1,446 @@
const loadPlugin = id => {
localStorage.setItem('plugin', id);
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
plugin: id
})}`;
window.location.href = newURL;
};
const component = () => {
return {
plugins: [],
info: undefined,
pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty
mid: undefined, // id of the selected manga
allChapters: [],
query: "",
mangaTitle: "",
searching: false,
adding: false,
sortOptions: [],
showFilters: false,
appliedFilters: [],
chaptersLimit: 500,
listManga: false,
subscribing: false,
subscriptionName: "",
$(() => {
var storedID = localStorage.getItem('plugin');
if (storedID && storedID !== pid) {
loadPlugin(storedID);
} else {
$('#controls').removeAttr('hidden');
}
init() {
const tableObserver = new MutationObserver(() => {
console.log("table mutated");
$("#selectable").selectable({
filter: "tr",
});
});
tableObserver.observe($("table").get(0), {
childList: true,
subtree: true,
});
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('#plugin-select').val(pid);
$('#plugin-select').change(() => {
const id = $('#plugin-select').val();
loadPlugin(id);
});
});
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid);
let mangaTitle = "";
let searching = false;
const search = () => {
if (searching)
return;
if (this.plugins.length > 0)
this.loadPlugin(this.plugins[0].id);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
loadPlugin(pid) {
fetch(
`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info;
this.pid = pid;
})
.catch((e) => {
alert(
"danger",
`Failed to get plugin metadata. Error: ${e}`
);
});
},
pluginChanged() {
this.loadPlugin(this.pid);
localStorage.setItem("plugin", this.pid);
},
get chapterKeys() {
if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title"].includes(k)
);
},
searchChapters(query) {
this.searching = true;
this.allChapters = [];
this.sortOptions = [];
this.chapters = undefined;
this.listManga = false;
fetch(
`${base_url}api/admin/plugin/list?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
const query = $.param({
query: $('#search-input').val(),
plugin: pid
});
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Search failed. Error: ${data.error}`);
this.allChapters = data.chapters;
this.chapters = data.chapters;
})
.catch((e) => {
alert("danger", `Failed to list chapters. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
searchManga(query) {
this.searching = true;
this.allChapters = [];
this.chapters = undefined;
this.manga = undefined;
fetch(
`${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.manga = data.manga;
this.listManga = true;
})
.catch((e) => {
alert("danger", `Search failed. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
search() {
const query = this.query.trim();
if (!query) return;
this.manga = undefined;
if (this.info.version === 1) {
this.searchChapters(query);
} else {
this.searchManga(query);
}
},
selectAll() {
$("tbody > tr").each((i, e) => {
$(e).addClass("ui-selected");
});
},
clearSelection() {
$("tbody > tr").each((i, e) => {
$(e).removeClass("ui-selected");
});
},
download() {
const selected = $("tbody > tr.ui-selected").get();
if (selected.length === 0) return;
UIkit.modal
.confirm(`Download ${selected.length} selected chapters?`)
.then(() => {
const ids = selected.map((e) => e.id);
const chapters = this.chapters.filter((c) =>
ids.includes(c.id)
);
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: "POST",
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert(
"success",
`${successCount} of ${
successCount + failCount
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
);
})
.catch((e) => {
alert(
"danger",
`Failed to add chapters to the download queue. Error: ${e}`
);
})
.finally(() => {
this.adding = false;
});
});
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split("-")[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option);
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log("initial size:", ary.length);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === "array" && filter.value === "all") continue;
if (filter.type.startsWith("number") && isNaN(filter.value))
continue;
if (filter.type === "string") {
ary = ary.filter((ch) =>
ch[filter.key]
.toLowerCase()
.includes(filter.value.toLowerCase())
);
}
if (filter.type === "number-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "number-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "date-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "date-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "array") {
ary = ary.filter((ch) =>
ch[filter.key]
.map((s) =>
typeof s === "string" ? s.toLowerCase() : s
)
.includes(filter.value.toLowerCase())
);
}
console.log("filtered size:", ary.length);
}
return ary;
},
// option:
// - 1: asending
// - -1: desending
// - 0: unsorted
sort(key, option) {
if (option === 0) {
this.chapters = this.filteredChapters;
return;
}
mangaTitle = data.title;
$('#title-text').text(data.title);
buildTable(data.chapters);
})
.fail((jqXHR, status) => {
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {});
};
const buildTable = (chapters) => {
$('#table').attr('hidden', '');
$('table').empty();
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
const thead = `<thead><tr>${keys}</tr></thead>`;
$('table').append(thead);
const rows = chapters.map(ch => {
const tds = Object.values(ch).map(v => {
const maxLength = 40;
const shouldShrink = v && v.length > maxLength;
const content = shouldShrink ? `<span title="${v}">${v.substring(0, maxLength)}...</span><div uk-dropdown><span>${v}</span></div>` : v;
return `<td>${content}</td>`
}).join('');
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
});
const tbody = `<tbody id="selectable">${rows}</tbody>`;
$('table').append(tbody);
$('#selectable').selectable({
filter: 'tr'
});
$('#table table').tablesorter();
$('#table').removeAttr('hidden');
};
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const chapters = selected.map((i, e) => {
return {
id: $(e).attr('data-id'),
title: $(e).attr('data-title')
}
}).get();
console.log(chapters);
$.ajax({
type: 'POST',
url: base_url + 'api/admin/plugin/download',
data: JSON.stringify({
plugin: pid,
chapters: chapters,
title: mangaTitle
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp;
});
});
},
compare(a, b) {
if (a === b) return 0;
// try numbers (also covers dates)
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
const preprocessString = (val) => {
if (typeof val !== "string") return val;
return val.toLowerCase().replace(/\s\s/g, " ").trim();
};
return preprocessString(a) > preprocessString(b) ? 1 : -1;
},
fieldType(values) {
if (values.every((v) => this.numIsDate(v))) return "date";
if (values.every((v) => !isNaN(v))) return "number";
if (values.every((v) => Array.isArray(v))) return "array";
return "string";
},
get filters() {
if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title", "id"].includes(k)
);
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values);
if (type === "array") {
// if the type is an array, return the list of available elements
// example: an array of groups or authors
values = Array.from(
new Set(
values.flat().map((v) => {
if (typeof v === "string")
return v.toLowerCase();
})
)
);
}
return {
key: k,
type: type,
values: values,
};
});
},
get filterSettings() {
return $("#filter-form input:visible, #filter-form select:visible")
.get()
.map((i) => {
const type = i.getAttribute("data-filter-type");
let value = i.value.trim();
if (type.startsWith("date"))
value = value ? Date.parse(value).toString() : "";
return {
key: i.getAttribute("data-filter-key"),
value: value,
type: type,
};
});
},
applyFilters() {
this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
clearFilters() {
$("#filter-form input")
.get()
.forEach((i) => (i.value = ""));
$("#filter-form select").val("all");
this.appliedFilters = [];
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute("data-id");
this.mid = mid;
this.searchChapters(mid);
},
subscribe(modal) {
this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: "POST",
body: JSON.stringify({
filters: this.filterSettings,
plugin: this.pid,
name: this.subscriptionName.trim(),
manga: this.mangaTitle,
manga_id: this.mid,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert("success", "Subscription created");
})
.catch((e) => {
alert("danger", `Failed to subscribe. Error: ${e}`);
})
.finally(() => {
this.subscribing = false;
UIkit.modal(modal).hide();
});
},
numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format(
"MMM D, YYYY"
)}</span>`;
const maxLength = 40;
if (value && value.length > maxLength)
return `<span>${value.substr(
0,
maxLength
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
};
};

View File

@ -0,0 +1,147 @@
const component = () => {
return {
subscriptions: [],
plugins: [],
pid: undefined,
subscription: undefined, // selected subscription
loading: false,
init() {
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
this.pid = pid;
else if (this.plugins.length > 0)
this.pid = this.plugins[0].id;
this.list(pid);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
pluginChanged() {
localStorage.setItem("plugin", this.pid);
this.list(this.pid);
},
list(pid) {
if (!pid) return;
fetch(
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
{
plugin: pid,
}
)}`,
{
method: "GET",
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.subscriptions = data.subscriptions;
})
.catch((e) => {
alert(
"danger",
`Failed to list subscriptions. Error: ${e}`
);
});
},
renderStrCell(str) {
const maxLength = 40;
if (str.length > maxLength)
return `<td><span>${str.substring(
0,
maxLength
)}...</span><div uk-dropdown>${str}</div></td>`;
return `<td>${str}</td>`;
},
renderDateCell(timestamp) {
return `<td>${moment
.duration(moment.unix(timestamp).diff(moment()))
.humanize(true)}</td>`;
},
selected(event, modal) {
const id = event.currentTarget.getAttribute("sid");
this.subscription = this.subscriptions.find((s) => s.id === id);
UIkit.modal(modal).show();
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
actionHandler(event, type) {
const id = $(event.currentTarget).closest("tr").attr("sid");
if (type !== 'delete') return this.action(id, type);
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
labels: {
ok: 'Yes, delete it',
cancel: 'Cancel'
}
}).then(() => {
this.action(id, type);
});
},
action(id, type) {
if (this.loading) return;
this.loading = true;
fetch(
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
{
plugin: this.pid,
subscription: id,
}
)}`,
{
method: type === 'delete' ? "DELETE" : 'POST'
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
if (type === 'update')
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
})
.catch((e) => {
alert(
"danger",
`Failed to ${type} subscription. Error: ${e}`
);
})
.finally(() => {
this.loading = false;
this.list(this.pid);
});
},
};
};

View File

@ -25,6 +25,7 @@ class Config
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property plugin_update_interval_hours : Int32 = 24
@@singlet : Config?

View File

@ -59,6 +59,7 @@ class Entry
json.field {{str}}, @{{str.id}}
{% end %}
json.field "title_id", @book.id
json.field "title_title", @book.title
json.field "sort_title", sort_title
json.field "pages" { json.number @pages }
unless slim

View File

@ -61,6 +61,7 @@ class CLI < Clim
Library.load_instance
Library.default
Plugin::Downloader.default
Plugin::Updater.default
spawn do
begin

View File

@ -2,6 +2,8 @@ require "duktape/runtime"
require "myhtml"
require "xml"
require "./subscriptions"
class Plugin
class Error < ::Exception
end
@ -16,12 +18,19 @@ class Plugin
end
struct Info
include JSON::Serializable
{% for name in ["id", "title", "placeholder"] %}
getter {{name.id}} = ""
{% end %}
getter wait_seconds : UInt64 = 0
getter wait_seconds = 0u64
getter version = 0u64
getter settings = {} of String => String?
getter dir : String
@[JSON::Field(ignore: true)]
@json : JSON::Any
def initialize(@dir)
info_path = File.join @dir, "info.json"
@ -37,6 +46,16 @@ class Plugin
@{{name.id}} = @json[{{name}}].as_s
{% end %}
@wait_seconds = @json["wait_seconds"].as_i.to_u64
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
settings_hash.each do |k, v|
unless str_value = v.as_s?
raise "The settings object can only contain strings or null"
end
@settings[k] = str_value
end
end
unless @id.alphanumeric_underscore?
raise "Plugin ID can only contain alphanumeric characters and " \
@ -114,6 +133,33 @@ class Plugin
@info.not_nil!
end
def subscribe(subscription : Subscription)
list = SubscriptionList.new info.dir
list << subscription
list.save
end
def list_subscriptions
SubscriptionList.new(info.dir).ary
end
def list_subscriptions_raw
SubscriptionList.new(info.dir)
end
def unsubscribe(id : String)
list = SubscriptionList.new info.dir
list.reject! &.id.== id
list.save
end
def check_subscription(id : String)
list = list_subscriptions_raw
sub = list.find &.id.== id
Plugin::Updater.default.check_subscription self, sub.not_nil!
list.save
end
def initialize(id : String)
Plugin.build_info_ary
@ -138,6 +184,12 @@ class Plugin
sbx.push_string path
sbx.put_prop_string -2, "storage_path"
sbx.push_pointer info.dir.as(Void*)
path = sbx.require_pointer(-1).as String
sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "info_dir"
def_helper_functions sbx
end
@ -152,23 +204,67 @@ class Plugin
{% end %}
end
def assert_manga_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s
rescue e
raise Error.new "Missing required fields in the Manga type"
end
def assert_chapter_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
obj["manga_title"].as_s
rescue e
raise Error.new "Missing required fields in the Chapter type"
end
def assert_page_type(obj : JSON::Any)
obj["url"].as_s && obj["filename"].as_s
rescue e
raise Error.new "Missing required fields in the Page type"
end
def search_manga(query : String)
if info.version == 1
raise Error.new "Manga searching is only available for plugins " \
"targeting API v2 or above"
end
json = eval_json "searchManga('#{query}')"
begin
json.as_a.each do |obj|
assert_manga_type obj
end
rescue e
raise Error.new e.message
end
json
end
def list_chapters(query : String)
json = eval_json "listChapters('#{query}')"
begin
check_fields ["title", "chapters"]
ary = json["chapters"].as_a
ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
if info.version > 1
# Since v2, listChapters returns an array
json.as_a.each do |obj|
assert_chapter_type obj
end
else
check_fields ["title", "chapters"]
title = obj["title"]?
raise "Field `title` missing from `listChapters` outputs" if title.nil?
ary = json["chapters"].as_a
ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
end
title = obj["title"]?
if title.nil?
raise "Field `title` missing from `listChapters` outputs"
end
end
end
rescue e
raise Error.new e.message
@ -179,10 +275,14 @@ class Plugin
def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')"
begin
check_fields ["title", "pages"]
if info.version > 1
assert_chapter_type json
else
check_fields ["title", "pages"]
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
end
end
rescue e
raise Error.new e.message
@ -194,7 +294,21 @@ class Plugin
json = eval_json "nextPage()"
return if json.size == 0
begin
check_fields ["filename", "url"]
assert_page_type json
rescue e
raise Error.new e.message
end
json
end
def new_chapters(manga_id : String, after : Int64)
# Converting standard timestamp to milliseconds so plugins can easily do
# `new Date(ms_timestamp)` in JS.
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
begin
json.as_a.each do |obj|
assert_chapter_type obj
end
rescue e
raise Error.new e.message
end
@ -379,6 +493,27 @@ class Plugin
end
sbx.put_prop_string -2, "storage"
if info.version > 1
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
key = env.require_string 0
env.get_global_string "info_dir"
info_dir = env.require_string -1
env.pop
info = Info.new info_dir
if value = info.settings[key]?
env.push_string value
else
env.push_undefined
end
env.call_success
end
sbx.put_prop_string -2, "settings"
end
sbx.put_prop_string -2, "mango"
end
end

115
src/plugin/subscriptions.cr Normal file
View File

@ -0,0 +1,115 @@
require "uuid"
require "big"
enum FilterType
String
NumMin
NumMax
DateMin
DateMax
Array
def self.from_string(str)
case str
when "string"
String
when "number-min"
NumMin
when "number-max"
NumMax
when "date-min"
DateMin
when "date-max"
DateMax
when "array"
Array
else
raise "Unknown filter type with string #{str}"
end
end
end
struct Filter
include JSON::Serializable
property key : String
property value : String | Int32 | Int64 | Float32 | Nil
property type : FilterType
def initialize(@key, @value, @type)
end
def self.from_json(str) : Filter
json = JSON.parse str
key = json["key"].as_s
type = FilterType.from_string json["type"].as_s
_value = json["value"]
value = _value.as_s? || _value.as_i? || _value.as_i64? ||
_value.as_f32? || nil
self.new key, value, type
end
def match_chapter(obj : JSON::Any) : Bool
return true if value.nil? || value.to_s.empty?
raw_value = obj[key]
case type
when FilterType::String
raw_value.as_s.downcase == value.to_s.downcase
when FilterType::NumMin, FilterType::DateMin
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
when FilterType::NumMax, FilterType::DateMax
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
when FilterType::Array
return true if value == "all"
raw_value.as_s.downcase.split(",")
.map(&.strip).includes? value.to_s.downcase.strip
else
false
end
end
end
# We use class instead of struct so we can update `last_checked` from
# `SubscriptionList`
class Subscription
include JSON::Serializable
property id : String
property plugin_id : String
property manga_id : String
property manga_title : String
property name : String
property created_at : Int64
property last_checked : Int64
property filters = [] of Filter
def initialize(@plugin_id, @manga_id, @manga_title, @name)
@id = UUID.random.to_s
@created_at = Time.utc.to_unix
@last_checked = Time.utc.to_unix
end
def match_chapter(obj : JSON::Any) : Bool
filters.all? &.match_chapter(obj)
end
end
struct SubscriptionList
@dir : String
@path : String
getter ary = [] of Subscription
forward_missing_to @ary
def initialize(@dir)
@path = Path[@dir, "subscriptions.json"].to_s
if File.exists? @path
@ary = Array(Subscription).from_json File.read @path
end
end
def save
File.write @path, @ary.to_pretty_json
end
end

75
src/plugin/updater.cr Normal file
View File

@ -0,0 +1,75 @@
class Plugin
class Updater
use_default
def initialize
interval = Config.current.plugin_update_interval_hours
return if interval <= 0
spawn do
loop do
Plugin.list.map(&.["id"]).each do |pid|
check_updates pid
end
sleep interval.hours
end
end
end
def check_updates(plugin_id : String)
Logger.debug "Checking plugin #{plugin_id} for updates"
plugin = Plugin.new plugin_id
if plugin.info.version == 1
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
"Skipping update check"
return
end
subscriptions = plugin.list_subscriptions_raw
subscriptions.each do |sub|
check_subscription plugin, sub
end
subscriptions.save
rescue e
Logger.error "Error checking plugin #{plugin_id} for updates: " \
"#{e.message}"
end
def check_subscription(plugin : Plugin, sub : Subscription)
Logger.debug "Checking subscription #{sub.name} for updates"
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
.as_a.select do |chapter|
sub.match_chapter chapter
end
if matches.empty?
Logger.debug "No new chapters found."
sub.last_checked = Time.utc.to_unix
return
end
Logger.debug "Found #{matches.size} new chapters. " \
"Pushing to download queue"
jobs = matches.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
"", # manga_id
ch["title"].as_s,
sub.manga_title,
Queue::JobStatus::Pending,
Time.utc
)
}
inserted_count = Queue.default.push jobs
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
"to the download queue. Plugin ID #{plugin.info.id}, " \
"subscription name #{sub.name}"
if inserted_count != matches.size
Logger.error "Failed to add #{matches.size - inserted_count} " \
"chapters to download queue"
end
sub.last_checked = Time.utc.to_unix
rescue e
Logger.error "Error when checking updates for subscription " \
"#{sub.name}: #{e.message}"
end
end
end

View File

@ -70,7 +70,13 @@ class Queue
ary = @id.split("-")
if ary.size == 2
@plugin_id = ary[0]
@plugin_chapter_id = ary[1]
# This begin-rescue block is for backward compatibility. In earlier
# versions we didn't encode the chapter ID
@plugin_chapter_id = begin
Base64.decode_string ary[1]
rescue
ary[1]
end
end
end

View File

@ -69,6 +69,10 @@ struct AdminRouter
layout "download-manager"
end
get "/admin/subscriptions" do |env|
layout "subscription-manager"
end
get "/admin/missing" do |env|
layout "missing-items"
end

View File

@ -56,6 +56,23 @@ struct APIRouter
"error" => String?,
}
Koa.schema "filter", {
"key" => String,
"type" => String,
"value" => String | Int32 | Int64 | Float32,
}
Koa.schema "subscription", {
"id" => String,
"plugin_id" => String,
"manga_id" => String,
"manga_title" => String,
"name" => String,
"created_at" => Int64,
"last_checked" => Int64,
"filters" => ["filter"],
}
Koa.describe "Authenticates a user", <<-MD
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
MD
@ -567,6 +584,209 @@ struct APIRouter
end
end
Koa.describe "Returns a list of available plugins"
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"plugins" => [{
"id" => String,
"title" => String,
}],
}
get "/api/admin/plugin" do |env|
begin
send_json env, {
"success" => true,
"plugins" => Plugin.list,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the metadata of a plugin"
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"info" => {
"dir" => String,
"id" => String,
"title" => String,
"placeholder" => String,
"wait_seconds" => Int32,
"version" => Int32,
"settings" => {} of String => String,
},
}
get "/api/admin/plugin/info" do |env|
begin
plugin = Plugin.new env.params.query["plugin"].as String
send_json env, {
"success" => true,
"info" => plugin.info,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
Only available for plugins targeting API v2 or above.
MD
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.query "query", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"manga" => [{
"id" => String,
"title" => String,
}],
}
get "/api/admin/plugin/search" do |env|
begin
query = env.params.query["query"].as String
plugin = Plugin.new env.params.query["plugin"].as String
manga_ary = plugin.search_manga(query).as_a
send_json env, {
"success" => true,
"manga" => manga_ary,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Creates a new subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"manga" => String,
"manga_id" => String,
"name" => String,
"filters" => ["filter"],
}
Koa.response 200, schema: "result"
post "/api/admin/plugin/subscriptions" do |env|
begin
plugin_id = env.params.json["plugin"].as String
manga_title = env.params.json["manga"].as String
manga_id = env.params.json["manga_id"].as String
filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f|
Filter.from_json f.to_json
end
name = env.params.json["name"].as String
sub = Subscription.new plugin_id, manga_id, manga_title, name
sub.filters = filters
plugin = Plugin.new plugin_id
plugin.subscribe sub
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the list of subscriptions for a plugin"
Koa.tags ["admin", "downloader", "subscription"]
Koa.query "plugin", desc: "The ID of the plugin"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"subscriptions" => ["subscription"],
}
get "/api/admin/plugin/subscriptions" do |env|
begin
pid = env.params.query["plugin"].as String
send_json env, {
"success" => true,
"subscriptions" => Plugin.new(pid).list_subscriptions,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"subscription" => String,
}
Koa.response 200, schema: "result"
delete "/api/admin/plugin/subscriptions" do |env|
begin
pid = env.params.query["plugin"].as String
sid = env.params.query["subscription"].as String
Plugin.new(pid).unsubscribe sid
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Checks for updates for a subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"subscription" => String,
}
Koa.response 200, schema: "result"
post "/api/admin/plugin/subscriptions/update" do |env|
pid = env.params.query["plugin"].as String
sid = env.params.query["subscription"].as String
Plugin.new(pid).check_subscription sid
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Lists the chapters in a title from a plugin"
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
@ -575,8 +795,8 @@ struct APIRouter
"success" => Bool,
"error" => String?,
"chapters?" => [{
"id" => String,
"title" => String,
"id" => String,
"title?" => String,
}],
"title" => String?,
}
@ -586,8 +806,14 @@ struct APIRouter
plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query
chapters = json["chapters"]
title = json["title"]
if plugin.info.version == 1
chapters = json["chapters"]
title = json["title"]
else
chapters = json
title = nil
end
send_json env, {
"success" => true,
@ -625,7 +851,7 @@ struct APIRouter
jobs = chapters.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{ch["id"]}",
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
"", # manga_id
ch["title"].as_s,
manga_title,

View File

@ -80,16 +80,6 @@ struct MainRouter
get "/download/plugins" do |env|
begin
id = env.params.query["plugin"]?
plugins = Plugin.list
plugin = nil
if id
plugin = Plugin.new id
elsif !plugins.empty?
plugin = Plugin.new plugins[0][:id]
end
layout "plugin-download"
rescue e
Logger.error e

View File

@ -1,83 +0,0 @@
require "db"
require "json"
struct Subscription
include DB::Serializable
include JSON::Serializable
getter id : Int64 = 0
getter username : String
getter manga_id : Int64
property language : String?
property group_id : Int64?
property min_volume : Int64?
property max_volume : Int64?
property min_chapter : Int64?
property max_chapter : Int64?
@[DB::Field(key: "last_checked")]
@[JSON::Field(key: "last_checked")]
@raw_last_checked : Int64
@[DB::Field(key: "created_at")]
@[JSON::Field(key: "created_at")]
@raw_created_at : Int64
def last_checked : Time
Time.unix @raw_last_checked
end
def created_at : Time
Time.unix @raw_created_at
end
def initialize(@manga_id, @username)
@raw_created_at = Time.utc.to_unix
@raw_last_checked = Time.utc.to_unix
end
private def in_range?(value : String, lowerbound : Int64?,
upperbound : Int64?) : Bool
lb = lowerbound.try &.to_f64
ub = upperbound.try &.to_f64
return true if lb.nil? && ub.nil?
v = value.to_f64?
return false unless v
if lb.nil?
v <= ub.not_nil!
elsif ub.nil?
v >= lb.not_nil!
else
v >= lb.not_nil! && v <= ub.not_nil!
end
end
def match?(chapter : MangaDex::Chapter) : Bool
if chapter.manga_id != manga_id ||
(language && chapter.language != language) ||
(group_id && !chapter.groups.map(&.id).includes? group_id)
return false
end
in_range?(chapter.volume, min_volume, max_volume) &&
in_range?(chapter.chapter, min_chapter, max_chapter)
end
def check_for_updates : Int32
Logger.debug "Checking updates for subscription with ID #{id}"
jobs = [] of Queue::Job
get_client(username).user.updates_after last_checked do |chapter|
next unless match? chapter
jobs << chapter.to_job
end
Storage.default.update_subscription_last_checked id
count = Queue.default.push jobs
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
count
rescue e
Logger.error "Error occurred when checking updates for " \
"subscription with ID #{id}. #{e}"
0
end
end

View File

@ -1,162 +0,0 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div x-data="downloadComponent()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-width-expand">
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-auto">
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
</div>
</div>
<template x-if="mangaAry">
<div>
<p x-show="mangaAry.length === 0">No matching manga found.</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<template x-for="manga in mangaAry" :key="manga.id">
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="manga.mainCover">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div x-show="data && data.chapters" x-cloak>
<div class"uk-grid-small" uk-grid>
<div class="uk-width-1-4@s">
<img :src="data.mainCover">
</div>
<div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<p x-text="`Artist: ${data.artist}`"></p>
<p x-text="`Author: ${data.author}`"></p>
</div>
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<div class="uk-margin">
<label class="uk-form-label">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<template x-for="lang in languages" :key="lang">
<option x-text="lang"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div>
</div>
<div class="uk-margin">
<div class="uk-margin">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
<div id="modal" class="uk-flex-top" uk-modal="container: false">
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
</div>
<div class="uk-modal-body">
<div class="uk-grid">
<div class="uk-width-1-3@s">
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
</div>
<div class="uk-width-2-3@s" uk-overflow-auto>
<p x-text="candidateManga.description"></p>
</div>
</div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<%= render_component "jquery-ui" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %>

View File

@ -19,6 +19,7 @@
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
</ul>
</li>
<% end %>
@ -51,6 +52,7 @@
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
</ul>
</div>
</li>

View File

@ -1,39 +0,0 @@
<div x-data="component()" x-init="init()">
<h2 class="uk-title">Connect to MangaDex</h2>
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
<div class="uk-width-1-2@s" x-show="!expires">
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
<ul>
<li>Search MangaDex by search terms in addition to manga IDs</li>
<li>Automatically download new chapters when they are available (coming soon)</li>
</ul>
</div>
<div class="uk-width-1-2@s" x-show="expires">
<p>
<span x-show="!expired">You have logged in to MangaDex!</span>
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
<span x-show="!expired">If the integration is not working, you</span>
<span x-show="expired">You</span>
can log in again and the token will be updated.
</p>
</div>
<div class="uk-width-1-2@s">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/mangadex.js"></script>
<% end %>

View File

@ -1,77 +1,214 @@
<% if plugins.empty? %>
<div class="uk-container uk-text-center">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<% else %>
<h2 class=uk-title>Download with Plugins</h2>
<div id="controls" class="uk-grid-small" uk-grid hidden>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls">
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
</div>
</div>
<div x-data="component()" x-init="init()" x-cloak>
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
<div class="uk-form-controls">
<select id="plugin-select" class="uk-select">
<% plugins.each do |p| %>
<option value="<%= p[:id] %>"><%= p[:title] %></option>
<% end %>
</select>
<div x-show="plugins.length > 0" style="width:100%">
<h2 class=uk-title>Download with Plugins
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
</h2>
<template x-if="info !== undefined">
<div>
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<div class="uk-form-controls">
<label class="uk-form-label">&nbsp;</label>
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
</div>
</div>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</div>
</div>
</div>
</div>
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
<dl class="uk-description-list" id="toggle" hidden>
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
<dd x-text="entry[1]"></dd>
</dl>
</template>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</template>
<template x-if="manga">
<div class="uk-margin">
<p x-show="manga.length === 0">No matching manga found.</p>
<p x-show="manga.length > 0">
<span x-text="`${manga.length} manga found`"></span>
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
<template x-for="m in manga" :key="m.id">
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="m.cover_url">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div class="uk-margin-large-top" x-show="chapters !== undefined">
<h3 x-text="mangaTitle"></h3>
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
<div class="uk-margin">
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
</div>
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
</div>
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
<template x-for="field in filters">
<div class="uk-margin">
<label class="uk-form-label">
<span x-text="field.key"></span>
<template x-if="field.type === 'number'">
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
</template>
</label>
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
</div>
</div>
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-max">
</div>
</div>
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
<option value="all">All</option>
<template x-for="v in field.values" :key="v">
<option x-text="v" :value="v"></option>
</template>
</select>
</div>
</template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<template x-for="(k, idx) in chapterKeys" :key="k">
<th :id="`th-${idx}`" @click="thClicked($event)">
<span x-text="k"></span>
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
</th>
</template>
</tr>
</thead>
<tbody id="selectable">
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
<template x-for="ch in chapters" :key="ch">
<tr class="ui-widget-content" :id="ch.id">
<template x-for="k in chapterKeys" :key="k">
<td x-html="renderCell(ch[k])"></td>
</template>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<dl class="uk-description-list" id="toggle" hidden>
<% plugin.not_nil!.info.each do |k, v| %>
<dt><%= k %></dt>
<dd><%= v.to_s %></dd>
<% end %>
</dl>
<div id="table" class="uk-margin-large-top" hidden>
<h3 id="title-text"></h3>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped tablesorter">
</table>
<div uk-modal="container:false" x-ref="modal">
<div class="uk-modal-dialog">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Confirmation</h2>
</div>
<div class="uk-modal-body">
<p>A subscription with the following filters with be created. All <strong>FUTURE</strong> chapters matching the filters will be automatically downloaded.</p>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in filterSettings" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p>Enter a meaningful name for the subscription to continue:</p>
<input class="uk-input" type="text" x-model="subscriptionName">
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
<button class="uk-button uk-button-primary" type="button" :disabled="subscriptionName.trim().length === 0" @click="subscribe($refs.modal)">Confirm</button>
</div>
</div>
</div>
<% end %>
</div>
<% content_for "script" do %>
<% if plugin %>
<script>
var pid = "<%= plugin.not_nil!.info.id %>";
</script>
<% end %>
<%= render_component "jquery-ui" %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script>
<% end %>

View File

@ -0,0 +1,101 @@
<h2 class=uk-title>Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<div x-show="plugins.length > 0" style="width:100%">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
<p x-show="subscriptions.length === 0" class="uk-text-meta">No subscriptions found.</p>
<div class="uk-overflow-auto" x-show="subscriptions.length > 0">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Name</th>
<th>Plugin ID</th>
<th>Manga Title</th>
<th>Created At</th>
<th>Last Checked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr :sid="sub.id" @click="selected($event, $refs.modal)">
<td x-html="renderStrCell(sub.name)"></td>
<td x-html="renderStrCell(sub.plugin_id)"></td>
<td x-html="renderStrCell(sub.manga_title)"></td>
<td x-html="renderDateCell(sub.created_at)"></td>
<td x-html="renderDateCell(sub.last_checked)"></td>
<td>
<a @click.prevent.stop="actionHandler($event, 'delete')" uk-icon="trash" uk-tooltip="Delete" :disabled="loading"></a>
<a @click.prevent.stop="actionHandler($event, 'update')" uk-icon="refresh" uk-tooltip="Check for updates" :disabled="loading"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div uk-modal="container:false" x-ref="modal" class="uk-flex-top">
<div class="uk-modal-dialog uk-margin-auto-vertical uk-overflow-auto">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Details</h2>
</div>
<div class="uk-modal-body">
<dl>
<dt>Name</dt>
<dd x-html="subscription && subscription.name"></dd>
<dt>Subscription ID</dt>
<dd x-html="subscription && subscription.id"></dd>
<dt>Plugin ID</dt>
<dd x-html="subscription && subscription.plugin_id"></dd>
<dt>Manga Title</dt>
<dd x-html="subscription && subscription.manga_title"></dd>
<dt>Manga ID</dt>
<dd x-html="subscription && subscription.manga_id"></dd>
<dt>Filters</dt>
</dl>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in (subscription && subscription.filters || [])" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p class="uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">OK</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription-manager.js"></script>
<% end %>

View File

@ -1,54 +0,0 @@
<h2 class="uk-title">MangaDex Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
<template x-if="subscriptions.length > 0">
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Manga ID</th>
<th>Language</th>
<th>Group ID</th>
<th>Volume Range</th>
<th>Chapter Range</th>
<th>Creator</th>
<th>Last Checked</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr>
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
<td x-text="sub.language || 'All'"></td>
<td>
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
<span x-show="!sub.group_id">All</span>
</td>
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
<td x-text="sub.username"></td>
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
<td :data-id="sub.id">
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription.js"></script>
<% end %>