From 23541f457e51e5f7a6330519d2fc224bac7251f0 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 15 May 2021 06:54:12 +0000 Subject: [PATCH 01/36] Add "title_title" to slim JSON --- src/library/entry.cr | 1 + 1 file changed, 1 insertion(+) diff --git a/src/library/entry.cr b/src/library/entry.cr index 92f4defc..f329c7a7 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -53,6 +53,7 @@ class Entry json.field {{str}}, @{{str.id}} {% end %} json.field "title_id", @book.id + json.field "title_title", @book.title json.field "pages" { json.number @pages } end end From a571d21cba0724c3ae55e5e2eaf996600265f905 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 15 May 2021 13:31:55 +0000 Subject: [PATCH 02/36] WIP --- src/plugin/plugin.cr | 71 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 6bedea17..28c756bb 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -19,7 +19,9 @@ class Plugin {% 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 def initialize(@dir) @@ -37,6 +39,16 @@ class Plugin @{{name.id}} = @json[{{name}}].as_s {% end %} @wait_seconds = @json["wait_seconds"].as_i.to_u64 + @version = @json["api_verson"]?.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 " \ @@ -152,23 +164,44 @@ class Plugin {% end %} end + def search_manga(query : String) + json = eval_json "searchManga('#{query}')" + begin + check_fields ["id", "title"] + rescue + raise Error.new e.message + end + json + end + def list_chapters(query : String) json = eval_json "listChapters('#{query}')" begin - check_fields ["title", "chapters"] + if info.version > 1 + # Since v2, listChapters returns an array + json.as_a.each do |obj| + {% for field in %w(id title pages manga_title) %} + unless obj[{{field}}]? + raise "Field `{{field.id}}` is required in the chapter objects" + end + {% end %} + 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? + 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 + unless id.to_s.alphanumeric_underscore? + raise "The `id` field can only contain alphanumeric characters " \ + "and underscores" + end - title = obj["title"]? - raise "Field `title` missing from `listChapters` outputs" if title.nil? + title = obj["title"]? + raise "Field `title` missing from `listChapters` outputs" if title.nil? + end end rescue e raise Error.new e.message @@ -379,6 +412,20 @@ class Plugin end sbx.put_prop_string -2, "storage" + sbx.push_proc 1 do |ptr| + env = Duktape::Sandbox.new ptr + key = env.require_string 0 + + if value = info.settings[key]? + env.push_string value + else + env.push_undefined + end + + env.call_success + end + sbx.put_prop_string -2, "settings" + sbx.put_prop_string -2, "mango" end end From e0713ccde8a32d4a52b67812589f853873526e71 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 22 May 2021 07:24:30 +0000 Subject: [PATCH 03/36] WIP --- public/js/plugin-download-2.js | 42 +++++++++++ src/plugin/plugin.cr | 101 +++++++++++++++++++++------ src/routes/api.cr | 91 ++++++++++++++++++++++++ src/routes/main.cr | 9 +++ src/views/plugin-download-2.html.ecr | 61 ++++++++++++++++ 5 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 public/js/plugin-download-2.js create mode 100644 src/views/plugin-download-2.html.ecr diff --git a/public/js/plugin-download-2.js b/public/js/plugin-download-2.js new file mode 100644 index 00000000..98744270 --- /dev/null +++ b/public/js/plugin-download-2.js @@ -0,0 +1,42 @@ +const component = () => { + return { + plugins: [], + info: undefined, + pid: undefined, + init() { + fetch(`${base_url}api/admin/plugin`) + .then(res => res.json()) + .then(data => { + if (!data.success) { + alert('danger', `Failed to list the available plugins. Error: ${data.error}`); + return; + } + 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); + }); + }, + loadPlugin(pid) { + fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({ + plugin: pid + })}`) + .then(res => res.json()) + .then(data => { + if (!data.success) { + alert('danger', `Failed to get plugin metadata. Error: ${data.error}`); + return; + } + this.info = data.info; + this.pid = pid; + }); + }, + pluginChanged() { + this.loadPlugin(this.pid); + } + }; +}; diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 28c756bb..f8cb1092 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -16,6 +16,8 @@ class Plugin end struct Info + include JSON::Serializable + {% for name in ["id", "title", "placeholder"] %} getter {{name.id}} = "" {% end %} @@ -24,6 +26,9 @@ class Plugin 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" @@ -150,6 +155,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 @@ -164,11 +175,36 @@ 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 - check_fields ["id", "title"] - rescue + json.as_a.each do |obj| + assert_manga_type obj + end + rescue e raise Error.new e.message end json @@ -180,11 +216,7 @@ class Plugin if info.version > 1 # Since v2, listChapters returns an array json.as_a.each do |obj| - {% for field in %w(id title pages manga_title) %} - unless obj[{{field}}]? - raise "Field `{{field.id}}` is required in the chapter objects" - end - {% end %} + assert_chapter_type obj end else check_fields ["title", "chapters"] @@ -200,7 +232,9 @@ class Plugin end title = obj["title"]? - raise "Field `title` missing from `listChapters` outputs" if title.nil? + if title.nil? + raise "Field `title` missing from `listChapters` outputs" + end end end rescue e @@ -212,10 +246,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 @@ -227,7 +265,19 @@ 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) + json = eval_json "newChapters('#{manga_id}', #{after})" + begin + json.as_a.each do |obj| + assert_chapter_type obj + end rescue e raise Error.new e.message end @@ -412,19 +462,26 @@ class Plugin end sbx.put_prop_string -2, "storage" - sbx.push_proc 1 do |ptr| - env = Duktape::Sandbox.new ptr - key = env.require_string 0 + if info.version > 1 + sbx.push_proc 1 do |ptr| + env = Duktape::Sandbox.new ptr + key = env.require_string 0 - if value = info.settings[key]? - env.push_string value - else - env.push_undefined - end + env.get_global_string "info_dir" + info_dir = env.require_string -1 + env.pop + info = Info.new info_dir - env.call_success + 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, "settings" sbx.put_prop_string -2, "mango" end diff --git a/src/routes/api.cr b/src/routes/api.cr index a66210ae..b1f07522 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -539,6 +539,97 @@ 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 "Lists the chapters in a title from a plugin" Koa.tags ["admin", "downloader"] Koa.query "plugin", schema: String diff --git a/src/routes/main.cr b/src/routes/main.cr index 54e3fbad..79d135c9 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -96,6 +96,15 @@ struct MainRouter end end + get "/download/plugins2" do |env| + begin + layout "plugin-download-2" + rescue e + Logger.error e + env.response.status_code = 500 + end + end + get "/download/subscription" do |env| mangadex_base_url = Config.current.mangadex["base_url"] username = get_username env diff --git a/src/views/plugin-download-2.html.ecr b/src/views/plugin-download-2.html.ecr new file mode 100644 index 00000000..a7033410 --- /dev/null +++ b/src/views/plugin-download-2.html.ecr @@ -0,0 +1,61 @@ +
+
+
+

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.

+
+
+

Download with Plugins

+ + +
+
+
+ +<% content_for "script" do %> + <%= render_component "moment" %> + <%= render_component "jquery-ui" %> + + +<% end %> From 87c479bf42c4ebbf6b00169dc7d5aa355f649dc6 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 30 May 2021 10:54:27 +0000 Subject: [PATCH 04/36] WIP --- public/js/plugin-download-2.js | 233 +++++++++++++++++++++++++-- src/views/plugin-download-2.html.ecr | 102 +++++++++++- 2 files changed, 322 insertions(+), 13 deletions(-) diff --git a/public/js/plugin-download-2.js b/public/js/plugin-download-2.js index 98744270..3986242e 100644 --- a/public/js/plugin-download-2.js +++ b/public/js/plugin-download-2.js @@ -1,16 +1,36 @@ +// TODO: limit the number of chapters to list + const component = () => { return { plugins: [], info: undefined, pid: undefined, + chapters: undefined, // undefined: not searched yet, []: empty + allChapters: [], + query: '', + mangaTitle: '', + searching: false, + adding: false, + sortOptions: [], + showFilters: false, + appliedFilters: [], + chaptersLimit: 500, 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) { - alert('danger', `Failed to list the available plugins. Error: ${data.error}`); - return; - } + if (!data.success) + throw new Error(data.error); this.plugins = data.plugins; const pid = localStorage.getItem('plugin'); @@ -19,6 +39,9 @@ const component = () => { 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) { @@ -27,16 +50,208 @@ const component = () => { })}`) .then(res => res.json()) .then(data => { - if (!data.success) { - alert('danger', `Failed to get plugin metadata. Error: ${data.error}`); - return; - } + 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)); + }, + search() { + this.searching = true; + this.allChapters = []; + this.chapters = undefined; + fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({ + plugin: this.pid, + query: this.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; + } + + data.chapters = Array(10).fill(data.chapters).flat(); + + this.allChapters = data.chapters; + this.chapters = data.chapters; + }) + .catch(e => { + alert('danger', `Failed to list chapters. Error: ${e}`); + }) + .finally(() => { + this.searching = false; + }); + }, + 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); + }) + }, + 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; + + console.log('applying filter:', filter); + + 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 => Date.parse(ch[filter.key]) >= Date.parse(filter.value)); + } + if (filter.type === 'date-max') { + ary = ary.filter(ch => Date.parse(ch[filter.key]) <= Date.parse(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; + } + + 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 + // this must come before the date checks, because any integer would + // also be parsed as a date. + if (!isNaN(a) && !isNaN(b)) + return Number(a) - Number(b); + + // try dates + if (!isNaN(Date.parse(a)) && !isNaN(Date.parse(b))) + return Date.parse(a) - Date.parse(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 => !isNaN(v))) return 'number'; // display input for number range + if (values.every(v => !isNaN(Date.parse(v)))) return 'date'; // display input for date range + if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains + return 'string'; // display input for string searching. + // for the last two, if the number of options is small enough (say < 50), display a multi-select2 + }, + 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 + }; + }); + }, + applyFilters() { + const values = $('#filter-form input, #filter-form select') + .get() + .map(i => ({ + key: i.getAttribute('data-filter-key'), + value: i.value.trim(), + type: i.getAttribute('data-filter-type') + })); + this.appliedFilters = values; + this.chapters = this.filteredChapters; + }, + clearFilters() { + $('#filter-form input').get().forEach(i => i.value = ''); + this.appliedFilters = []; + this.chapters = this.filteredChapters; + }, }; }; diff --git a/src/views/plugin-download-2.html.ecr b/src/views/plugin-download-2.html.ecr index a7033410..ca664780 100644 --- a/src/views/plugin-download-2.html.ecr +++ b/src/views/plugin-download-2.html.ecr @@ -1,12 +1,15 @@ -
+
-
+

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.

+
-

Download with Plugins

+

Download with Plugins + +

+ +
+

+

+ +
+
+ + + + +
+
+
+ +
+ + + +
+ +

+

No chapters found.

+ +
+

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.

+
+ + + + + + + + + +
+
+
+
From 59bcb4db3bb900f30d898ee482dd3e06c2938c57 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 5 Jun 2021 10:53:45 +0000 Subject: [PATCH 05/36] WIP --- public/js/plugin-download-2.js | 257 ---------------- public/js/plugin-download.js | 439 +++++++++++++++++++-------- src/plugin/plugin.cr | 2 +- src/routes/api.cr | 14 +- src/routes/main.cr | 19 -- src/views/plugin-download-2.html.ecr | 155 ---------- src/views/plugin-download.html.ecr | 225 ++++++++++---- 7 files changed, 488 insertions(+), 623 deletions(-) delete mode 100644 public/js/plugin-download-2.js delete mode 100644 src/views/plugin-download-2.html.ecr diff --git a/public/js/plugin-download-2.js b/public/js/plugin-download-2.js deleted file mode 100644 index 3986242e..00000000 --- a/public/js/plugin-download-2.js +++ /dev/null @@ -1,257 +0,0 @@ -// TODO: limit the number of chapters to list - -const component = () => { - return { - plugins: [], - info: undefined, - pid: undefined, - chapters: undefined, // undefined: not searched yet, []: empty - allChapters: [], - query: '', - mangaTitle: '', - searching: false, - adding: false, - sortOptions: [], - showFilters: false, - appliedFilters: [], - chaptersLimit: 500, - 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)); - }, - search() { - this.searching = true; - this.allChapters = []; - this.chapters = undefined; - fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({ - plugin: this.pid, - query: this.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; - } - - data.chapters = Array(10).fill(data.chapters).flat(); - - this.allChapters = data.chapters; - this.chapters = data.chapters; - }) - .catch(e => { - alert('danger', `Failed to list chapters. Error: ${e}`); - }) - .finally(() => { - this.searching = false; - }); - }, - 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); - }) - }, - 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; - - console.log('applying filter:', filter); - - 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 => Date.parse(ch[filter.key]) >= Date.parse(filter.value)); - } - if (filter.type === 'date-max') { - ary = ary.filter(ch => Date.parse(ch[filter.key]) <= Date.parse(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; - } - - 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 - // this must come before the date checks, because any integer would - // also be parsed as a date. - if (!isNaN(a) && !isNaN(b)) - return Number(a) - Number(b); - - // try dates - if (!isNaN(Date.parse(a)) && !isNaN(Date.parse(b))) - return Date.parse(a) - Date.parse(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 => !isNaN(v))) return 'number'; // display input for number range - if (values.every(v => !isNaN(Date.parse(v)))) return 'date'; // display input for date range - if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains - return 'string'; // display input for string searching. - // for the last two, if the number of options is small enough (say < 50), display a multi-select2 - }, - 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 - }; - }); - }, - applyFilters() { - const values = $('#filter-form input, #filter-form select') - .get() - .map(i => ({ - key: i.getAttribute('data-filter-key'), - value: i.value.trim(), - type: i.getAttribute('data-filter-type') - })); - this.appliedFilters = values; - this.chapters = this.filteredChapters; - }, - clearFilters() { - $('#filter-form input').get().forEach(i => i.value = ''); - this.appliedFilters = []; - this.chapters = this.filteredChapters; - }, - }; -}; diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index a335e038..3a846eca 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -1,139 +1,326 @@ -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 + allChapters: [], + query: '', + mangaTitle: '', + searching: false, + adding: false, + sortOptions: [], + showFilters: false, + appliedFilters: [], + chaptersLimit: 500, + listManga: false, -$(() => { - 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; - } - mangaTitle = data.title; - $('#title-text').text(data.title); - buildTable(data.chapters); - }) - .fail((jqXHR, status) => { - alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }) - .always(() => {}); -}; + 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 buildTable = (chapters) => { - $('#table').attr('hidden', ''); - $('table').empty(); + const pid = localStorage.getItem('plugin'); + if (pid && this.plugins.map(p => p.id).includes(pid)) + return this.loadPlugin(pid); - const keys = Object.keys(chapters[0]).map(k => `${k}`).join(''); - const thead = `${keys}`; - $('table').append(thead); + 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.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 rows = chapters.map(ch => { - const tds = Object.values(ch).map(v => `${v}`).join(''); - return `${tds}`; - }); - const tbody = `${rows}`; - $('table').append(tbody); + this.allChapters = data.chapters; + this.chapters = data.chapters; + }) + .catch(e => { + alert('danger', `Failed to list chapters. Error: ${e}`); + }) + .finally(() => { + this.searching = false; + }); + }, + searchManga() { + this.searching = true; + this.allChapters = []; + this.chapters = undefined; + this.manga = undefined; + fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({ + plugin: this.pid, + query: this.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() { + this.manga = undefined; + if (this.info.version === 1) { + this.searchChapters(this.query); + } else { + this.searchManga(); + } + }, + 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; - $('#selectable').selectable({ - filter: 'tr' - }); + 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(); - $('#table table').tablesorter(); - $('#table').removeAttr('hidden'); -}; + console.log('initial size:', ary.length); + for (let filter of this.appliedFilters) { + if (!filter.value) continue; + if (filter.type === 'array' && filter.value === 'all') continue; -const selectAll = () => { - $('tbody > tr').each((i, e) => { - $(e).addClass('ui-selected'); - }); -}; + console.log('applying filter:', filter); -const unselect = () => { - $('tbody > tr').each((i, e) => { - $(e).removeClass('ui-selected'); - }); -}; + 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 => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value)); + } + if (filter.type === 'date-max') { + ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(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())); + } -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') + console.log('filtered size:', ary.length); } - }).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; + + return ary; + }, + // option: + // - 1: asending + // - -1: desending + // - 0: unsorted + sort(key, option) { + if (option === 0) { + this.chapters = this.filteredChapters; + return; + } + + 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 + // this must come before the date checks, because any integer would + // also be parsed as a date. + if (!isNaN(a) && !isNaN(b)) + return Number(a) - Number(b); + + // try dates + if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b))) + return this.parseDate(a) - this.parseDate(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 => !isNaN(v))) return 'number'; // display input for number range + if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range + if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains + return 'string'; // display input for string searching. + // for the last two, if the number of options is small enough (say < 50), display a multi-select2 + }, + 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(); + }))); } - 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}`); - }) - .always(() => { - $('#download-spinner').attr('hidden', ''); - $('#download-btn').removeAttr('hidden'); + + return { + key: k, + type: type, + values: values + }; }); - }); + }, + applyFilters() { + const values = $('#filter-form input, #filter-form select') + .get() + .map(i => ({ + key: i.getAttribute('data-filter-key'), + value: i.value.trim(), + type: i.getAttribute('data-filter-type') + })); + this.appliedFilters = values; + this.chapters = this.filteredChapters; + }, + clearFilters() { + $('#filter-form input').get().forEach(i => i.value = ''); + this.appliedFilters = []; + this.chapters = this.filteredChapters; + }, + mangaSelected(event) { + const mid = event.currentTarget.getAttribute('data-id'); + this.searchChapters(mid); + }, + parseDate(str) { + const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g; + // Basic sanity check to make sure it's an actual date. + // We need this because Date.parse thinks 'Chapter 1' is a date. + if (!regex.test(str)) + return NaN; + return Date.parse(str); + } + }; }; diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index f8cb1092..be08d6f3 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -44,7 +44,7 @@ class Plugin @{{name.id}} = @json[{{name}}].as_s {% end %} @wait_seconds = @json["wait_seconds"].as_i.to_u64 - @version = @json["api_verson"]?.try(&.as_i.to_u64) || 1u64 + @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| diff --git a/src/routes/api.cr b/src/routes/api.cr index b1f07522..155e0dc0 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -638,8 +638,8 @@ struct APIRouter "success" => Bool, "error" => String?, "chapters?" => [{ - "id" => String, - "title" => String, + "id" => String, + "title?" => String, }], "title" => String?, } @@ -649,8 +649,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, diff --git a/src/routes/main.cr b/src/routes/main.cr index 79d135c9..b3be1816 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -79,16 +79,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 @@ -96,15 +86,6 @@ struct MainRouter end end - get "/download/plugins2" do |env| - begin - layout "plugin-download-2" - rescue e - Logger.error e - env.response.status_code = 500 - end - end - get "/download/subscription" do |env| mangadex_base_url = Config.current.mangadex["base_url"] username = get_username env diff --git a/src/views/plugin-download-2.html.ecr b/src/views/plugin-download-2.html.ecr deleted file mode 100644 index ca664780..00000000 --- a/src/views/plugin-download-2.html.ecr +++ /dev/null @@ -1,155 +0,0 @@ -
-
-
-

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.

-
- -
-

Download with Plugins - -

- - - -
-

-

- -
-
- - - - -
-
-
- -
- - - -
- -

-

No chapters found.

- -
-

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.

-
- - - - - - - - - -
-
-
-
-
-
-
- -<% content_for "script" do %> - <%= render_component "moment" %> - <%= render_component "jquery-ui" %> - - -<% end %> diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index ece56b6f..bf683253 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -1,77 +1,180 @@ -<% 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.

+
+ +
+

Download with Plugins + +

-<% else %> -

Download with Plugins

+ + + + +
+

+

+ +
+
+ + + + +
+
-
-
-
- +
+ + + +
+ +

+

No chapters found.

+ +
+

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.

+
+ + + + + + + + + +
+
+
+
-<% end %> - + <% content_for "script" do %> - <% if plugin %> - - <% end %> <%= render_component "jquery-ui" %> - <% end %> From 9eb699ea3b82c1474d8bbfb0a7a7832fb4482093 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Mon, 7 Jun 2021 07:04:49 +0000 Subject: [PATCH 06/36] Add plugin subscription types --- src/plugin/plugin.cr | 18 ++++++++ src/plugin/subscriptions.cr | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/plugin/subscriptions.cr diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index be08d6f3..f6fd2a74 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 @@ -131,6 +133,22 @@ 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 unsubscribe(id : String) + list = SubscriptionList.new info.dir + list.reject &.id.== id + list.save + end + def initialize(id : String) Plugin.build_info_ary diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr new file mode 100644 index 00000000..daca7850 --- /dev/null +++ b/src/plugin/subscriptions.cr @@ -0,0 +1,87 @@ +require "uuid" + +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_i32? || _value.as_i64? || + _value.as_f32? || nil + self.new key, value, type + end +end + +struct Subscription + include JSON::Serializable + + property id : String + property plugin_id : String + property name : String + property created_at : Int64 + property last_checked : Int64 + property filters = [] of Filter + + def initialize(@plugin_id, @name) + @id = UUID.random.to_s + @created_at = Time.utc.to_unix + @last_checked = Time.utc.to_unix + end +end + +struct SubscriptionList + @dir : String + @path : String + + getter ary = [] of Subscription + + forward_missing_to @ary + + def initialize(@dir) + @path = Path[@dir, "subscriptions.json"] + if File.exists? @path + @ary = Array(Subscription).from_json File.read @path + end + end + + def save + File.write @path, @ary.to_json + end +end From 6844860065ea5fae756f5464d904b0af8fb78a90 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Mon, 7 Jun 2021 07:32:32 +0000 Subject: [PATCH 07/36] Revert "Subscription manager" This reverts commit a612500b0fabf7259a5ee0c841b0157d191e5bdd. --- migration/subscription.12.cr | 31 --- public/js/download.js | 94 --------- shard.lock | 2 +- src/config.cr | 6 +- src/library/library.cr | 19 -- src/mangadex/ext.cr | 34 ---- src/routes/api.cr | 144 +------------- src/routes/main.cr | 6 - src/storage.cr | 68 ------- src/util/web.cr | 19 -- src/views/download-manager.html.ecr | 102 +++++----- src/views/download.html.ecr | 288 ++++++++++++++-------------- src/views/layout.html.ecr | 158 ++++++++------- src/views/missing-items.html.ecr | 56 +++--- 14 files changed, 308 insertions(+), 719 deletions(-) delete mode 100644 migration/subscription.12.cr diff --git a/migration/subscription.12.cr b/migration/subscription.12.cr deleted file mode 100644 index 3810755c..00000000 --- a/migration/subscription.12.cr +++ /dev/null @@ -1,31 +0,0 @@ -class CreateSubscription < MG::Base - def up : String - # We allow multiple subscriptions for the same manga. - # This can be useful for example when you want to download from multiple - # groups. - <<-SQL - CREATE TABLE subscription ( - id INTEGER PRIMARY KEY, - manga_id INTEGER NOT NULL, - language TEXT, - group_id INTEGER, - min_volume INTEGER, - max_volume INTEGER, - min_chapter INTEGER, - max_chapter INTEGER, - last_checked INTEGER NOT NULL, - created_at INTEGER NOT NULL, - username TEXT NOT NULL, - FOREIGN KEY (username) REFERENCES users (username) - ON UPDATE CASCADE - ON DELETE CASCADE - ); - SQL - end - - def down : String - <<-SQL - DROP TABLE subscription; - SQL - end -end diff --git a/public/js/download.js b/public/js/download.js index 31a803eb..4d8504b9 100644 --- a/public/js/download.js +++ b/public/js/download.js @@ -280,100 +280,6 @@ const downloadComponent = () => { UIkit.modal($('#modal').get(0)).hide(); this.searchInput = id; this.search(); - }, - - subscribe(langConfirmed = false, groupConfirmed = false) { - const filters = { - manga: this.data.id, - language: this.langChoice === 'All' ? null : this.langChoice, - group: this.groupChoice === 'All' ? null : this.groupChoice, - volume: this.volumeRange === '' ? null : this.volumeRange, - chapter: this.chapterRange === '' ? null : this.chapterRange - }; - - // Get group ID - if (filters.group) { - this.data.chapters.forEach(chp => { - const gid = chp.groups[filters.group]; - if (gid) { - filters.groupId = gid; - return; - } - }); - } - - // Parse range values - if (filters.volume) { - [filters.volumeMin, filters.volumeMax] = this.parseRange(filters.volume); - } - if (filters.chapter) { - [filters.chapterMin, filters.chapterMax] = this.parseRange(filters.chapter); - } - - if (!filters.language && !langConfirmed) { - UIkit.modal.confirm('You didn\'t specify a language in the filtering rules. This might cause Mango to download chapters that are not in your preferred language. Are you sure you want to continue?', { - labels: { - ok: 'Yes', - cancel: 'Cancel' - } - }).then(() => { - this.subscribe(true, groupConfirmed); - }); - return; - } - - if (!filters.group && !groupConfirmed) { - UIkit.modal.confirm('You didn\'t specify a group in the filtering rules. This might cause Mango to download multiple versions of the same chapter. Are you sure you want to continue?', { - labels: { - ok: 'Yes', - cancel: 'Cancel' - } - }).then(() => { - this.subscribe(langConfirmed, true); - }); - return; - } - - const mangaURL = `${mangadex_base_url}/manga/${filters.manga}`; - - console.log(filters); - UIkit.modal.confirm(`All FUTURE chapters matching the following filters will be downloaded:
-
    -
  • Manga ID: ${filters.manga}
  • -
  • Language: ${filters.language || 'all'}
  • -
  • Group: ${filters.group || 'all'}
  • -
  • Volume: ${filters.volume || 'all'}
  • -
  • Chapter: ${filters.chapter || 'all'}
  • -
- - IMPORTANT: Please make sure you are following the manga on MangaDex, otherwise Mango won't be able to receive any updates. To follow it, visit ${mangaURL} and click "Follow". - `, { - labels: { - ok: 'Confirm', - cancel: 'Cancel' - } - }).then(() => { - $.ajax({ - type: 'POST', - url: `${base_url}api/admin/mangadex/subscriptions`, - data: JSON.stringify({ - subscription: filters - }), - contentType: "application/json", - dataType: 'json' - }) - .done(data => { - console.log(data); - if (data.error) { - alert('danger', `Failed to subscribe. Error: ${data.error}`); - return; - } - alert('success', `You've successfully subscribed to this manga! You can view and manage your subscriptions on the subscription manager page.`); - }) - .fail((jqXHR, status) => { - alert('danger', `Failed to subscribe. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }); - }); } }; }; diff --git a/shard.lock b/shard.lock index 13ce55dc..c22f839b 100644 --- a/shard.lock +++ b/shard.lock @@ -54,7 +54,7 @@ shards: mangadex: git: https://github.com/hkalexling/mangadex.git - version: 0.11.0+git.commit.f5b0d64fbb138879fb9228b6e9ff34ec97c3e824 + version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6 mg: git: https://github.com/hkalexling/mg.git diff --git a/src/config.cr b/src/config.cr index 6de78a79..332a159e 100644 --- a/src/config.cr +++ b/src/config.cr @@ -33,10 +33,8 @@ class Config "download_retries" => 4, "download_queue_db_path" => File.expand_path("~/mango/queue.db", home: true), - "chapter_rename_rule" => "[Vol.{volume} ]" \ - "[Ch.{chapter} ]{title|id}", - "manga_rename_rule" => "{title}", - "subscription_update_interval_hours" => 24, + "chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}", + "manga_rename_rule" => "{title}", } @@singlet : Config? diff --git a/src/library/library.cr b/src/library/library.cr index e3598474..a5a4a80d 100644 --- a/src/library/library.cr +++ b/src/library/library.cr @@ -42,25 +42,6 @@ class Library end end end - - subscription_interval = Config.current - .mangadex["subscription_update_interval_hours"].as Int32 - unless subscription_interval < 1 - spawn do - loop do - subscriptions = Storage.default.subscriptions - Logger.info "Checking MangaDex for updates on " \ - "#{subscriptions.size} subscriptions" - added_count = 0 - subscriptions.each do |sub| - added_count += sub.check_for_updates - end - Logger.info "Subscription update completed. Added #{added_count} " \ - "chapters to the download queue" - sleep subscription_interval.hours - end - end - end end def titles diff --git a/src/mangadex/ext.cr b/src/mangadex/ext.cr index e919d970..deb09c89 100644 --- a/src/mangadex/ext.cr +++ b/src/mangadex/ext.cr @@ -56,39 +56,5 @@ module MangaDex hash["full_title"] = JSON::Any.new full_title hash.to_json end - - # We don't need to rename the manga title here. It will be renamed in - # src/mangadex/downloader.cr - def to_job : Queue::Job - Queue::Job.new( - id.to_s, - manga_id.to_s, - full_title, - manga_title, - Queue::JobStatus::Pending, - Time.unix timestamp - ) - end - end - - struct User - def updates_after(time : Time, &block : Chapter ->) - page = 1 - stopped = false - until stopped - chapters = followed_updates(page: page).chapters - return if chapters.empty? - chapters.each do |c| - if time > Time.unix c.timestamp - stopped = true - break - end - yield c - end - page += 1 - # Let's not DDOS MangaDex :) - sleep 5.seconds - end - end end end diff --git a/src/routes/api.cr b/src/routes/api.cr index 155e0dc0..3d181263 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -1083,147 +1083,23 @@ struct APIRouter Koa.tags ["admin", "mangadex"] get "/api/admin/mangadex/search" do |env| begin - query = env.params.query["query"] - - send_json env, { - "success" => true, - "error" => nil, - "manga" => get_client(env).partial_search query, - }.to_json - rescue e - Logger.error e - send_json env, { - "success" => false, - "error" => e.message, - }.to_json - end - end - - Koa.describe "Lists all MangaDex subscriptions" - Koa.response 200, schema: { - "success" => Bool, - "error" => String?, - "subscriptions?" => [{ - "id" => Int64, - "username" => String, - "manga_id" => Int64, - "language" => String?, - "group_id" => Int64?, - "min_volume" => Int64?, - "max_volume" => Int64?, - "min_chapter" => Int64?, - "max_chapter" => Int64?, - "last_checked" => Int64, - "created_at" => Int64, - }], - } - Koa.tags ["admin", "mangadex", "subscriptions"] - get "/api/admin/mangadex/subscriptions" do |env| - begin - send_json env, { - "success" => true, - "error" => nil, - "subscriptions" => Storage.default.subscriptions, - }.to_json - rescue e - Logger.error e - send_json env, { - "success" => false, - "error" => e.message, - }.to_json - end - end - - Koa.describe "Creates a new MangaDex subscription" - Koa.body schema: { - "subscription" => { - "manga" => Int64, - "language" => String?, - "groupId" => Int64?, - "volumeMin" => Int64?, - "volumeMax" => Int64?, - "chapterMin" => Int64?, - "chapterMax" => Int64?, - }, - } - Koa.response 200, schema: { - "success" => Bool, - "error" => String?, - } - Koa.tags ["admin", "mangadex", "subscriptions"] - post "/api/admin/mangadex/subscriptions" do |env| - begin - json = env.params.json["subscription"].as Hash(String, JSON::Any) - sub = Subscription.new json["manga"].as_i64, get_username env - sub.language = json["language"]?.try &.as_s? - sub.group_id = json["groupId"]?.try &.as_i64? - sub.min_volume = json["volumeMin"]?.try &.as_i64? - sub.max_volume = json["volumeMax"]?.try &.as_i64? - sub.min_chapter = json["chapterMin"]?.try &.as_i64? - sub.max_chapter = json["chapterMax"]?.try &.as_i64? + username = get_username env + token, expires = Storage.default.get_md_token username - Storage.default.save_subscription sub + unless expires && token + raise "No token found for user #{username}" + end - send_json env, { - "success" => true, - "error" => nil, - }.to_json - rescue e - Logger.error e - send_json env, { - "success" => false, - "error" => e.message, - }.to_json - end - end + client = MangaDex::Client.from_config + client.token = token + client.token_expires = expires - Koa.describe "Deletes a MangaDex subscription identified by `id`", <<-MD - Does nothing if the subscription was not created by the current user. - MD - Koa.response 200, schema: { - "success" => Bool, - "error" => String?, - } - Koa.tags ["admin", "mangadex", "subscriptions"] - delete "/api/admin/mangadex/subscriptions/:id" do |env| - begin - id = env.params.url["id"].to_i64 - Storage.default.delete_subscription id, get_username env - send_json env, { - "success" => true, - "error" => nil, - }.to_json - rescue e - Logger.error e - send_json env, { - "success" => false, - "error" => e.message, - }.to_json - end - end + query = env.params.query["query"] - Koa.describe "Triggers an update for a MangaDex subscription identified by `id`", <<-MD - Does nothing if the subscription was not created by the current user. - MD - Koa.response 200, schema: { - "success" => Bool, - "error" => String?, - } - Koa.tags ["admin", "mangadex", "subscriptions"] - post "/api/admin/mangadex/subscriptions/check/:id" do |env| - begin - id = env.params.url["id"].to_i64 - username = get_username env - sub = Storage.default.get_subscription id, username - unless sub - raise "Subscription with id #{id} not found under user #{username}" - end - spawn do - sub.check_for_updates - end send_json env, { "success" => true, "error" => nil, + "manga" => client.partial_search query, }.to_json rescue e Logger.error e diff --git a/src/routes/main.cr b/src/routes/main.cr index b3be1816..80bc16d1 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -86,12 +86,6 @@ struct MainRouter end end - get "/download/subscription" do |env| - mangadex_base_url = Config.current.mangadex["base_url"] - username = get_username env - layout "subscription" - end - get "/" do |env| begin username = get_username env diff --git a/src/storage.cr b/src/storage.cr index 164ce401..39116b98 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -5,7 +5,6 @@ require "base64" require "./util/*" require "mg" require "../migration/*" -require "./subscription" def hash_password(pw) Crypto::Bcrypt::Password.create(pw).to_s @@ -15,9 +14,6 @@ def verify_password(hash, pw) (Crypto::Bcrypt::Password.new hash).verify pw end -SUB_ATTR = %w(manga_id language group_id min_volume max_volume min_chapter - max_chapter username) - class Storage @@insert_entry_ids = [] of IDTuple @@insert_title_ids = [] of IDTuple @@ -549,70 +545,6 @@ class Storage {token, expires} end - def save_subscription(sub : Subscription) - MainFiber.run do - get_db do |db| - {% begin %} - db.exec "insert into subscription (#{SUB_ATTR.join ","}, " \ - "last_checked, created_at) values " \ - "(#{Array.new(SUB_ATTR.size + 2, "?").join ","})", - {% for type in SUB_ATTR %} - sub.{{type.id}}, - {% end %} - sub.last_checked.to_unix, sub.created_at.to_unix - {% end %} - end - end - end - - def subscriptions : Array(Subscription) - subs = [] of Subscription - MainFiber.run do - get_db do |db| - db.query "select * from subscription" do |rs| - subs += Subscription.from_rs rs - end - end - end - subs - end - - def delete_subscription(id : Int64, username : String) - MainFiber.run do - get_db do |db| - db.exec "delete from subscription where id = (?) and username = (?)", - id, username - end - end - end - - def get_subscription(id : Int64, username : String) : Subscription? - sub = nil - MainFiber.run do - get_db do |db| - db.query "select * from subscription where id = (?) and " \ - "username = (?) limit 1", id, username do |rs| - sub = Subscription.from_rs(rs).first? - end - end - end - sub - end - - def update_subscription_last_checked(id : Int64? = nil) - MainFiber.run do - get_db do |db| - if id - db.exec "update subscription set last_checked = (?) where id = (?)", - Time.utc.to_unix, id - else - db.exec "update subscription set last_checked = (?)", - Time.utc.to_unix - end - end - end - end - def close MainFiber.run do unless @db.nil? diff --git a/src/util/web.cr b/src/util/web.cr index efa269d8..12459e53 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -107,25 +107,6 @@ macro get_sort_opt end end -# Returns an authorized client -def get_client(username : String) : MangaDex::Client - token, expires = Storage.default.get_md_token username - - unless expires && token - raise "No token found for user #{username}" - end - - client = MangaDex::Client.from_config - client.token = token - client.token_expires = expires - - client -end - -def get_client(env) : MangaDex::Client - get_client get_username env -end - module HTTP class Client private def self.exec(uri : URI, tls : TLSContext = nil) diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr index 2075e01e..c264177b 100644 --- a/src/views/download-manager.html.ecr +++ b/src/views/download-manager.html.ecr @@ -5,63 +5,61 @@ -
- - - - - - - - - - - - - - + +
ChapterMangaProgressTimeStatusPluginActions
+
<% content_for "script" do %> diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr index 983cae00..0ea85276 100644 --- a/src/views/download.html.ecr +++ b/src/views/download.html.ecr @@ -1,170 +1,162 @@

