diff --git a/.ameba.yml b/.ameba.yml index 163c936..2fce30d 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -12,3 +12,4 @@ Layout/LineLength: MaxLength: 80 Excluded: - src/routes/api.cr + - spec/plugin_spec.cr diff --git a/public/js/reader.js b/public/js/reader.js index a5d8e6b..63cef7f 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -14,6 +14,7 @@ const readerComponent = () => { margin: 30, preloadLookahead: 3, enableRightToLeft: false, + fitType: 'vert', /** * Initialize the component by fetching the page dimensions @@ -29,14 +30,16 @@ const readerComponent = () => { return { id: i + 1, url: `${base_url}api/page/${tid}/${eid}/${i+1}`, - width: d.width, - height: d.height, + width: d.width == 0 ? "100%" : d.width, + height: d.height == 0 ? "100%" : d.height, }; }); - const avgRatio = this.items.reduce((acc, cur) => { + // Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`. + // TODO: support more image types in image_size.cr + const avgRatio = dimensions.reduce((acc, cur) => { return acc + cur.height / cur.width - }, 0) / this.items.length; + }, 0) / dimensions.length; console.log(avgRatio); this.longPages = avgRatio > 2; @@ -63,6 +66,11 @@ const readerComponent = () => { this.preloadImage(this.items[idx - 1].url); } + const savedFitType = localStorage.getItem('fitType'); + if (savedFitType) { + this.fitType = savedFitType; + $('#fit-select').val(savedFitType); + } const savedFlipAnimation = localStorage.getItem('enableFlipAnimation'); this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true'; @@ -333,6 +341,11 @@ const readerComponent = () => { this.toPage(this.selectedIndex); }, + fitChanged(){ + this.fitType = $('#fit-select').val(); + localStorage.setItem('fitType', this.fitType); + }, + preloadLookaheadChanged() { localStorage.setItem('preloadLookahead', this.preloadLookahead); }, diff --git a/spec/asset/plugins/plugin/index.js b/spec/asset/plugins/plugin/index.js new file mode 100644 index 0000000..e69de29 diff --git a/spec/asset/plugins/plugin/info.json b/spec/asset/plugins/plugin/info.json new file mode 100644 index 0000000..7329147 --- /dev/null +++ b/spec/asset/plugins/plugin/info.json @@ -0,0 +1,6 @@ +{ + "id": "test", + "title": "Test Plugin", + "placeholder": "placeholder", + "wait_seconds": 1 +} diff --git a/spec/config_spec.cr b/spec/config_spec.cr index e2d5fca..5ea6f89 100644 --- a/spec/config_spec.cr +++ b/spec/config_spec.cr @@ -1,14 +1,31 @@ require "./spec_helper" describe Config do - it "creates config if it does not exist" do - with_default_config do |_, path| + it "creates default config if it does not exist" do + with_default_config do |config, path| File.exists?(path).should be_true + config.port.should eq 9000 end end it "correctly loads config" do config = Config.load "spec/asset/test-config.yml" config.port.should eq 3000 + config.base_url.should eq "/" + end + + it "correctly reads config defaults from ENV" do + ENV["LOG_LEVEL"] = "debug" + config = Config.load "spec/asset/test-config.yml" + config.log_level.should eq "debug" + config.base_url.should eq "/" + end + + it "correctly handles ENV truthiness" do + ENV["CACHE_ENABLED"] = "false" + config = Config.load "spec/asset/test-config.yml" + config.cache_enabled.should be_false + config.cache_log_enabled.should be_true + config.disable_login.should be_false end end diff --git a/spec/plugin_spec.cr b/spec/plugin_spec.cr new file mode 100644 index 0000000..c0535ed --- /dev/null +++ b/spec/plugin_spec.cr @@ -0,0 +1,70 @@ +require "./spec_helper" + +describe Plugin do + describe "helper functions" do + it "mango.text" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.text('Click Me'); + JS + res.should eq "Click Me" + end + end + + it "mango.text returns empty string when no text" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.text(''); + JS + res.should eq "" + end + end + + it "mango.css" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.css('ABC', 'li.test'); + + JS + res.should eq ["A", "B"] + end + end + + it "mango.css returns empty array when no match" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.css('ABC', 'li.noclass'); + JS + res.should eq [] of String + end + end + + it "mango.attribute" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.attribute('Click Me', 'href'); + JS + res.should eq "https://github.com" + end + end + + it "mango.attribute returns undefined when no match" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.attribute('', 'href') === undefined; + JS + res.should be_true + end + end + + # https://github.com/hkalexling/Mango/issues/320 + it "mango.attribute handles tags in attribute values" do + with_plugin do |plugin| + res = plugin.eval <<-JS + mango.attribute('', 'data-b'); + JS + res.should eq "test" + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 1bbd287..dc8c2a3 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -3,6 +3,7 @@ require "../src/queue" require "../src/server" require "../src/config" require "../src/main_fiber" +require "../src/plugin/plugin" class State @@hash = {} of String => String @@ -54,3 +55,10 @@ def with_storage end end end + +def with_plugin + with_default_config do + plugin = Plugin.new "test", "spec/asset/plugins" + yield plugin + end +end diff --git a/src/config.cr b/src/config.cr index 807a74c..2384431 100644 --- a/src/config.cr +++ b/src/config.cr @@ -1,31 +1,51 @@ require "yaml" class Config + private OPTIONS = { + "host" => "0.0.0.0", + "port" => 9000, + "base_url" => "/", + "session_secret" => "mango-session-secret", + "library_path" => "~/mango/library", + "library_cache_path" => "~/mango/library.yml.gz", + "db_path" => "~/mango.db", + "queue_db_path" => "~/mango/queue.db", + "scan_interval_minutes" => 5, + "thumbnail_generation_interval_hours" => 24, + "log_level" => "info", + "upload_path" => "~/mango/uploads", + "plugin_path" => "~/mango/plugins", + "download_timeout_seconds" => 30, + "cache_enabled" => true, + "cache_size_mbs" => 50, + "cache_log_enabled" => true, + "disable_login" => false, + "default_username" => "", + "auth_proxy_header_name" => "", + "plugin_update_interval_hours" => 24, + } + include YAML::Serializable @[YAML::Field(ignore: true)] - property path = "" - property host = "0.0.0.0" - property port : Int32 = 9000 - property base_url = "/" - property session_secret = "mango-session-secret" - property library_path = "~/mango/library" - property library_cache_path = "~/mango/library.yml.gz" - property db_path = "~/mango/mango.db" - property queue_db_path = "~/mango/queue.db" - property scan_interval_minutes : Int32 = 5 - property thumbnail_generation_interval_hours : Int32 = 24 - property log_level = "info" - property upload_path = "~/mango/uploads" - property plugin_path = "~/mango/plugins" - property download_timeout_seconds : Int32 = 30 - property cache_enabled = true - property cache_size_mbs = 50 - property cache_log_enabled = true - property disable_login = false - property default_username = "" - property auth_proxy_header_name = "" - property plugin_update_interval_hours : Int32 = 24 + property path : String = "" + + # Go through the options constant above and define them as properties. + # Allow setting the default values through environment variables. + # Overall precedence: config file > environment variable > default value + {% begin %} + {% for k, v in OPTIONS %} + {% if v.is_a? StringLiteral %} + property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }} + {% elsif v.is_a? NumberLiteral %} + property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i + {% elsif v.is_a? BoolLiteral %} + property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }} + {% else %} + raise "Unknown type in config option: {{ v.class_name.id }}" + {% end %} + {% end %} + {% end %} @@singlet : Config? @@ -38,7 +58,7 @@ class Config end def self.load(path : String?) - path = "~/.config/mango/config.yml" if path.nil? + path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil? cfg_path = File.expand_path path, home: true if File.exists? cfg_path config = self.from_yaml File.read cfg_path diff --git a/src/library/title.cr b/src/library/title.cr index e9873f2..9c3ad78 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -632,6 +632,16 @@ class Title if last_read_entry && last_read_entry.finished? username last_read_entry = last_read_entry.next_entry username + if last_read_entry.nil? + # The last entry is finished. Return the first unfinished entry + # (if any) + sorted_entries(username).each do |e| + unless e.finished? username + last_read_entry = e + break + end + end + end end last_read_entry diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 7f9fcc8..d553ae8 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -105,9 +105,10 @@ class Plugin getter js_path = "" getter storage_path = "" - def self.build_info_ary + def self.build_info_ary(dir : String? = nil) @@info_ary.clear - dir = Config.current.plugin_path + dir ||= Config.current.plugin_path + Dir.mkdir_p dir unless Dir.exists? dir Dir.each_child dir do |f| @@ -160,8 +161,8 @@ class Plugin list.save end - def initialize(id : String) - Plugin.build_info_ary + def initialize(id : String, dir : String? = nil) + Plugin.build_info_ary dir @info = @@info_ary.find &.id.== id if @info.nil? @@ -319,7 +320,7 @@ class Plugin json end - private def eval(str) + def eval(str) @rt.eval str rescue e : Duktape::SyntaxError raise SyntaxError.new e.message @@ -448,9 +449,15 @@ class Plugin env = Duktape::Sandbox.new ptr html = env.require_string 0 - str = XML.parse(html).inner_text + begin + parser = Myhtml::Parser.new html + str = parser.body!.children.first.inner_text + + env.push_string str + rescue + env.push_string "" + end - env.push_string str env.call_success end sbx.put_prop_string -2, "text" @@ -461,8 +468,9 @@ class Plugin name = env.require_string 1 begin - attr = XML.parse(html).first_element_child.not_nil![name] - env.push_string attr + parser = Myhtml::Parser.new html + attr = parser.body!.children.first.attribute_by name + env.push_string attr.not_nil! rescue env.push_undefined end diff --git a/src/util/signature.cr b/src/util/signature.cr index 8d2b961..74c8b8e 100644 --- a/src/util/signature.cr +++ b/src/util/signature.cr @@ -64,11 +64,12 @@ class Dir path = File.join dirname, fn if File.directory? path signatures << Dir.contents_signature path, cache - signatures << fn if DirEntry.is_valid? path else # Only add its signature value to `signatures` when it is a # supported file - signatures << fn if ArchiveEntry.is_valid? fn + if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn) + signatures << fn + end end Fiber.yield end diff --git a/src/util/util.cr b/src/util/util.cr index e08bd9d..ce658aa 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -11,6 +11,7 @@ SUPPORTED_IMG_TYPES = %w( image/avif image/gif image/svg+xml + image/jxl ) def random_str @@ -49,6 +50,7 @@ def register_mime_types # defiend by Crystal in `MIME.DEFAULT_TYPES` ".apng" => "image/apng", ".avif" => "image/avif", + ".jxl" => "image/jxl", }.each do |k, v| MIME.register k, v end @@ -93,9 +95,9 @@ class String end end -def env_is_true?(key : String) : Bool +def env_is_true?(key : String, default : Bool = false) : Bool val = ENV[key.upcase]? || ENV[key.downcase]? - return false unless val + return default unless val val.downcase.in? "1", "true" end diff --git a/src/views/reader.html.ecr b/src/views/reader.html.ecr index 19e2b19..21357ee 100644 --- a/src/views/reader.html.ecr +++ b/src/views/reader.html.ecr @@ -5,7 +5,7 @@ <%= render_component "head" %> - + @@ -19,7 +19,7 @@ + :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;"> - + @@ -94,6 +94,17 @@ + + Page fit + + + Fit height + Fit width + Real size + + + +