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