Download from MangaDex

-
-
- -
-
-
- -
+
+
+
+
+
+ +
+
- + -
- -
- -
-
+
+
+
+ +
+
+

Title:

+

+

+
+
+

Filter Chapters

+

+
+ +
+ +
+
-
- -
- -
-
-
+
+ +
+ +
-
- - - -
-
-

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.

+ +
+ +
-

-
- - - - - - - - - - - - - -
IDTitleLanguageGroupVolumeChapterTimestamp
+
+ +
+ +
+
- <% content_for "script" do %> - <%= render_component "moment" %> - <%= render_component "jquery-ui" %> - - - + <%= render_component "moment" %> + <%= render_component "jquery-ui" %> + + <% end %> diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr index 0712c4d3..ff4853c9 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -1,91 +1,89 @@ - <%= render_component "head" %> + <%= render_component "head" %> - -
-
-
-
- -
-
-
-
-
-
-
-
+ +
+
+
+
+ +
+
+
-
- - +
+ +
+
-
- +
-
-
-
-
-
-
-
- <%= content %> -
- +
+
+
+ <%= content %> +
+ +
+
-
-
- - <%= render_component "uikit" %> - <%= yield_content "script" %> - + + <%= render_component "uikit" %> + <%= yield_content "script" %> + diff --git a/src/views/missing-items.html.ecr b/src/views/missing-items.html.ecr index 024960b1..334e1859 100644 --- a/src/views/missing-items.html.ecr +++ b/src/views/missing-items.html.ecr @@ -3,36 +3,34 @@

