mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-02 10:55:30 -04:00
541 lines
12 KiB
Crystal
541 lines
12 KiB
Crystal
require "duktape/runtime"
|
|
require "myhtml"
|
|
require "xml"
|
|
|
|
require "./subscriptions"
|
|
|
|
class Plugin
|
|
class Error < ::Exception
|
|
end
|
|
|
|
class MetadataError < Error
|
|
end
|
|
|
|
class PluginException < Error
|
|
end
|
|
|
|
class SyntaxError < Error
|
|
end
|
|
|
|
struct Info
|
|
include JSON::Serializable
|
|
|
|
{% for name in ["id", "title", "placeholder"] %}
|
|
getter {{name.id}} = ""
|
|
{% end %}
|
|
getter wait_seconds = 0u64
|
|
getter version = 0u64
|
|
getter settings = {} of String => String?
|
|
getter dir : String
|
|
|
|
@[JSON::Field(ignore: true)]
|
|
@json : JSON::Any
|
|
|
|
def initialize(@dir)
|
|
info_path = File.join @dir, "info.json"
|
|
|
|
unless File.exists? info_path
|
|
raise MetadataError.new "File `info.json` not found in the " \
|
|
"plugin directory #{dir}"
|
|
end
|
|
|
|
@json = JSON.parse File.read info_path
|
|
|
|
begin
|
|
{% for name in ["id", "title", "placeholder"] %}
|
|
@{{name.id}} = @json[{{name}}].as_s
|
|
{% end %}
|
|
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
|
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
|
|
|
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
|
settings_hash.each do |k, v|
|
|
unless str_value = v.as_s?
|
|
raise "The settings object can only contain strings or null"
|
|
end
|
|
@settings[k] = str_value
|
|
end
|
|
end
|
|
|
|
unless @id.alphanumeric_underscore?
|
|
raise "Plugin ID can only contain alphanumeric characters and " \
|
|
"underscores"
|
|
end
|
|
rescue e
|
|
raise MetadataError.new "Failed to retrieve metadata from plugin " \
|
|
"at #{@dir}. Error: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def each(&block : String, JSON::Any -> _)
|
|
@json.as_h.each &block
|
|
end
|
|
end
|
|
|
|
struct Storage
|
|
@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 storage_path = ""
|
|
|
|
def self.build_info_ary(dir : String? = nil)
|
|
@@info_ary.clear
|
|
dir ||= Config.current.plugin_path
|
|
|
|
Dir.mkdir_p dir unless Dir.exists? dir
|
|
|
|
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 do |m|
|
|
{id: m.id, title: m.title}
|
|
end
|
|
end
|
|
|
|
def info
|
|
@info.not_nil!
|
|
end
|
|
|
|
def subscribe(subscription : Subscription)
|
|
list = SubscriptionList.new info.dir
|
|
list << subscription
|
|
list.save
|
|
end
|
|
|
|
def list_subscriptions
|
|
SubscriptionList.new(info.dir).ary
|
|
end
|
|
|
|
def list_subscriptions_raw
|
|
SubscriptionList.new(info.dir)
|
|
end
|
|
|
|
def unsubscribe(id : String)
|
|
list = SubscriptionList.new info.dir
|
|
list.reject! &.id.== id
|
|
list.save
|
|
end
|
|
|
|
def check_subscription(id : String)
|
|
list = list_subscriptions_raw
|
|
sub = list.find &.id.== id
|
|
Plugin::Updater.default.check_subscription self, sub.not_nil!
|
|
list.save
|
|
end
|
|
|
|
def initialize(id : String, dir : String? = nil)
|
|
Plugin.build_info_ary dir
|
|
|
|
@info = @@info_ary.find &.id.== id
|
|
if @info.nil?
|
|
raise Error.new "Plugin with ID #{id} not found"
|
|
end
|
|
|
|
@js_path = File.join info.dir, "index.js"
|
|
@storage_path = File.join info.dir, "storage.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.push_pointer @storage_path.as(Void*)
|
|
path = sbx.require_pointer(-1).as String
|
|
sbx.pop
|
|
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
|
|
|
|
eval File.read @js_path
|
|
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 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 can_subscribe? : Bool
|
|
info.version > 1 && eval_exists?("newChapters")
|
|
end
|
|
|
|
def search_manga(query : String)
|
|
if info.version == 1
|
|
raise Error.new "Manga searching is only available for plugins " \
|
|
"targeting API v2 or above"
|
|
end
|
|
json = eval_json "searchManga('#{query}')"
|
|
begin
|
|
json.as_a.each do |obj|
|
|
assert_manga_type obj
|
|
end
|
|
rescue e
|
|
raise Error.new e.message
|
|
end
|
|
json
|
|
end
|
|
|
|
def list_chapters(query : String)
|
|
json = eval_json "listChapters('#{query}')"
|
|
begin
|
|
if info.version > 1
|
|
# Since v2, listChapters returns an array
|
|
json.as_a.each do |obj|
|
|
assert_chapter_type obj
|
|
end
|
|
else
|
|
check_fields ["title", "chapters"]
|
|
|
|
ary = json["chapters"].as_a
|
|
ary.each do |obj|
|
|
id = obj["id"]?
|
|
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
|
|
unless id.to_s.alphanumeric_underscore?
|
|
raise "The `id` field can only contain alphanumeric characters " \
|
|
"and underscores"
|
|
end
|
|
|
|
title = obj["title"]?
|
|
if title.nil?
|
|
raise "Field `title` missing from `listChapters` outputs"
|
|
end
|
|
end
|
|
end
|
|
rescue e
|
|
raise Error.new e.message
|
|
end
|
|
json
|
|
end
|
|
|
|
def select_chapter(id : String)
|
|
json = eval_json "selectChapter('#{id}')"
|
|
begin
|
|
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"
|
|
end
|
|
end
|
|
rescue e
|
|
raise Error.new e.message
|
|
end
|
|
json
|
|
end
|
|
|
|
def next_page
|
|
json = eval_json "nextPage()"
|
|
return if json.size == 0
|
|
begin
|
|
assert_page_type json
|
|
rescue e
|
|
raise Error.new e.message
|
|
end
|
|
json
|
|
end
|
|
|
|
def new_chapters(manga_id : String, after : Int64)
|
|
# Converting standard timestamp to milliseconds so plugins can easily do
|
|
# `new Date(ms_timestamp)` in JS.
|
|
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
|
|
begin
|
|
json.as_a.each do |obj|
|
|
assert_chapter_type obj
|
|
end
|
|
rescue e
|
|
raise Error.new e.message
|
|
end
|
|
json
|
|
end
|
|
|
|
def eval(str)
|
|
@rt.eval str
|
|
rescue e : Duktape::SyntaxError
|
|
raise SyntaxError.new e.message
|
|
rescue e : Duktape::Error
|
|
raise Error.new e.message
|
|
end
|
|
|
|
private def eval_json(str)
|
|
JSON.parse eval(str).as String
|
|
end
|
|
|
|
private def eval_exists?(str) : Bool
|
|
@rt.eval str
|
|
true
|
|
rescue e : Duktape::ReferenceError
|
|
false
|
|
rescue e : Duktape::Error
|
|
raise Error.new e.message
|
|
end
|
|
|
|
private def def_helper_functions(sbx)
|
|
sbx.push_object
|
|
|
|
sbx.push_proc LibDUK::VARARGS do |ptr|
|
|
env = Duktape::Sandbox.new ptr
|
|
url = env.require_string 0
|
|
|
|
headers = HTTP::Headers.new
|
|
|
|
if env.get_top == 2
|
|
env.enum 1, LibDUK::Enum::OwnPropertiesOnly
|
|
while env.next -1, true
|
|
key = env.require_string -2
|
|
val = env.require_string -1
|
|
headers.add key, val
|
|
env.pop_2
|
|
end
|
|
end
|
|
|
|
res = HTTP::Client.get url, headers
|
|
|
|
env.push_object
|
|
|
|
env.push_int res.status_code
|
|
env.put_prop_string -2, "status_code"
|
|
|
|
env.push_string res.body
|
|
env.put_prop_string -2, "body"
|
|
|
|
env.push_object
|
|
res.headers.each do |k, v|
|
|
if v.size == 1
|
|
env.push_string v[0]
|
|
else
|
|
env.push_string v.join ","
|
|
end
|
|
env.put_prop_string -2, k
|
|
end
|
|
env.put_prop_string -2, "headers"
|
|
|
|
env.call_success
|
|
end
|
|
sbx.put_prop_string -2, "get"
|
|
|
|
sbx.push_proc LibDUK::VARARGS do |ptr|
|
|
env = Duktape::Sandbox.new ptr
|
|
url = env.require_string 0
|
|
body = env.require_string 1
|
|
|
|
headers = HTTP::Headers.new
|
|
|
|
if env.get_top == 3
|
|
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
|
|
while env.next -1, true
|
|
key = env.require_string -2
|
|
val = env.require_string -1
|
|
headers.add key, val
|
|
env.pop_2
|
|
end
|
|
end
|
|
|
|
res = HTTP::Client.post url, headers, body
|
|
|
|
env.push_object
|
|
|
|
env.push_int res.status_code
|
|
env.put_prop_string -2, "status_code"
|
|
|
|
env.push_string res.body
|
|
env.put_prop_string -2, "body"
|
|
|
|
env.push_object
|
|
res.headers.each do |k, v|
|
|
if v.size == 1
|
|
env.push_string v[0]
|
|
else
|
|
env.push_string v.join ","
|
|
end
|
|
env.put_prop_string -2, k
|
|
end
|
|
env.put_prop_string -2, "headers"
|
|
|
|
env.call_success
|
|
end
|
|
sbx.put_prop_string -2, "post"
|
|
|
|
sbx.push_proc 2 do |ptr|
|
|
env = Duktape::Sandbox.new ptr
|
|
html = env.require_string 0
|
|
selector = env.require_string 1
|
|
|
|
myhtml = Myhtml::Parser.new html
|
|
ary = myhtml.css(selector).map(&.to_html).to_a
|
|
|
|
ary_idx = env.push_array
|
|
ary.each_with_index do |str, i|
|
|
env.push_string str
|
|
env.put_prop_index ary_idx, i.to_u32
|
|
end
|
|
|
|
env.call_success
|
|
end
|
|
sbx.put_prop_string -2, "css"
|
|
|
|
sbx.push_proc 1 do |ptr|
|
|
env = Duktape::Sandbox.new ptr
|
|
html = env.require_string 0
|
|
|
|
begin
|
|
parser = Myhtml::Parser.new html
|
|
str = parser.body!.children.first.inner_text
|
|
|
|
env.push_string str
|
|
rescue
|
|
env.push_string ""
|
|
end
|
|
|
|
env.call_success
|
|
end
|
|
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
|
|
parser = Myhtml::Parser.new html
|
|
attr = parser.body!.children.first.attribute_by name
|
|
env.push_string attr.not_nil!
|
|
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
|
|
msg = env.require_string 0
|
|
env.call_success
|
|
|
|
raise PluginException.new msg
|
|
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 "storage_path"
|
|
storage_path = env.require_string -1
|
|
env.pop
|
|
storage = Storage.new storage_path
|
|
|
|
if env.get_top == 2
|
|
val = env.require_string 1
|
|
storage[key] = val
|
|
storage.save
|
|
else
|
|
val = storage[key]?
|
|
if val
|
|
env.push_string val
|
|
else
|
|
env.push_undefined
|
|
end
|
|
end
|
|
|
|
env.call_success
|
|
end
|
|
sbx.put_prop_string -2, "storage"
|
|
|
|
if info.version > 1
|
|
sbx.push_proc 1 do |ptr|
|
|
env = Duktape::Sandbox.new ptr
|
|
key = env.require_string 0
|
|
|
|
env.get_global_string "info_dir"
|
|
info_dir = env.require_string -1
|
|
env.pop
|
|
info = Info.new info_dir
|
|
|
|
if value = info.settings[key]?
|
|
env.push_string value
|
|
else
|
|
env.push_undefined
|
|
end
|
|
|
|
env.call_success
|
|
end
|
|
sbx.put_prop_string -2, "settings"
|
|
end
|
|
|
|
sbx.put_prop_string -2, "mango"
|
|
end
|
|
end
|