Finish plugin functionalities

This commit is contained in:
Alex Ling 2020-07-23 09:30:05 +00:00
parent 70ea1711ce
commit df8a6ee6da
10 changed files with 360 additions and 68 deletions

View File

@ -119,11 +119,12 @@ const load = () => {
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : ''; const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : ''; const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}"> return `<tr id="chapter-${obj.id}">
<td><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td> <td>${obj.plugin_name ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
<td><a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a></td> <td>${obj.plugin_name ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
<td>${obj.success_count}/${obj.pages}</td> <td>${obj.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td> <td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td> <td>${statusSpan} ${dropdown}</td>
<td>${obj.plugin_name || ""}</td>
<td> <td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a> <a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn} ${retryBtn}

View File

@ -4,8 +4,18 @@ $(() => {
search(); 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; let searching = false;
const search = () => { const search = () => {
if (searching) if (searching)
@ -28,9 +38,92 @@ const search = () => {
alert('danger', `Search failed. Error: ${data.error}`); alert('danger', `Search failed. Error: ${data.error}`);
return; return;
} }
mangaTitle = data.title;
$('#title-text').text(data.title);
buildTable(data.chapters);
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.always(() => {}); .always(() => {});
}; };
const buildTable = (chapters) => {
$('#table').attr('hidden', '');
$('table').empty();
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
const thead = `<thead><tr>${keys}</tr></thead>`;
$('table').append(thead);
const rows = chapters.map(ch => {
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
});
const tbody = `<tbody id="selectable">${rows}</tbody>`;
$('table').append(tbody);
$('#selectable').selectable({
filter: 'tr'
});
$('#table').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');
});
});
};

View File

@ -35,20 +35,22 @@ class Plugin
raise "Job does not have plugin name specificed" raise "Job does not have plugin name specificed"
end end
plugin = Plugin.new job.plugin_name.not_nil! plugin = Plugin.new_from_id job.plugin_name.not_nil!
info = plugin.select_chapter job.id info = plugin.select_chapter job.plugin_chapter_id.not_nil!
title = process_filename info["title"].as_s
pages = info["pages"].as_i 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 @queue.set_pages pages, job
lib_dir = @library_path lib_dir = @library_path
manga_dir = File.join lib_dir, title manga_dir = File.join lib_dir, manga_title
unless File.exists? manga_dir unless File.exists? manga_dir
Dir.mkdir_p manga_dir Dir.mkdir_p manga_dir
end 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 writer = Zip::Writer.new zip_path
rescue e rescue e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
@ -76,7 +78,7 @@ class Plugin
tries = 4 tries = 4
loop do loop do
sleep plugin.wait_seconds.seconds sleep plugin.info.wait_seconds.seconds
Logger.debug "downloading #{url}" Logger.debug "downloading #{url}"
tries -= 1 tries -= 1

View File