The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.

-
- - - - - - - +
TypeRelative PathIDActions
+ + + + + + + + + + + + +
TypeRelative PathIDActions
From 3b19883ddec6674e310ecf09a1b2bf1d3c8399d5 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Mon, 7 Jun 2021 07:35:44 +0000 Subject: [PATCH 08/36] Use auto overflow tables cherry-picked from a612500b0fabf7259a5ee0c841b0157d191e5bdd --- src/views/download-manager.html.ecr | 102 ++++++++++++++-------------- src/views/missing-items.html.ecr | 56 +++++++-------- 2 files changed, 81 insertions(+), 77 deletions(-) diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr index c264177b..73a5445d 100644 --- a/src/views/download-manager.html.ecr +++ b/src/views/download-manager.html.ecr @@ -5,61 +5,63 @@
- - - - - - - - - - - - - - + +
ChapterMangaProgressTimeStatusPluginActions
+
<% content_for "script" do %> diff --git a/src/views/missing-items.html.ecr b/src/views/missing-items.html.ecr index 334e1859..024960b1 100644 --- a/src/views/missing-items.html.ecr +++ b/src/views/missing-items.html.ecr @@ -3,34 +3,36 @@

The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.

- - - - - - - - - - - - - -
TypeRelative PathIDActions
+ + + + + + +
From 259f6cb2850a8205c50680f448bbd57b9aec99da Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 11 Jul 2021 02:45:14 +0000 Subject: [PATCH 09/36] Add endpoint for plugin subscription --- src/plugin/subscriptions.cr | 2 +- src/routes/api.cr | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index daca7850..a86831a7 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -75,7 +75,7 @@ struct SubscriptionList forward_missing_to @ary def initialize(@dir) - @path = Path[@dir, "subscriptions.json"] + @path = Path[@dir, "subscriptions.json"].to_s if File.exists? @path @ary = Array(Subscription).from_json File.read @path end diff --git a/src/routes/api.cr b/src/routes/api.cr index 3d181263..0e4272a4 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -630,6 +630,30 @@ struct APIRouter end end + post "/api/admin/plugin/subscribe" do |env| + begin + plugin_id = env.params.json["plugin"].as String + filters = Array(Filter).from_json env.params.json["filters"].to_s + name = env.params.json["name"].as String + + sub = Subscription.new plugin_id, 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 "Lists the chapters in a title from a plugin" Koa.tags ["admin", "downloader"] Koa.query "plugin", schema: String From f56ce2313c2e0ca615cc11613af826ee1d36f537 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 11 Jul 2021 11:19:08 +0000 Subject: [PATCH 10/36] WIP --- public/js/plugin-download.js | 45 ++++++++++++++++++++++++++---- src/plugin/subscriptions.cr | 4 +-- src/routes/api.cr | 5 +++- src/views/plugin-download.html.ecr | 2 ++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 3a846eca..94136812 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -15,6 +15,7 @@ const component = () => { appliedFilters: [], chaptersLimit: 500, listManga: false, + subscribing: false, init() { const tableObserver = new MutationObserver(() => { @@ -88,6 +89,10 @@ const component = () => { this.mangaTitle = data.title; } + data.chapters.forEach(c => { + c.array = ['hello', 'world', 'haha', 'wtf'].sort(() => 0.5 - Math.random()).slice(0, 2); + }); + this.allChapters = data.chapters; this.chapters = data.chapters; }) @@ -268,9 +273,8 @@ const component = () => { fieldType(values) { if (values.every(v => !isNaN(v))) return 'number'; // display input for number range if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range - if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains + if (values.every(v => Array.isArray(v))) return 'array'; // display select return 'string'; // display input for string searching. - // for the last two, if the number of options is small enough (say < 50), display a multi-select2 }, get filters() { if (this.allChapters.length < 1) return []; @@ -294,15 +298,17 @@ const component = () => { }; }); }, - applyFilters() { - const values = $('#filter-form input, #filter-form select') + get filterSettings() { + return $('#filter-form input:visible, #filter-form select:visible') .get() .map(i => ({ key: i.getAttribute('data-filter-key'), value: i.value.trim(), type: i.getAttribute('data-filter-type') })); - this.appliedFilters = values; + }, + applyFilters() { + this.appliedFilters = this.filterSettings; this.chapters = this.filteredChapters; }, clearFilters() { @@ -321,6 +327,35 @@ const component = () => { if (!regex.test(str)) return NaN; return Date.parse(str); + }, + subscribe() { + // TODO: + // - confirmation + // - name + // - use select2 + this.subscribing = true; + fetch(`${base_url}api/admin/plugin/subscribe`, { + method: 'POST', + body: JSON.stringify({ + filters: JSON.stringify(this.filterSettings), + plugin: this.pid, + name: 'Test Name' + }), + headers: { + "Content-Type": "application/json" + } + }) + .then(res => res.json()) + .then(data => { + if (!data.success) + throw new Error(data.error); + }) + .catch(e => { + alert('danger', `Failed to subscribe. Error: ${e}`); + }) + .finally(() => { + this.subscribing = false; + }); } }; }; diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index a86831a7..3c23848c 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -43,7 +43,7 @@ struct Filter key = json["key"].as_s type = FilterType.from_string json["type"].as_s _value = json["value"] - value = _value.as_s? || _value.as_i32? || _value.as_i64? || + value = _value.as_s? || _value.as_i? || _value.as_i64? || _value.as_f32? || nil self.new key, value, type end @@ -82,6 +82,6 @@ struct SubscriptionList end def save - File.write @path, @ary.to_json + File.write @path, @ary.to_pretty_json end end diff --git a/src/routes/api.cr b/src/routes/api.cr index 0e4272a4..4a403e9b 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -633,7 +633,9 @@ struct APIRouter post "/api/admin/plugin/subscribe" do |env| begin plugin_id = env.params.json["plugin"].as String - filters = Array(Filter).from_json env.params.json["filters"].to_s + filters = JSON.parse(env.params.json["filters"].to_s).as_a.map do |f| + Filter.from_json f.to_json + end name = env.params.json["name"].as String sub = Subscription.new plugin_id, name @@ -651,6 +653,7 @@ struct APIRouter "success" => false, "error" => e.message, }.to_json + raise e end end diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index bf683253..1e8486da 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -133,6 +133,8 @@ + +

From b7aee1e903d3c8ac3f2e2bd3aa3aab5d6f097e17 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Tue, 17 Aug 2021 04:11:58 +0000 Subject: [PATCH 11/36] WIP --- public/js/plugin-download.js | 38 +++++++++++++++++++++++------- src/views/plugin-download.html.ecr | 37 ++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 94136812..9973cfe4 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -16,6 +16,7 @@ const component = () => { chaptersLimit: 500, listManga: false, subscribing: false, + subscriptionName: '', init() { const tableObserver = new MutationObserver(() => { @@ -91,6 +92,7 @@ const component = () => { data.chapters.forEach(c => { c.array = ['hello', 'world', 'haha', 'wtf'].sort(() => 0.5 - Math.random()).slice(0, 2); + c.date = ['4 Jun, 1989', '1 July, 2021'].sort(() => 0.5 - Math.random())[0]; }); this.allChapters = data.chapters; @@ -103,14 +105,14 @@ const component = () => { this.searching = false; }); }, - searchManga() { + 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: this.query + query: query })}`) .then(res => res.json()) .then(data => { @@ -127,11 +129,14 @@ const component = () => { }); }, search() { + const query = this.query.trim(); + if (!query) return; + this.manga = undefined; if (this.info.version === 1) { - this.searchChapters(this.query); + this.searchChapters(query); } else { - this.searchManga(); + this.searchManga(query); } }, selectAll() { @@ -328,18 +333,17 @@ const component = () => { return NaN; return Date.parse(str); }, - subscribe() { + subscribe(modal) { // TODO: - // - confirmation - // - name // - use select2 + this.subscribing = true; fetch(`${base_url}api/admin/plugin/subscribe`, { method: 'POST', body: JSON.stringify({ filters: JSON.stringify(this.filterSettings), plugin: this.pid, - name: 'Test Name' + name: this.subscriptionName.trim() }), headers: { "Content-Type": "application/json" @@ -349,13 +353,29 @@ const component = () => { .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(); }); + }, + filterTypeToReadable(type) { + switch (type) { + case 'number-min': + return 'number (minimum value)'; + case 'number-max': + return 'number (maximum value)'; + case 'date-min': + return 'minimum date'; + case 'date-max': + return 'maximum date'; + default: + return type; + } } - }; + } }; diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index 1e8486da..c6df3764 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -134,7 +134,7 @@ - +

@@ -173,6 +173,41 @@ + +
+
+
+

Subscription Confirmation

+
+
+

A subscription with the following filters with be created. All FUTURE chapters matching the filters will be automatically downloaded.

+ + + + + + + + + + + +
KeyTypeValue
+

Enter a meaningful name for the subscription to continue:

+ +
+ +
+
<% content_for "script" do %> From 238860d52d47de9045aba67aeb44776e0ed5611f Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 4 Sep 2021 02:25:53 +0000 Subject: [PATCH 12/36] Simplify subscription JSON parsing --- public/js/plugin-download.js | 2 +- src/routes/api.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 9973cfe4..9dfb95e2 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -341,7 +341,7 @@ const component = () => { fetch(`${base_url}api/admin/plugin/subscribe`, { method: 'POST', body: JSON.stringify({ - filters: JSON.stringify(this.filterSettings), + filters: this.filterSettings, plugin: this.pid, name: this.subscriptionName.trim() }), diff --git a/src/routes/api.cr b/src/routes/api.cr index 85df5160..b7f4651a 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -587,7 +587,7 @@ struct APIRouter post "/api/admin/plugin/subscribe" do |env| begin plugin_id = env.params.json["plugin"].as String - filters = JSON.parse(env.params.json["filters"].to_s).as_a.map do |f| + 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 From 198913db3e2774bc906908565a72cd62745b4bb2 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 4 Sep 2021 02:31:05 +0000 Subject: [PATCH 13/36] Remove MangaDex files that are no longer needed --- src/subscription.cr | 83 ---------------- src/views/download.html.ecr | 162 -------------------------------- src/views/mangadex.html.ecr | 39 -------- src/views/subscription.html.ecr | 54 ----------- 4 files changed, 338 deletions(-) delete mode 100644 src/subscription.cr delete mode 100644 src/views/download.html.ecr delete mode 100644 src/views/mangadex.html.ecr delete mode 100644 src/views/subscription.html.ecr 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

-
-
-
- -
-
-
- -
-
- - - -
-
-
- -
-
-

Title:

-

-

-
-
-

Filter Chapters

-

-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
- -
-
- - - -
-
-

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.

-
-

- - - - - - - - - - - - - - -
IDTitleLanguageGroupVolumeChapterTimestamp
-
- - -
- -<% content_for "script" do %> - <%= render_component "moment" %> - <%= render_component "jquery-ui" %> - - -<% end %> 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. -

-
- -
-
-
-
-
-
-
-
-
-
-
- -<% 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.

- - -
- -<% content_for "script" do %> - <%= render_component "moment" %> - - -<% end %> From a45bea01c9f001ebe1cdcdbcb323c0ac2b597300 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 4 Sep 2021 02:32:46 +0000 Subject: [PATCH 14/36] Fix linter --- src/plugin/plugin.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index f6fd2a74..7867a68c 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -214,8 +214,8 @@ class Plugin def search_manga(query : String) if info.version == 1 - raise Error.new "Manga searching is only available for plugins targeting API " \ - "v2 or above" + raise Error.new "Manga searching is only available for plugins " \ + "targeting API v2 or above" end json = eval_json "searchManga('#{query}')" begin From e44213960f2e573ce1a0e7e64f996c92270b9267 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 20 Nov 2021 08:10:51 +0000 Subject: [PATCH 15/36] Refactor date filtering and use native date picker --- public/js/plugin-download.js | 407 +++++++++++++++++------------ src/views/plugin-download.html.ecr | 23 +- 2 files changed, 241 insertions(+), 189 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 9dfb95e2..752e978a 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -6,8 +6,8 @@ const component = () => { chapters: undefined, // undefined: not searched yet, []: empty manga: undefined, // undefined: not searched yet, []: empty allChapters: [], - query: '', - mangaTitle: '', + query: "", + mangaTitle: "", searching: false, adding: false, sortOptions: [], @@ -16,73 +16,82 @@ const component = () => { chaptersLimit: 500, listManga: false, subscribing: false, - subscriptionName: '', + subscriptionName: "", init() { const tableObserver = new MutationObserver(() => { - console.log('table mutated'); - $('#selectable').selectable({ - filter: 'tr' + console.log("table mutated"); + $("#selectable").selectable({ + filter: "tr", }); }); - tableObserver.observe($('table').get(0), { + tableObserver.observe($("table").get(0), { childList: true, - subtree: true + subtree: true, }); fetch(`${base_url}api/admin/plugin`) - .then(res => res.json()) - .then(data => { - if (!data.success) - throw new Error(data.error); + .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)) + 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}`); + .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); + 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}`); + .catch((e) => { + alert( + "danger", + `Failed to get plugin metadata. Error: ${e}` + ); }); }, pluginChanged() { this.loadPlugin(this.pid); - localStorage.setItem('plugin', 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)); + return Object.keys(this.allChapters[0]).filter( + (k) => !["manga_title"].includes(k) + ); }, searchChapters(query) { this.searching = true; this.allChapters = []; 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); + 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(); @@ -90,16 +99,20 @@ const component = () => { this.mangaTitle = data.title; } - data.chapters.forEach(c => { - c.array = ['hello', 'world', 'haha', 'wtf'].sort(() => 0.5 - Math.random()).slice(0, 2); - c.date = ['4 Jun, 1989', '1 July, 2021'].sort(() => 0.5 - Math.random())[0]; + data.chapters.forEach((c) => { + c.array = ["hello", "world", "haha", "wtf"] + .sort(() => 0.5 - Math.random()) + .slice(0, 2); + c.date = [612892800000, "1625068800000"].sort( + () => 0.5 - Math.random() + )[0]; }); this.allChapters = data.chapters; this.chapters = data.chapters; }) - .catch(e => { - alert('danger', `Failed to list chapters. Error: ${e}`); + .catch((e) => { + alert("danger", `Failed to list chapters. Error: ${e}`); }) .finally(() => { this.searching = false; @@ -110,19 +123,20 @@ const component = () => { 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); + 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}`); + .catch((e) => { + alert("danger", `Search failed. Error: ${e}`); }) .finally(() => { this.searching = false; @@ -140,53 +154,64 @@ const component = () => { } }, selectAll() { - $('tbody > tr').each((i, e) => { - $(e).addClass('ui-selected'); + $("tbody > tr").each((i, e) => { + $(e).addClass("ui-selected"); }); }, clearSelection() { - $('tbody > tr').each((i, e) => { - $(e).removeClass('ui-selected'); + $("tbody > tr").each((i, e) => { + $(e).removeClass("ui-selected"); }); }, download() { - const selected = $('tbody > tr.ui-selected').get(); + 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', + 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 + title: this.mangaTitle, }), headers: { - "Content-Type": "application/json" - } + "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; - }); - }) + .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]); + const idx = parseInt(event.currentTarget.id.split("-")[1]); if (idx === undefined || isNaN(idx)) return; const curOption = this.sortOptions[idx]; let option; @@ -202,40 +227,58 @@ const component = () => { option = 1; } this.sortOptions[idx] = option; - this.sort(this.chapterKeys[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); + 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.value || isNaN(filter.value)) continue; + if (filter.type === "array" && filter.value === "all") continue; - console.log('applying filter:', filter); + console.log("applying filter:", filter); - if (filter.type === 'string') { - ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase())); + 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-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 === "number-max") { + ary = ary.filter( + (ch) => Number(ch[filter.key]) <= Number(filter.value) + ); } - if (filter.type === 'date-min') { - ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(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 => this.parseDate(ch[filter.key]) <= this.parseDate(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())); + 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); + console.log("filtered size:", ary.length); } return ary; @@ -258,124 +301,146 @@ const component = () => { compare(a, b) { if (a === b) return 0; - // try numbers - // this must come before the date checks, because any integer would - // also be parsed as a date. - if (!isNaN(a) && !isNaN(b)) - return Number(a) - Number(b); - - // try dates - if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b))) - return this.parseDate(a) - this.parseDate(b); + // 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(); + 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 => !isNaN(v))) return 'number'; // display input for number range - if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range - if (values.every(v => Array.isArray(v))) return 'array'; // display select - return 'string'; // display input for string searching. + if (values.every((v) => this.numIsDate(v))) return "date"; // 328896000000 => 1 Jan, 1980 + 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 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 (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(); - }))); + values = Array.from( + new Set( + values.flat().map((v) => { + if (typeof v === "string") + return v.toLowerCase(); + }) + ) + ); } return { key: k, type: type, - values: values + values: values, }; }); }, get filterSettings() { - return $('#filter-form input:visible, #filter-form select:visible') + return $("#filter-form input:visible, #filter-form select:visible") .get() - .map(i => ({ - key: i.getAttribute('data-filter-key'), - value: i.value.trim(), - type: i.getAttribute('data-filter-type') - })); + .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; }, clearFilters() { - $('#filter-form input').get().forEach(i => i.value = ''); + $("#filter-form input") + .get() + .forEach((i) => (i.value = "")); this.appliedFilters = []; this.chapters = this.filteredChapters; }, mangaSelected(event) { - const mid = event.currentTarget.getAttribute('data-id'); + const mid = event.currentTarget.getAttribute("data-id"); this.searchChapters(mid); }, - parseDate(str) { - const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g; - // Basic sanity check to make sure it's an actual date. - // We need this because Date.parse thinks 'Chapter 1' is a date. - if (!regex.test(str)) - return NaN; - return Date.parse(str); - }, subscribe(modal) { - // TODO: - // - use select2 - this.subscribing = true; fetch(`${base_url}api/admin/plugin/subscribe`, { - method: 'POST', - body: JSON.stringify({ - filters: this.filterSettings, - plugin: this.pid, - name: this.subscriptionName.trim() - }), - headers: { - "Content-Type": "application/json" - } - }) - .then(res => res.json()) - .then(data => { - if (!data.success) - throw new Error(data.error); - alert('success', 'Subscription created'); + method: "POST", + body: JSON.stringify({ + filters: this.filterSettings, + plugin: this.pid, + name: this.subscriptionName.trim(), + }), + 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}`); + .catch((e) => { + alert("danger", `Failed to subscribe. Error: ${e}`); }) .finally(() => { this.subscribing = false; UIkit.modal(modal).hide(); }); }, - filterTypeToReadable(type) { + 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.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': - return 'number (minimum value)'; - case 'number-max': - return 'number (maximum value)'; - case 'date-min': - return 'minimum date'; - case 'date-max': - return 'maximum date'; - default: - return 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 (!value || isNaN(value)) value = ""; + else if (ft.type.startsWith("date")) + value = moment(Number(value)).format("MMM D, YYYY"); + + return `${key}${type}${value}`; + }, + }; }; diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index 1d324f15..2bbda911 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -114,10 +114,10 @@
- +
- +
@@ -161,17 +161,7 @@ @@ -201,11 +191,7 @@ @@ -222,6 +208,7 @@ <% content_for "script" do %> <%= render_component "jquery-ui" %> + <%= render_component "moment" %> <% end %> From 352236ab65a0254e0341b747aa8478011c078598 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 20 Nov 2021 08:50:06 +0000 Subject: [PATCH 16/36] Delete unnecessary raise for debugging --- src/routes/api.cr | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/api.cr b/src/routes/api.cr index 6654714c..545533cc 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -649,7 +649,6 @@ struct APIRouter "success" => false, "error" => e.message, }.to_json - raise e end end From 5442d124af5e1f5f016361560c8dde8598ed70a4 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 20 Nov 2021 09:47:18 +0000 Subject: [PATCH 17/36] Subscription management API endpoints --- public/js/plugin-download.js | 2 +- src/plugin/plugin.cr | 2 +- src/routes/api.cr | 37 +++++++++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 752e978a..422f6130 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -378,7 +378,7 @@ const component = () => { }, subscribe(modal) { this.subscribing = true; - fetch(`${base_url}api/admin/plugin/subscribe`, { + fetch(`${base_url}api/admin/plugin/subscriptions`, { method: "POST", body: JSON.stringify({ filters: this.filterSettings, diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 7867a68c..e7439152 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -145,7 +145,7 @@ class Plugin def unsubscribe(id : String) list = SubscriptionList.new info.dir - list.reject &.id.== id + list.reject! &.id.== id list.save end diff --git a/src/routes/api.cr b/src/routes/api.cr index 545533cc..8ef02291 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -626,7 +626,7 @@ struct APIRouter end end - post "/api/admin/plugin/subscribe" do |env| + post "/api/admin/plugin/subscriptions" do |env| begin plugin_id = env.params.json["plugin"].as String filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f| @@ -652,6 +652,41 @@ struct APIRouter end end + 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 + + 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 "Lists the chapters in a title from a plugin" Koa.tags ["admin", "downloader"] Koa.query "plugin", schema: String From fe6f884d946f1e7810f865088d3b5e1493222982 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 20 Nov 2021 11:08:36 +0000 Subject: [PATCH 18/36] Store manga ID with subscriptions --- public/js/plugin-download.js | 3 +++ src/plugin/subscriptions.cr | 3 ++- src/routes/api.cr | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 422f6130..fd10e58f 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -5,6 +5,7 @@ const component = () => { 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: "", @@ -374,6 +375,7 @@ const component = () => { }, mangaSelected(event) { const mid = event.currentTarget.getAttribute("data-id"); + this.mid = mid; this.searchChapters(mid); }, subscribe(modal) { @@ -384,6 +386,7 @@ const component = () => { filters: this.filterSettings, plugin: this.pid, name: this.subscriptionName.trim(), + manga: this.mid, }), headers: { "Content-Type": "application/json", diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index 3c23848c..25939704 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -54,12 +54,13 @@ struct Subscription property id : String property plugin_id : String + property manga_id : String property name : String property created_at : Int64 property last_checked : Int64 property filters = [] of Filter - def initialize(@plugin_id, @name) + def initialize(@plugin_id, @manga_id, @name) @id = UUID.random.to_s @created_at = Time.utc.to_unix @last_checked = Time.utc.to_unix diff --git a/src/routes/api.cr b/src/routes/api.cr index 8ef02291..caedc30e 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -629,12 +629,13 @@ struct APIRouter post "/api/admin/plugin/subscriptions" do |env| begin plugin_id = env.params.json["plugin"].as String + manga_id = env.params.json["manga"].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, name + sub = Subscription.new plugin_id, manga_id, name sub.filters = filters plugin = Plugin.new plugin_id From b76d4645cc8222b1b107beb09de04f0b1bb83a25 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 21 Nov 2021 04:08:17 +0000 Subject: [PATCH 19/36] Add subscription manager page (WIP) --- public/js/subscription-manager.js | 57 +++++++++++++++++++++++++ src/routes/admin.cr | 4 ++ src/views/layout.html.ecr | 2 + src/views/subscription-manager.html.ecr | 29 +++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 public/js/subscription-manager.js create mode 100644 src/views/subscription-manager.html.ecr diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js new file mode 100644 index 00000000..f733cf42 --- /dev/null +++ b/public/js/subscription-manager.js @@ -0,0 +1,57 @@ +const component = () => { + return { + subscriptions: [], + plugins: [], + pid: undefined, + + 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; + + if (this.pid) 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) { + 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}` + ); + }); + }, + }; +}; diff --git a/src/routes/admin.cr b/src/routes/admin.cr index fd63ec82..c78df86c 100644 --- a/src/routes/admin.cr +++ b/src/routes/admin.cr @@ -70,6 +70,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/views/layout.html.ecr b/src/views/layout.html.ecr index c32bfb5b..3bdeb33f 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/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr new file mode 100644 index 00000000..8c0525b0 --- /dev/null +++ b/src/views/subscription-manager.html.ecr @@ -0,0 +1,29 @@ +

    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.

    +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    + +<% content_for "script" do %> + <%= render_component "moment" %> + + +<% end %> From 031ea7ef165c23c9c48862eed06e69f3b4728e50 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Thu, 25 Nov 2021 12:31:17 +0000 Subject: [PATCH 20/36] Finish subscription manager page --- public/js/plugin-download.js | 10 ++- public/js/subscription-manager.js | 76 ++++++++++++++++ src/views/subscription-manager.html.ecr | 110 +++++++++++++++++++----- 3 files changed, 173 insertions(+), 23 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index fd10e58f..2384e28a 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -237,8 +237,10 @@ const component = () => { console.log("initial size:", ary.length); for (let filter of this.appliedFilters) { - if (!filter.value || isNaN(filter.value)) continue; + if (!filter.value) continue; if (filter.type === "array" && filter.value === "all") continue; + if (filter.type.startsWith("number") && isNaN(filter.value)) + continue; console.log("applying filter:", filter); @@ -370,6 +372,7 @@ const component = () => { $("#filter-form input") .get() .forEach((i) => (i.value = "")); + $("#filter-form select").val("all"); this.appliedFilters = []; this.chapters = this.filteredChapters; }, @@ -439,8 +442,9 @@ const component = () => { break; } let value = ft.value; - if (!value || isNaN(value)) value = ""; - else if (ft.type.startsWith("date")) + + 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 index f733cf42..6d3f2afd 100644 --- a/public/js/subscription-manager.js +++ b/public/js/subscription-manager.js @@ -3,6 +3,8 @@ const component = () => { subscriptions: [], plugins: [], pid: undefined, + subscription: undefined, // selected subscription + loading: false, init() { fetch(`${base_url}api/admin/plugin`) @@ -53,5 +55,79 @@ const component = () => { ); }); }, + 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}`; + }, + action(event, type) { + if (this.loading) return; + this.loading = true; + const id = $(event.currentTarget).closest("tr").attr("sid"); + fetch( + `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams( + { + plugin: this.pid, + subscription: id, + } + )}`, + { + method: "DELETE", + } + ) + .then((response) => response.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + }) + .catch((e) => { + alert( + "danger", + `Failed to delete subscription. Error: ${e}` + ); + }) + .finally(() => { + this.loading = false; + this.list(this.pid); + }); + }, }; }; diff --git a/src/views/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr index 8c0525b0..02f04e94 100644 --- a/src/views/subscription-manager.html.ecr +++ b/src/views/subscription-manager.html.ecr @@ -1,29 +1,99 @@

    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.

    -
    +
    +
    +

    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 subscriptions found.

    + +
    + + + + + + + + + + + + + + +
    NamePlugin IDManga IDCreated AtLast CheckedActions
    +
    +
    +
    + +
    +
    +
    +

    Subscription Details

    +
    +
    +
    +
    Name
    +
    +
    Subscription ID
    +
    +
    Plugin ID
    +
    +
    Manga ID
    +
    +
    Filters
    +
    + + + + + + + + + + + +
    KeyTypeValue
    +

    + +

    +
    +
    <% content_for "script" do %> - <%= render_component "moment" %> - - + <%= render_component "moment" %> + + <% end %> From 748386f0af9869381258032ce89343c667c29a63 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Fri, 31 Dec 2021 14:49:37 +0000 Subject: [PATCH 21/36] WIP --- src/plugin/subscriptions.cr | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index 25939704..5a190275 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -1,4 +1,5 @@ require "uuid" +require "big" enum FilterType String @@ -47,6 +48,23 @@ struct Filter _value.as_f32? || nil self.new key, value, type end + + def match_chapter(obj : JSON::Any) : Bool + raw_value = obj[key] + case type + when FilterType::String + raw_value.as_s.lower == value.as_s.lower + when FilterType::NumMin + when FilterType::DateMin + BigFloat.new(raw_value) >= BigFloat.new(value) + when FilterType::NumMax + when FilterType::DateMax + BigFloat.new(raw_value) <= BigFloat.new(value) + when FilterType::Array + raw_value.as_s.lower.split(",") + .map(&.strip).include? value.as_s.lower.strip + end + end end struct Subscription @@ -65,6 +83,10 @@ struct Subscription @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 From 031df3a2bc8a6f03e1138695dff56efe3be91840 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 22 Jan 2022 13:11:58 +0000 Subject: [PATCH 22/36] Finish plugin updater --- public/js/plugin-download.js | 19 ++++----- src/config.cr | 1 + src/mango.cr | 1 + src/plugin/plugin.cr | 4 ++ src/plugin/subscriptions.cr | 38 ++++++++++++------ src/plugin/updater.cr | 74 ++++++++++++++++++++++++++++++++++++ src/routes/api.cr | 5 ++- 7 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 src/plugin/updater.cr diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 75a33130..ef18d577 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -100,14 +100,14 @@ const component = () => { this.mangaTitle = data.title; } - data.chapters.forEach((c) => { - c.array = ["hello", "world", "haha", "wtf"] - .sort(() => 0.5 - Math.random()) - .slice(0, 2); - c.date = [612892800000, "1625068800000"].sort( - () => 0.5 - Math.random() - )[0]; - }); + //data.chapters.forEach((c) => { + //c.array = ["hello", "world", "haha", "wtf"] + //.sort(() => 0.5 - Math.random()) + //.slice(0, 2); + //c.date = [612892800000, "1625068800000"].sort( + //() => 0.5 - Math.random() + //)[0]; + //}); this.allChapters = data.chapters; this.chapters = data.chapters; @@ -387,7 +387,8 @@ const component = () => { filters: this.filterSettings, plugin: this.pid, name: this.subscriptionName.trim(), - manga: this.mid, + manga: this.mangaTitle, + manga_id: this.mid, }), headers: { "Content-Type": "application/json", diff --git a/src/config.cr b/src/config.cr index aa818c32..41a4cf10 100644 --- a/src/config.cr +++ b/src/config.cr @@ -28,6 +28,7 @@ class Config property disable_login = false property default_username = "" property auth_proxy_header_name = "" + property plugin_update_interval_hours : Int32 = 24 property mangadex = Hash(String, String | Int32).new @[YAML::Field(ignore: true)] diff --git a/src/mango.cr b/src/mango.cr index 8716d044..c3e86cd8 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.new spawn do begin diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index e7439152..7f44ccc3 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -143,6 +143,10 @@ class Plugin 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 diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index 5a190275..f60e11c5 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -50,35 +50,40 @@ struct Filter 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.lower == value.as_s.lower - when FilterType::NumMin - when FilterType::DateMin - BigFloat.new(raw_value) >= BigFloat.new(value) - when FilterType::NumMax - when FilterType::DateMax - BigFloat.new(raw_value) <= BigFloat.new(value) + raw_value.as_s.downcase == value.to_s.downcase + when FilterType::NumMin, FilterType::DateMin + raw_value.as_bf >= BigFloat.new value.not_nil!.to_f32 + when FilterType::NumMax, FilterType::DateMax + raw_value.as_bf <= BigFloat.new value.not_nil!.to_f32 when FilterType::Array - raw_value.as_s.lower.split(",") - .map(&.strip).include? value.as_s.lower.strip + return true if value == "all" + raw_value.as_s.downcase.split(",") + .map(&.strip).includes? value.to_s.downcase.strip + else + false end end end -struct Subscription +# 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, @name) + 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 @@ -108,3 +113,14 @@ struct SubscriptionList File.write @path, @ary.to_pretty_json end end + +struct JSON::Any + def as_bf : BigFloat + i64_or_f32 = begin + as_i64 + rescue + as_f32 + end + BigFloat.new i64_or_f32 + end +end diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr new file mode 100644 index 00000000..4b69826b --- /dev/null +++ b/src/plugin/updater.cr @@ -0,0 +1,74 @@ +require "../config" + +class Plugin + class Updater + 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." + return + end + Logger.debug "Found #{matches.size} new chapters. " \ + "Pushing to download queue" + jobs = matches.map { |ch| + Queue::Job.new( + "#{plugin.info.id}-#{ch["id"]}", + "", # 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/routes/api.cr b/src/routes/api.cr index 5d589b58..4489f85e 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -661,13 +661,14 @@ struct APIRouter post "/api/admin/plugin/subscriptions" do |env| begin plugin_id = env.params.json["plugin"].as String - manga_id = env.params.json["manga"].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, name + sub = Subscription.new plugin_id, manga_id, manga_title, name sub.filters = filters plugin = Plugin.new plugin_id From d862c386f15ae12e4d785d0d512e6a86266fd475 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 06:08:20 +0000 Subject: [PATCH 23/36] Base64 encode chapter IDs --- src/plugin/updater.cr | 2 +- src/queue.cr | 8 +++++++- src/routes/api.cr | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr index 4b69826b..e4909400 100644 --- a/src/plugin/updater.cr +++ b/src/plugin/updater.cr @@ -49,7 +49,7 @@ class Plugin "Pushing to download queue" jobs = matches.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, sub.manga_title, diff --git a/src/queue.cr b/src/queue.cr index 381441b6..dc15d36b 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/api.cr b/src/routes/api.cr index 4489f85e..47e55585 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -785,7 +785,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, From 968f6246ded546b7be2d36a34c20c5c5297e7b27 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 08:46:19 +0000 Subject: [PATCH 24/36] Fix actions on download manager --- public/js/download-manager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/download-manager.js b/public/js/download-manager.js index 0393dd37..1183ce53 100644 --- a/public/js/download-manager.js +++ b/public/js/download-manager.js @@ -55,7 +55,7 @@ const component = () => { jobAction(action, event) { let url = `${base_url}api/admin/mangadex/queue/${action}`; if (event) { - const id = event.currentTarget.closest('tr').id.split('-')[1]; + const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-'); url = `${url}?${$.param({ id: id })}`; From 2c7c29fef9940ca8f823236814a5044eb7fb40e2 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 09:45:45 +0000 Subject: [PATCH 25/36] Trigger subscription update from manager page --- public/js/subscription-manager.js | 8 +++++--- src/mango.cr | 2 +- src/plugin/plugin.cr | 5 +++++ src/plugin/updater.cr | 4 ++-- src/routes/api.cr | 17 +++++++++++++++++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js index 6d3f2afd..7206c56b 100644 --- a/public/js/subscription-manager.js +++ b/public/js/subscription-manager.js @@ -104,24 +104,26 @@ const component = () => { this.loading = true; const id = $(event.currentTarget).closest("tr").attr("sid"); fetch( - `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams( + `${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams( { plugin: this.pid, subscription: id, } )}`, { - method: "DELETE", + 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 delete subscription. Error: ${e}` + `Failed to ${type} subscription. Error: ${e}` ); }) .finally(() => { diff --git a/src/mango.cr b/src/mango.cr index c3e86cd8..b19799f3 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -61,7 +61,7 @@ class CLI < Clim Library.load_instance Library.default Plugin::Downloader.default - Plugin::Updater.new + Plugin::Updater.default spawn do begin diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 7f44ccc3..76f1f1f0 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -153,6 +153,11 @@ class Plugin list.save end + def check_subscription(id : String) + sub = list_subscriptions_raw.find &.id.== id + Plugin::Updater.default.check_subscription self, sub.not_nil! + end + def initialize(id : String) Plugin.build_info_ary diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr index e4909400..d4fc7432 100644 --- a/src/plugin/updater.cr +++ b/src/plugin/updater.cr @@ -1,7 +1,7 @@ -require "../config" - class Plugin class Updater + use_default + def initialize interval = Config.current.plugin_update_interval_hours return if interval <= 0 diff --git a/src/routes/api.cr b/src/routes/api.cr index 47e55585..ffed75f3 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -721,6 +721,23 @@ struct APIRouter end end + 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 From cae832911d604d7853ed3c182017db08ff374332 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 09:47:00 +0000 Subject: [PATCH 26/36] Fix timestamp precision issue in plugin --- src/plugin/plugin.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 76f1f1f0..a96f0aa4 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -300,7 +300,9 @@ class Plugin end def new_chapters(manga_id : String, after : Int64) - json = eval_json "newChapters('#{manga_id}', #{after})" + # 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 From be3babda376a0e5358825d887de3bd5c8e355e2f Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 11:06:07 +0000 Subject: [PATCH 27/36] Show target API version --- src/views/plugin-download.html.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index 2bbda911..7c3b4d55 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -46,7 +46,7 @@ From f6bd3fa15d097541e06b2ff1f36a4a0e66dcda41 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 12:23:58 +0000 Subject: [PATCH 28/36] Update last checked from manager page --- src/plugin/plugin.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index a96f0aa4..5175b3a0 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -154,8 +154,10 @@ class Plugin end def check_subscription(id : String) - sub = list_subscriptions_raw.find &.id.== id + 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) From 0adcd3a63440ddefd5d633670ff578768f2755d6 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 23 Jan 2022 12:24:19 +0000 Subject: [PATCH 29/36] Update last checked even when no chapters found --- src/plugin/updater.cr | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr index d4fc7432..81ba8c89 100644 --- a/src/plugin/updater.cr +++ b/src/plugin/updater.cr @@ -43,6 +43,7 @@ class Plugin 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. " \ From fd650bdf45272f66ebf35438abbeb01699a8e53b Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Feb 2022 03:58:36 +0000 Subject: [PATCH 30/36] Fix null pid --- public/js/subscription-manager.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js index 7206c56b..8e49c05c 100644 --- a/public/js/subscription-manager.js +++ b/public/js/subscription-manager.js @@ -19,7 +19,7 @@ const component = () => { else if (this.plugins.length > 0) this.pid = this.plugins[0].id; - if (this.pid) this.list(pid); + this.list(pid); }) .catch((e) => { alert( @@ -33,6 +33,7 @@ const component = () => { this.list(this.pid); }, list(pid) { + if (!pid) return; fetch( `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams( { From b56a9cb50c291f1bc5bea4c8e81f1f389722fae3 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Feb 2022 04:48:05 +0000 Subject: [PATCH 31/36] Clean up --- public/js/plugin-download.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index ef18d577..7e13ded2 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -100,15 +100,6 @@ const component = () => { this.mangaTitle = data.title; } - //data.chapters.forEach((c) => { - //c.array = ["hello", "world", "haha", "wtf"] - //.sort(() => 0.5 - Math.random()) - //.slice(0, 2); - //c.date = [612892800000, "1625068800000"].sort( - //() => 0.5 - Math.random() - //)[0]; - //}); - this.allChapters = data.chapters; this.chapters = data.chapters; }) @@ -313,7 +304,7 @@ const component = () => { return preprocessString(a) > preprocessString(b) ? 1 : -1; }, fieldType(values) { - if (values.every((v) => this.numIsDate(v))) return "date"; // 328896000000 => 1 Jan, 1980 + 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"; From 2ade6ebd8c4d49e8bbb1a4009e17602a5ca8ad1c Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Feb 2022 06:09:16 +0000 Subject: [PATCH 32/36] Document the subscription endpoints --- src/routes/api.cr | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/routes/api.cr b/src/routes/api.cr index 3b33ecac..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 @@ -658,6 +675,16 @@ struct APIRouter 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 @@ -686,6 +713,14 @@ struct APIRouter 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 @@ -702,6 +737,13 @@ struct APIRouter 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 @@ -721,6 +763,13 @@ struct APIRouter 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 From c290eee90b74f0fdc23f8215b95fa7060eecbcee Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Tue, 15 Mar 2022 13:58:16 +0000 Subject: [PATCH 33/36] Fix BigFloat conversion issue --- src/plugin/subscriptions.cr | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr index f60e11c5..153667d7 100644 --- a/src/plugin/subscriptions.cr +++ b/src/plugin/subscriptions.cr @@ -56,9 +56,9 @@ struct Filter when FilterType::String raw_value.as_s.downcase == value.to_s.downcase when FilterType::NumMin, FilterType::DateMin - raw_value.as_bf >= BigFloat.new value.not_nil!.to_f32 + BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32 when FilterType::NumMax, FilterType::DateMax - raw_value.as_bf <= BigFloat.new value.not_nil!.to_f32 + 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(",") @@ -113,14 +113,3 @@ struct SubscriptionList File.write @path, @ary.to_pretty_json end end - -struct JSON::Any - def as_bf : BigFloat - i64_or_f32 = begin - as_i64 - rescue - as_f32 - end - BigFloat.new i64_or_f32 - end -end From 95eb208901caa0d9b86c86e9a0013e418d3b3a96 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 19 Mar 2022 11:13:48 +0000 Subject: [PATCH 34/36] Confirmation before deleting subscriptions --- public/js/subscription-manager.js | 15 +++++++++++++-- src/views/subscription-manager.html.ecr | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js index 8e49c05c..fad4e56c 100644 --- a/public/js/subscription-manager.js +++ b/public/js/subscription-manager.js @@ -100,10 +100,21 @@ const component = () => { return `${key}${type}${value}`; }, - action(event, type) { + 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; - const id = $(event.currentTarget).closest("tr").attr("sid"); fetch( `${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams( { diff --git a/src/views/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr index 02f04e94..b1b49cd4 100644 --- a/src/views/subscription-manager.html.ecr +++ b/src/views/subscription-manager.html.ecr @@ -42,8 +42,8 @@ - - + + From 2cc1a06b4e85696099b4617e6185e4e6c32c6360 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 19 Mar 2022 11:23:23 +0000 Subject: [PATCH 35/36] Reset table sort options --- public/js/plugin-download.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 7e13ded2..2e9d0a0b 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -82,6 +82,7 @@ const component = () => { searchChapters(query) { this.searching = true; this.allChapters = []; + this.sortOptions = []; this.chapters = undefined; this.listManga = false; fetch( @@ -356,6 +357,7 @@ const component = () => { applyFilters() { this.appliedFilters = this.filterSettings; this.chapters = this.filteredChapters; + this.sortOptions = []; }, clearFilters() { $("#filter-form input") @@ -364,6 +366,7 @@ const component = () => { $("#filter-form select").val("all"); this.appliedFilters = []; this.chapters = this.filteredChapters; + this.sortOptions = []; }, mangaSelected(event) { const mid = event.currentTarget.getAttribute("data-id"); From cdfc9f3a9399266973ecd02c6be7425ae7f029b0 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 19 Mar 2022 11:39:20 +0000 Subject: [PATCH 36/36] Show manga title on subscription manager --- src/views/subscription-manager.html.ecr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr index b1b49cd4..45774c16 100644 --- a/src/views/subscription-manager.html.ecr +++ b/src/views/subscription-manager.html.ecr @@ -27,7 +27,7 @@ Name Plugin ID - Manga ID + Manga Title Created At Last Checked Actions @@ -38,7 +38,7 @@ - + @@ -66,6 +66,8 @@
    Plugin ID
    +
    Manga Title
    +
    Manga ID
    Filters