From df8a6ee6da4be485673228e8e265195035ff687c Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Thu, 23 Jul 2020 09:30:05 +0000 Subject: [PATCH] Finish plugin functionalities --- public/js/download-manager.js | 5 +- public/js/plugin-download.js | 93 +++++++++++ src/plugin/downloader.cr | 14 +- src/plugin/plugin.cr | 233 +++++++++++++++++++++------- src/queue.cr | 4 +- src/routes/api.cr | 34 +++- src/routes/main.cr | 18 ++- src/util/util.cr | 6 + src/views/download-manager.html.ecr | 1 + src/views/plugin-download.html.ecr | 20 ++- 10 files changed, 360 insertions(+), 68 deletions(-) diff --git a/public/js/download-manager.js b/public/js/download-manager.js index 9ab407e..51869c0 100644 --- a/public/js/download-manager.js +++ b/public/js/download-manager.js @@ -119,11 +119,12 @@ const load = () => { const dropdown = obj.status_message.length > 0 ? `
${obj.status_message}
` : ''; const retryBtn = obj.status_message.length > 0 ? `` : ''; return ` - ${obj.title} - ${obj.manga_title} + ${obj.plugin_name ? obj.title : `${obj.title}`} + ${obj.plugin_name ? obj.manga_title : `${obj.manga_title}`} ${obj.success_count}/${obj.pages} ${moment(obj.time).fromNow()} ${statusSpan} ${dropdown} + ${obj.plugin_name || ""} ${retryBtn} diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index f785a13..66a8527 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -4,8 +4,18 @@ $(() => { search(); } }); + $('#plugin-select').change(() => { + const title = $('#plugin-select').val(); + const url = `${location.protocol}//${location.host}${location.pathname}`; + const newURL = `${url}?${$.param({ + plugin: encodeURIComponent(title) + })}`; + window.location.href = newURL; + }); + $('#plugin-select').val(plugin); }); +let mangaTitle = ""; let searching = false; const search = () => { if (searching) @@ -28,9 +38,92 @@ const search = () => { 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(() => {}); }; + +const buildTable = (chapters) => { + $('#table').attr('hidden', ''); + $('table').empty(); + + const keys = Object.keys(chapters[0]).map(k => `${k}`).join(''); + const thead = `${keys}`; + $('table').append(thead); + + const rows = chapters.map(ch => { + const tds = Object.values(ch).map(v => `${v}`).join(''); + return `${tds}`; + }); + const tbody = `${rows}`; + $('table').append(tbody); + + $('#selectable').selectable({ + filter: 'tr' + }); + + $('#table').removeAttr('hidden'); +}; + +const selectAll = () => { + $('tbody > tr').each((i, e) => { + $(e).addClass('ui-selected'); + }); +}; + +const unselect = () => { + $('tbody > tr').each((i, e) => { + $(e).removeClass('ui-selected'); + }); +}; + +const download = () => { + const selected = $('tbody > tr.ui-selected'); + if (selected.length === 0) return; + UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => { + $('#download-btn').attr('hidden', ''); + $('#download-spinner').removeAttr('hidden'); + const chapters = selected.map((i, e) => { + return { + id: $(e).attr('data-id'), + title: $(e).attr('data-title') + } + }).get(); + console.log(chapters); + $.ajax({ + type: 'POST', + url: base_url + 'api/admin/plugin/download', + data: JSON.stringify({ + plugin: plugin, + chapters: chapters, + title: mangaTitle + }), + contentType: "application/json", + dataType: 'json' + }) + .done(data => { + console.log(data); + if (data.error) { + alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`); + return; + } + const successCount = parseInt(data.success); + const failCount = parseInt(data.fail); + UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { + window.location.href = base_url + 'admin/downloads'; + }); + }) + .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'); + }); + }); +}; diff --git a/src/plugin/downloader.cr b/src/plugin/downloader.cr index 4b32ddd..7847526 100644 --- a/src/plugin/downloader.cr +++ b/src/plugin/downloader.cr @@ -35,20 +35,22 @@ class Plugin raise "Job does not have plugin name specificed" end - plugin = Plugin.new job.plugin_name.not_nil! - info = plugin.select_chapter job.id + plugin = Plugin.new_from_id job.plugin_name.not_nil! + info = plugin.select_chapter job.plugin_chapter_id.not_nil! - title = process_filename info["title"].as_s pages = info["pages"].as_i + manga_title = process_filename job.manga_title + chapter_title = process_filename info["title"].as_s + @queue.set_pages pages, job lib_dir = @library_path - manga_dir = File.join lib_dir, title + manga_dir = File.join lib_dir, manga_title unless File.exists? manga_dir Dir.mkdir_p manga_dir end - zip_path = File.join manga_dir, "#{job.title}.cbz.part" + zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" writer = Zip::Writer.new zip_path rescue e @queue.set_status Queue::JobStatus::Error, job @@ -76,7 +78,7 @@ class Plugin tries = 4 loop do - sleep plugin.wait_seconds.seconds + sleep plugin.info.wait_seconds.seconds Logger.debug "downloading #{url}" tries -= 1 diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 1769e73..7930f9c 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -1,6 +1,7 @@ require "duktape/runtime" require "myhtml" require "http" +require "xml" class Plugin class Error < ::Exception @@ -15,70 +16,164 @@ class Plugin class SyntaxError < Error end - {% for name in ["id", "title", "author", "version", "placeholder"] %} - getter {{name.id}} = "" - {% end %} - getter wait_seconds : UInt64 = 0 - getter filename + struct Info + {% for name in ["id", "title", "author", "version", "placeholder"] %} + getter {{name.id}} = "" + {% end %} + getter wait_seconds : UInt64 = 0 + getter dir : String - def self.list - dir = Config.current.plugin_path - Dir.mkdir_p dir unless Dir.exists? dir + def initialize(@dir) + info_path = File.join @dir, "info.json" - Dir.children(dir) - .select do |f| - fp = File.join dir, f - File.file?(fp) && File.extname(fp) == ".js" + unless File.exists? info_path + raise MetadataError.new "File `info.json` not found in the " \ + "plugin directory #{dir}" end - .map do |f| - File.basename f, ".js" + + json = JSON.parse File.read info_path + + begin + {% for name in ["id", "title", "author", "version", "placeholder"] %} + @{{name.id}} = json[{{name}}].as_s + {% end %} + @wait_seconds = json["wait_seconds"].as_i.to_u64 + + unless @id.alphanumeric_underscore? + raise "Plugin ID can only contain alphanumeric characters and " \ + "underscores" + end + rescue e + raise MetadataError.new "Failed to retrieve metadata from plugin " \ + "at #{@dir}. Error: #{e.message}" end + end end - def initialize(@filename : String) + struct State + @hash = {} of String => String + + def initialize(@path : String) + unless File.exists? @path + save + end + + json = JSON.parse File.read @path + json.as_h.each do |k, v| + @hash[k] = v.as_s + end + end + + def []?(key) + @hash[key]? + end + + def []=(key, val : String) + @hash[key] = val + end + + def save + File.write @path, @hash.to_pretty_json + end + end + + @@info_ary = [] of Info + @info : Info? + + getter js_path = "" + getter state_path = "" + + def self.build_info_ary + return unless @@info_ary.empty? + dir = Config.current.plugin_path Dir.mkdir_p dir unless Dir.exists? dir - @path = File.join dir, "#{filename}.js" - unless File.exists? @path - raise Error.new "Plugin script not found at #{@path}" + Dir.each_child dir do |f| + path = File.join dir, f + next unless File.directory? path + + begin + @@info_ary << Info.new path + rescue e : MetadataError + Logger.warn e + end + end + end + + def self.list + self.build_info_ary + + @@info_ary.map &.title + end + + def info + @info.not_nil! + end + + def self.new_from_id(id : String) + self.build_info_ary + + info = @@info_ary.find { |i| i.id == id } + raise Error.new "Plugin with id #{id} not found" unless info + self.new info.title + end + + def initialize(title : String) + Plugin.build_info_ary + + @info = @@info_ary.find { |i| i.title == title } + if @info.nil? + raise Error.new "Plugin with title #{title} not found" + end + + @js_path = File.join info.dir, "main.js" + @state_path = File.join info.dir, "state.json" + + unless File.exists? @js_path + raise Error.new "Plugin script not found at #{@js_path}" end @rt = Duktape::Runtime.new do |sbx| sbx.push_global_object - sbx.del_prop_string -1, "print" - sbx.del_prop_string -1, "alert" - sbx.del_prop_string -1, "console" + sbx.push_pointer @state_path.as(Void*) + path = sbx.require_pointer(-1).as String + sbx.pop + sbx.push_string path + sbx.put_prop_string -2, "state_path" def_helper_functions sbx end - eval File.read @path + eval File.read @js_path + end - begin - data = eval_json "metadata" - {% for name in ["id", "title", "author", "version", "placeholder"] %} - @{{name.id}} = data[{{name}}].as_s - {% end %} - @wait_seconds = data["wait_seconds"].as_i.to_u64 - rescue e - raise MetadataError.new "Failed to retrieve metadata from plugin " \ - "at #{@path}. Error: #{e.message}" - end + macro check_fields(ary) + {% for field in ary %} + unless json[{{field}}]? + raise "Field `{{field.id}}` is missing from the function outputs" + end + {% end %} end def search(query : String) json = eval_json "search('#{query}')" begin - ary = json.as_a + check_fields ["title", "chapters"] + + ary = json["chapters"].as_a ary.each do |obj| id = obj["id"]? raise "Field `id` missing from `search` outputs" if id.nil? - unless id.to_s.chars.all? &.number? - raise "The `id` values must be numeric" unless id + 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 `search` outputs" if title.nil? end rescue e raise Error.new e.message @@ -89,12 +184,11 @@ class Plugin def select_chapter(id : String) json = eval_json "selectChapter('#{id}')" begin - {% for field in ["title", "pages"] %} - unless json[{{field}}]? - raise "Field `{{field.id}}` is missing from the " \ - "`selectChapter` outputs" - end - {% end %} + check_fields ["title", "pages"] + + if json["title"].to_s.empty? + raise "The `title` field of the chapter can not be empty" + end rescue e raise Error.new e.message end @@ -105,12 +199,7 @@ class Plugin json = eval_json "nextPage()" return if json.size == 0 begin - {% for field in ["filename", "url"] %} - unless json[{{field}}]? - raise "Field `{{field.id}}` is missing from the " \ - "`nextPage` outputs" - end - {% end %} + check_fields ["filename", "url"] rescue e raise Error.new e.message end @@ -173,16 +262,28 @@ class Plugin env = Duktape::Sandbox.new ptr html = env.require_string 0 - myhtml = Myhtml::Parser.new html - root = myhtml.root - - str = "" - str = root.inner_text if root + str = XML.parse(html).inner_text env.push_string str env.call_success end - sbx.put_prop_string -2, "innerText" + sbx.put_prop_string -2, "text" + + sbx.push_proc 2 do |ptr| + env = Duktape::Sandbox.new ptr + html = env.require_string 0 + name = env.require_string 1 + + begin + attr = XML.parse(html).first_element_child.not_nil![name] + env.push_string attr + rescue + env.push_undefined + end + + env.call_success + end + sbx.put_prop_string -2, "attribute" sbx.push_proc 1 do |ptr| env = Duktape::Sandbox.new ptr @@ -193,6 +294,32 @@ class Plugin end sbx.put_prop_string -2, "raise" + sbx.push_proc LibDUK::VARARGS do |ptr| + env = Duktape::Sandbox.new ptr + key = env.require_string 0 + + env.get_global_string "state_path" + state_path = env.require_string -1 + env.pop + state = State.new state_path + + if env.get_top == 2 + val = env.require_string 1 + state[key] = val + state.save + else + val = state[key]? + if val + env.push_string val + else + env.push_undefined + end + end + + env.call_success + end + sbx.put_prop_string -2, "state" + sbx.put_prop_string -2, "mango" end end diff --git a/src/queue.cr b/src/queue.cr index 3b745d9..cec40f8 100644 --- a/src/queue.cr +++ b/src/queue.cr @@ -51,6 +51,7 @@ class Queue property fail_count : Int32 = 0 property time : Time property plugin_name : String? + property plugin_chapter_id : String? def parse_query_result(res : DB::ResultSet) @id = res.read String @@ -69,7 +70,7 @@ class Queue ary = @id.split("-") if ary.size == 2 @plugin_name = ary[0] - @id = ary[1] + @plugin_chapter_id = ary[1] end end @@ -99,6 +100,7 @@ class Queue json.field "time" do json.number @time.to_unix_ms end + json.field "plugin_name", @plugin_name if @plugin_name end end end diff --git a/src/routes/api.cr b/src/routes/api.cr index 1c39a41..320499a 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -265,11 +265,43 @@ class APIRouter < Router query = env.params.json["query"].as String plugin = Plugin.new env.params.json["plugin"].as String - chapters = plugin.search query + json = plugin.search query + chapters = json["chapters"] + title = json["title"] send_json env, { "success" => true, "chapters" => chapters, + "title" => title, + }.to_json + rescue e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + post "/api/admin/plugin/download" do |env| + begin + plugin = Plugin.new env.params.json["plugin"].as String + chapters = env.params.json["chapters"].as Array(JSON::Any) + manga_title = env.params.json["title"].as String + + jobs = chapters.map { |ch| + Queue::Job.new( + "#{plugin.info.id}-#{ch["id"]}", + "", # manga_id + ch["title"].as_s, + manga_title, + Queue::JobStatus::Pending, + Time.utc + ) + } + inserted_count = @context.queue.push jobs + send_json env, { + "success": inserted_count, + "fail": jobs.size - inserted_count, }.to_json rescue e send_json env, { diff --git a/src/routes/main.cr b/src/routes/main.cr index dfd452a..db1b272 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -79,9 +79,21 @@ class MainRouter < Router end get "/download/plugins" do |env| - plugins = Plugin.list - plugin = Plugin.new plugins[0] - layout "plugin-download" + begin + title = env.params.query["plugin"]? + plugins = Plugin.list + + if title + plugin = Plugin.new title + else + plugin = Plugin.new plugins[0] + end + + layout "plugin-download" + rescue e + @context.error e + env.response.status_code = 500 + end end get "/" do |env| diff --git a/src/util/util.cr b/src/util/util.cr index b26979a..1c7330c 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -54,3 +54,9 @@ macro use_default @@default.not_nil! end end + +class String + def alphanumeric_underscore? + self.chars.all? { |c| c.alphanumeric? || c == '_' } + end +end diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr index 0372010..ec8618a 100644 --- a/src/views/download-manager.html.ecr +++ b/src/views/download-manager.html.ecr @@ -17,6 +17,7 @@ Progress Time Status + Plugin Actions diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index faf502a..86f61e0 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -13,7 +13,7 @@
- +
@@ -30,13 +30,29 @@ + + + <% end %> <% content_for "script" do %> + <% end %>