@ -1,6 +1,7 @@
require "duktape/runtime" require "duktape/runtime"
require "myhtml" require "myhtml"
require "http" require "http"
require "xml"
class Plugin class Plugin
class Error < ::Exception class Error < ::Exception
@ -15,70 +16,164 @@ class Plugin
class SyntaxError < Error class SyntaxError < Error
end end
{% for name in ["id", "title", "author", "version", "placeholder"] %} struct Info
getter {{name.id}} = "" {% for name in ["id", "title", "author", "version", "placeholder"] %}
{% end %} getter {{name.id}} = ""
getter wait_seconds : UInt64 = 0 {% end %}
getter filename getter wait_seconds : UInt64 = 0
getter dir : String
def self.list def initialize(@dir)
dir = Config.current.plugin_path info_path = File.join @dir, "info.json"
Dir.mkdir_p dir unless Dir.exists? dir
Dir.children(dir) unless File.exists? info_path
.select do |f| raise MetadataError.new "File `info.json` not found in the " \
fp = File.join dir, f "plugin directory #{dir}"
File.file?(fp) && File.extname(fp) == ".js"
end 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
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 = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir Dir.mkdir_p dir unless Dir.exists? dir
@path = File.join dir, "#{filename}.js" Dir.each_child dir do |f|
unless File.exists? @path path = File.join dir, f
raise Error.new "Plugin script not found at #{@path}" 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 end
@rt = Duktape::Runtime.new do |sbx| @rt = Duktape::Runtime.new do |sbx|
sbx.push_global_object sbx.push_global_object
sbx.del_prop_string -1, "print" sbx.push_pointer @state_path.as(Void*)
sbx.del_prop_string -1, "alert" path = sbx.require_pointer(-1).as String
sbx.del_prop_string -1, "console" sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "state_path"
def_helper_functions sbx def_helper_functions sbx
end end
eval File.read @path eval File.read @js_path
end
begin macro check_fields(ary)
data = eval_json "metadata" {% for field in ary %}
{% for name in ["id", "title", "author", "version", "placeholder"] %} unless json[{{field}}]?
@{{name.id}} = data[{{name}}].as_s raise "Field `{{field.id}}` is missing from the function outputs"
{% end %} end
@wait_seconds = data["wait_seconds"].as_i.to_u64 {% end %}
rescue e
raise MetadataError.new "Failed to retrieve metadata from plugin " \
"at #{@path}. Error: #{e.message}"
end
end end
def search(query : String) def search(query : String)
json = eval_json "search('#{query}')" json = eval_json "search('#{query}')"
begin begin
ary = json.as_a check_fields ["title", "chapters"]
ary = json["chapters"].as_a
ary.each do |obj| ary.each do |obj|
id = obj["id"]? id = obj["id"]?
raise "Field `id` missing from `search` outputs" if id.nil? raise "Field `id` missing from `search` outputs" if id.nil?
unless id.to_s.chars.all? &.number? unless id.to_s.alphanumeric_underscore?
raise "The `id` values must be numeric" unless id raise "The `id` field can only contain alphanumeric characters and " \
"underscores"
end end
title = obj["title"]?
raise "Field `title` missing from `search` outputs" if title.nil?
end end
rescue e rescue e
raise Error.new e.message raise Error.new e.message
@ -89,12 +184,11 @@ class Plugin
def select_chapter(id : String) def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')" json = eval_json "selectChapter('#{id}')"
begin begin
{% for field in ["title", "pages"] %} check_fields ["title", "pages"]
unless json[{{field}}]?
raise "Field `{{field.id}}` is missing from the " \ if json["title"].to_s.empty?
"`selectChapter` outputs" raise "The `title` field of the chapter can not be empty"
end end
{% end %}
rescue e rescue e
raise Error.new e.message raise Error.new e.message
end end
@ -105,12 +199,7 @@ class Plugin
json = eval_json "nextPage()" json = eval_json "nextPage()"
return if json.size == 0 return if json.size == 0
begin begin
{% for field in ["filename", "url"] %} check_fields ["filename", "url"]
unless json[{{field}}]?
raise "Field `{{field.id}}` is missing from the " \
"`nextPage` outputs"
end
{% end %}
rescue e rescue e
raise Error.new e.message raise Error.new e.message
end end
@ -173,16 +262,28 @@ class Plugin
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
myhtml = Myhtml::Parser.new html str = XML.parse(html).inner_text
root = myhtml.root
str = ""
str = root.inner_text if root
env.push_string str env.push_string str
env.call_success env.call_success
end 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| sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
@ -193,6 +294,32 @@ class Plugin
end end
sbx.put_prop_string -2, "raise" 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" sbx.put_prop_string -2, "mango"
end end
end end

View File

@ -51,6 +51,7 @@ class Queue
property fail_count : Int32 = 0 property fail_count : Int32 = 0
property time : Time property time : Time
property plugin_name : String? property plugin_name : String?
property plugin_chapter_id : String?
def parse_query_result(res : DB::ResultSet) def parse_query_result(res : DB::ResultSet)
@id = res.read String @id = res.read String
@ -69,7 +70,7 @@ class Queue
ary = @id.split("-") ary = @id.split("-")
if ary.size == 2 if ary.size == 2
@plugin_name = ary[0] @plugin_name = ary[0]
@id = ary[1] @plugin_chapter_id = ary[1]
end end
end end
@ -99,6 +100,7 @@ class Queue
json.field "time" do json.field "time" do
json.number @time.to_unix_ms json.number @time.to_unix_ms
end end
json.field "plugin_name", @plugin_name if @plugin_name
end end
end end
end end

View File

@ -265,11 +265,43 @@ class APIRouter < Router
query = env.params.json["query"].as String query = env.params.json["query"].as String
plugin = Plugin.new env.params.json["plugin"].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, { send_json env, {
"success" => true, "success" => true,
"chapters" => chapters, "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 }.to_json
rescue e rescue e
send_json env, { send_json env, {

View File

@ -79,9 +79,21 @@ class MainRouter < Router
end end
get "/download/plugins" do |env| get "/download/plugins" do |env|
plugins = Plugin.list begin
plugin = Plugin.new plugins[0] title = env.params.query["plugin"]?
layout "plugin-download" 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 end
get "/" do |env| get "/" do |env|

View File

@ -54,3 +54,9 @@ macro use_default
@@default.not_nil! @@default.not_nil!
end end
end end
class String
def alphanumeric_underscore?
self.chars.all? { |c| c.alphanumeric? || c == '_' }
end
end

View File

@ -17,6 +17,7 @@
<th>Progress</th> <th>Progress</th>
<th>Time</th> <th>Time</th>
<th>Status</th> <th>Status</th>
<th>Plugin</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>

View File

@ -13,7 +13,7 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label> <label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.placeholder %>"> <input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.info.placeholder %>">
</div> </div>
</div> </div>
</div> </div>
@ -30,13 +30,29 @@
</div> </div>
</div> </div>
</div> </div>
<div id="table" class="uk-margin-large-top" hidden>
<h3 id="title-text"></h3>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<table class="uk-table uk-table-striped uk-overflow-auto">
</table>
</div>
<% end %> <% end %>
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
var plugin = "<%= plugin.filename %>"; var plugin = "<%= plugin.info.title %>";
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script> <script src="<%= base_url %>js/plugin-download.js"></script>
<% end %> <% end %>