diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js
index 11c047ce..2e9d0a0b 100644
--- a/public/js/plugin-download.js
+++ b/public/js/plugin-download.js
@@ -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: "",
+
+ 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;
+
+ const pid = localStorage.getItem("plugin");
+ if (pid && this.plugins.map((p) => p.id).includes(pid))
+ return this.loadPlugin(pid);
+
+ 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;
+ }
+
+ 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 download manager page .`
+ );
+ })
+ .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);
+ }
-$(() => {
- var storedID = localStorage.getItem('plugin');
- if (storedID && storedID !== pid) {
- loadPlugin(storedID);
- } else {
- $('#controls').removeAttr('hidden');
- }
-
- $('#search-input').keypress(event => {
- if (event.which === 13) {
- search();
- }
- });
- $('#plugin-select').val(pid);
- $('#plugin-select').change(() => {
- const id = $('#plugin-select').val();
- loadPlugin(id);
- });
-});
-
-let mangaTitle = "";
-let searching = false;
-const search = () => {
- if (searching)
- return;
-
- 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}`);
+ 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();
+ 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;
- const keys = Object.keys(chapters[0]).map(k => `
${k} `).join('');
- const thead = `${keys} `;
- $('table').append(thead);
+ // try numbers (also covers dates)
+ if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
- const rows = chapters.map(ch => {
- const tds = Object.values(ch).map(v => {
- const maxLength = 40;
- const shouldShrink = v && v.length > maxLength;
- const content = shouldShrink ? `${v.substring(0, maxLength)}... ${v}
` : v;
- return `${content} `
- }).join('');
- return `${tds} `;
- });
- const tbody = `${rows} `;
- $('table').append(tbody);
-
- $('#selectable').selectable({
- filter: 'tr'
- });
-
- $('#table table').tablesorter();
- $('#table').removeAttr('hidden');
-};
+ const preprocessString = (val) => {
+ if (typeof val !== "string") return val;
+ return val.toLowerCase().replace(/\s\s/g, " ").trim();
+ };
-const selectAll = () => {
- $('tbody > tr').each((i, e) => {
- $(e).addClass('ui-selected');
- });
-};
+ 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);
-const unselect = () => {
- $('tbody > tr').each((i, e) => {
- $(e).removeClass('ui-selected');
- });
-};
+ 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();
+ })
+ )
+ );
+ }
-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
+ 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,
}),
- 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 download manager page .`);
- })
- .fail((jqXHR, status) => {
- alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ headers: {
+ "Content-Type": "application/json",
+ },
})
- .always(() => {
- $('#download-spinner').attr('hidden', '');
- $('#download-btn').removeAttr('hidden');
- });
- });
+ .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 `${moment(Number(value)).format(
+ "MMM D, YYYY"
+ )} `;
+ const maxLength = 40;
+ if (value && value.length > maxLength)
+ return `${value.substr(
+ 0,
+ maxLength
+ )}... ${value}
`;
+ return `${value} `;
+ },
+ 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 `${key} ${type} ${value} `;
+ },
+ };
};
diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js
new file mode 100644
index 00000000..fad4e56c
--- /dev/null
+++ b/public/js/subscription-manager.js
@@ -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 `${str.substring(
+ 0,
+ maxLength
+ )}... ${str}
`;
+ return `${str} `;
+ },
+ renderDateCell(timestamp) {
+ return `${moment
+ .duration(moment.unix(timestamp).diff(moment()))
+ .humanize(true)} `;
+ },
+ 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 `${key} ${type} ${value} `;
+ },
+ 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);
+ });
+ },
+ };
+};
diff --git a/src/config.cr b/src/config.cr
index b5b77dbf..807a74cb 100644
--- a/src/config.cr
+++ b/src/config.cr
@@ -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?
diff --git a/src/library/entry.cr b/src/library/entry.cr
index 55d0062c..18209207 100644
--- a/src/library/entry.cr
+++ b/src/library/entry.cr
@@ -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
diff --git a/src/mango.cr b/src/mango.cr
index 3cdafc05..14603b9e 100644
--- a/src/mango.cr
+++ b/src/mango.cr
@@ -61,6 +61,7 @@ class CLI < Clim
Library.load_instance
Library.default
Plugin::Downloader.default
+ Plugin::Updater.default
spawn do
begin
diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr
index 6bedea17..5175b3a0 100644
--- a/src/plugin/plugin.cr
+++ b/src/plugin/plugin.cr
@@ -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"]
+
+ 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
-
- title = obj["title"]?
- raise "Field `title` missing from `listChapters` outputs" if title.nil?
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
diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr
new file mode 100644
index 00000000..153667d7
--- /dev/null
+++ b/src/plugin/subscriptions.cr
@@ -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
diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr
new file mode 100644
index 00000000..81ba8c89
--- /dev/null
+++ b/src/plugin/updater.cr
@@ -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
diff --git a/src/queue.cr b/src/queue.cr
index 01cef38c..82fdedac 100644
--- a/src/queue.cr
+++ b/src/queue.cr
@@ -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
diff --git a/src/routes/admin.cr b/src/routes/admin.cr
index a63bc0eb..c3692c99 100644
--- a/src/routes/admin.cr
+++ b/src/routes/admin.cr
@@ -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
diff --git a/src/routes/api.cr b/src/routes/api.cr
index 413c318b..3e02a645 100644
--- a/src/routes/api.cr
+++ b/src/routes/api.cr
@@ -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,
diff --git a/src/routes/main.cr b/src/routes/main.cr
index ea2f0d8c..0e7bf341 100644
--- a/src/routes/main.cr
+++ b/src/routes/main.cr
@@ -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
diff --git a/src/subscription.cr b/src/subscription.cr
deleted file mode 100644
index e3913601..00000000
--- a/src/subscription.cr
+++ /dev/null
@@ -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
diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr
deleted file mode 100644
index 0ea85276..00000000
--- a/src/views/download.html.ecr
+++ /dev/null
@@ -1,162 +0,0 @@
-Download from MangaDex
-
-
-
-
-
-
No matching manga found.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Select All
-
Clear Selections
-
Download Selected
-
-
-
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.
-
-
-
-
-
- ID
- Title
- Language
- Group
- Volume
- Chapter
- Timestamp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
- <%= render_component "jquery-ui" %>
-
-
-<% end %>
diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr
index 24a036a4..7264ba74 100644
--- a/src/views/layout.html.ecr
+++ b/src/views/layout.html.ecr
@@ -19,6 +19,7 @@
<% end %>
@@ -51,6 +52,7 @@
Plugins
Download Manager
+ Subscription Manager
diff --git a/src/views/mangadex.html.ecr b/src/views/mangadex.html.ecr
deleted file mode 100644
index 764c4f4d..00000000
--- a/src/views/mangadex.html.ecr
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
Connect to MangaDex
-
-
-
This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:
-
- Search MangaDex by search terms in addition to manga IDs
- Automatically download new chapters when they are available (coming soon)
-
-
-
-
-
- You have logged in to MangaDex!
- You have logged in to MangaDex but the token has expired.
- The expiration date of your token is
.
- If the integration is not working, you
- You
- can log in again and the token will be updated.
-
-
-
-
-
-
-
Login to MangaDex
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
-
-
-<% end %>
diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr
index ece56b6f..7c3b4d55 100644
--- a/src/views/plugin-download.html.ecr
+++ b/src/views/plugin-download.html.ecr
@@ -1,77 +1,214 @@
-<% if plugins.empty? %>
-
-
No Plugins Found
-
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
-
You can download official plugins from the Mango plugins repository .
-
+
+
+
+
No Plugins Found
+
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
+
You can download official plugins from the Mango plugins repository .
+
-<% else %>
-
Download with Plugins
+
+
Download with Plugins
+
+
-
-
-
-
-
-
-
-
- <% plugin.not_nil!.info.each do |k, v| %>
- <%= k %>
- <%= v.to_s %>
- <% end %>
-
-
-
-
-
-
-
Select All
-
Clear Selections
-
Download Selected
-
-
-
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.
-
-
+
+
+
+
+
A subscription with the following filters with be created. All FUTURE chapters matching the filters will be automatically downloaded.
+
+
+
+ Key
+ Type
+ Value
+
+
+
+
+
+
+
+
+
Enter a meaningful name for the subscription to continue:
+
+
+
-<% end %>
-
+
<% content_for "script" do %>
- <% if plugin %>
-
- <% end %>
<%= render_component "jquery-ui" %>
-
+ <%= render_component "moment" %>
<% end %>
diff --git a/src/views/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr
new file mode 100644
index 00000000..45774c16
--- /dev/null
+++ b/src/views/subscription-manager.html.ecr
@@ -0,0 +1,101 @@
+
Subscription Manager
+
+
+
+
No Plugins Found
+
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
+
You can download official plugins from the Mango plugins repository .
+
+
+
+
+
Choose a plugin
+
+
+
+
+
+
+
+
+
+
No subscriptions found.
+
+
+
+
+
+ Name
+ Plugin ID
+ Manga Title
+ Created At
+ Last Checked
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+ Subscription ID
+
+ Plugin ID
+
+ Manga Title
+
+ Manga ID
+
+ Filters
+
+
+
+
+ Key
+ Type
+ Value
+
+
+
+
+
+
+
+
+
+ OK
+
+
+
+
+
+
+<% content_for "script" do %>
+ <%= render_component "moment" %>
+
+
+<% end %>
diff --git a/src/views/subscription.html.ecr b/src/views/subscription.html.ecr
deleted file mode 100644
index cc96c471..00000000
--- a/src/views/subscription.html.ecr
+++ /dev/null
@@ -1,54 +0,0 @@
-
MangaDex Subscription Manager
-
-
-
The subscription manager uses a MangaDex API that requires authentication. Please connect to MangaDex before using this feature.
-
-
No subscription found. Go to the MangaDex download page and start subscribing.
-
-
-
-
-
-
- Manga ID
- Language
- Group ID
- Volume Range
- Chapter Range
- Creator
- Last Checked
- Created At
- Actions
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
-
-
-<% end %>