diff --git a/.ameba.yml b/.ameba.yml index 163c936a..2fce30d1 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/README.md b/README.md index 62422224..b8f7e413 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r ### CLI ``` - Mango - Manga Server and Web Reader. Version 0.26.2 + Mango - Manga Server and Web Reader. Version 0.27.0 Usage: diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 2e9d0a0b..0f197382 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -1,6 +1,7 @@ const component = () => { return { plugins: [], + subscribable: false, info: undefined, pid: undefined, chapters: undefined, // undefined: not searched yet, []: empty @@ -60,6 +61,7 @@ const component = () => { .then((data) => { if (!data.success) throw new Error(data.error); this.info = data.info; + this.subscribable = data.subscribable; this.pid = pid; }) .catch((e) => { @@ -70,6 +72,9 @@ const component = () => { }); }, pluginChanged() { + this.manga = undefined; + this.chapters = undefined; + this.mid = undefined; this.loadPlugin(this.pid); localStorage.setItem("plugin", this.pid); }, @@ -140,6 +145,7 @@ const component = () => { if (!query) return; this.manga = undefined; + this.mid = undefined; if (this.info.version === 1) { this.searchChapters(query); } else { diff --git a/public/js/reader.js b/public/js/reader.js index 2cb3a666..63cef7ff 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; @@ -58,11 +61,16 @@ const readerComponent = () => { // Preload Images this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3); - const limit = Math.min(page + this.preloadLookahead, this.items.length + 1); + const limit = Math.min(page + this.preloadLookahead, this.items.length); for (let idx = page + 1; idx <= limit; idx++) { 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'; @@ -135,7 +143,11 @@ const readerComponent = () => { const idx = parseInt(this.curItem.id); const newIdx = idx + (isNext ? 1 : -1); - if (newIdx <= 0 || newIdx > this.items.length) return; + if (newIdx <= 0) return; + if (newIdx > this.items.length) { + this.showControl(idx); + return; + } if (newIdx + this.preloadLookahead < this.items.length + 1) { this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url); @@ -253,12 +265,20 @@ const readerComponent = () => { }); }, /** - * Shows the control modal + * Handles clicked image * * @param {Event} event - The triggering event */ - showControl(event) { + clickImage(event) { const idx = event.currentTarget.id; + this.showControl(idx); + }, + /** + * Shows the control modal + * + * @param {number} idx - selected page index + */ + showControl(idx) { this.selectedIndex = idx; UIkit.modal($('#modal-sections')).show(); }, @@ -321,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/shard.yml b/shard.yml index 943d4e54..9eb4d012 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: mango -version: 0.26.2 +version: 0.27.0 authors: - Alex Ling diff --git a/spec/asset/plugins/plugin/index.js b/spec/asset/plugins/plugin/index.js new file mode 100644 index 00000000..e69de29b diff --git a/spec/asset/plugins/plugin/info.json b/spec/asset/plugins/plugin/info.json new file mode 100644 index 00000000..73291476 --- /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 e2d5fca9..5ea6f89b 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 00000000..c0535ede --- /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('', '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('', '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 1bbd287f..dc8c2a34 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 807a74cb..23844314 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/archive_entry.cr b/src/library/archive_entry.cr new file mode 100644 index 00000000..cd63f17c --- /dev/null +++ b/src/library/archive_entry.cr @@ -0,0 +1,111 @@ +require "yaml" + +require "./entry" + +class ArchiveEntry < Entry + include YAML::Serializable + + getter zip_path : String + + def initialize(@zip_path, @book) + storage = Storage.default + @path = @zip_path + @encoded_path = URI.encode @zip_path + @title = File.basename @zip_path, File.extname @zip_path + @encoded_title = URI.encode @title + @size = (File.size @zip_path).humanize_bytes + id = storage.get_entry_id @zip_path, File.signature(@zip_path) + if id.nil? + id = random_str + storage.insert_entry_id({ + path: @zip_path, + id: id, + signature: File.signature(@zip_path).to_s, + }) + end + @id = id + @mtime = File.info(@zip_path).modification_time + + unless File.readable? @zip_path + @err_msg = "File #{@zip_path} is not readable." + Logger.warn "#{@err_msg} Please make sure the " \ + "file permission is configured correctly." + return + end + + archive_exception = validate_archive @zip_path + unless archive_exception.nil? + @err_msg = "Archive error: #{archive_exception}" + Logger.warn "Unable to extract archive #{@zip_path}. " \ + "Ignoring it. #{@err_msg}" + return + end + + file = ArchiveFile.new @zip_path + @pages = file.entries.count do |e| + SUPPORTED_IMG_TYPES.includes? \ + MIME.from_filename? e.filename + end + file.close + end + + private def sorted_archive_entries + ArchiveFile.open @zip_path do |file| + entries = file.entries + .select { |e| + SUPPORTED_IMG_TYPES.includes? \ + MIME.from_filename? e.filename + } + .sort! { |a, b| + compare_numerically a.filename, b.filename + } + yield file, entries + end + end + + def read_page(page_num) + raise "Unreadble archive. #{@err_msg}" if @err_msg + img = nil + begin + sorted_archive_entries do |file, entries| + page = entries[page_num - 1] + data = file.read_entry page + if data + img = Image.new data, MIME.from_filename(page.filename), + page.filename, data.size + end + end + rescue e + Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}" + end + img + end + + def page_dimensions + sizes = [] of Hash(String, Int32) + sorted_archive_entries do |file, entries| + entries.each_with_index do |e, i| + begin + data = file.read_entry(e).not_nil! + size = ImageSize.get data + sizes << { + "width" => size.width, + "height" => size.height, + } + rescue e + Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}" + sizes << {"width" => 1000_i32, "height" => 1000_i32} + end + end + end + sizes + end + + def examine : Bool + File.exists? @zip_path + end + + def self.is_valid?(path : String) : Bool + is_supported_file path + end +end diff --git a/src/library/cache.cr b/src/library/cache.cr index 10e4f60f..f35af8b5 100644 --- a/src/library/cache.cr +++ b/src/library/cache.cr @@ -76,8 +76,8 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry)) entries : Array(Entry), opt : SortOptions?) entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s user_context = opt && opt.method == SortMethod::Progress ? username : "" - sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context + - (opt ? opt.to_tuple.to_s : "nil")) + sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context + + (opt ? opt.to_tuple.to_s : "nil")) "#{sig}:sorted_entries" end end @@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title)) def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?) titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s user_context = opt && opt.method == SortMethod::Progress ? username : "" - sig = Digest::SHA1.hexdigest (titles_sig + user_context + - (opt ? opt.to_tuple.to_s : "nil")) + sig = Digest::SHA1.hexdigest(titles_sig + user_context + + (opt ? opt.to_tuple.to_s : "nil")) "#{sig}:sorted_titles" end end diff --git a/src/library/dir_entry.cr b/src/library/dir_entry.cr new file mode 100644 index 00000000..0ce4e71b --- /dev/null +++ b/src/library/dir_entry.cr @@ -0,0 +1,132 @@ +require "yaml" + +require "./entry" + +class DirEntry < Entry + include YAML::Serializable + + getter dir_path : String + + @[YAML::Field(ignore: true)] + @sorted_files : Array(String)? + + @signature : String + + def initialize(@dir_path, @book) + storage = Storage.default + @path = @dir_path + @encoded_path = URI.encode @dir_path + @title = File.basename @dir_path + @encoded_title = URI.encode @title + + unless File.readable? @dir_path + @err_msg = "Directory #{@dir_path} is not readable." + Logger.warn "#{@err_msg} Please make sure the " \ + "file permission is configured correctly." + return + end + + unless DirEntry.is_valid? @dir_path + @err_msg = "Directory #{@dir_path} is not valid directory entry." + Logger.warn "#{@err_msg} Please make sure the " \ + "directory has valid images." + return + end + + size_sum = 0 + sorted_files.each do |file_path| + size_sum += File.size file_path + end + @size = size_sum.humanize_bytes + + @signature = Dir.directory_entry_signature @dir_path + id = storage.get_entry_id @dir_path, @signature + if id.nil? + id = random_str + storage.insert_entry_id({ + path: @dir_path, + id: id, + signature: @signature, + }) + end + @id = id + + @mtime = sorted_files.map do |file_path| + File.info(file_path).modification_time + end.max + @pages = sorted_files.size + end + + def read_page(page_num) + img = nil + begin + files = sorted_files + file_path = files[page_num - 1] + data = File.read(file_path).to_slice + if data + img = Image.new data, MIME.from_filename(file_path), + File.basename(file_path), data.size + end + rescue e + Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}" + end + img + end + + def page_dimensions + sizes = [] of Hash(String, Int32) + sorted_files.each_with_index do |path, i| + data = File.read(path).to_slice + begin + data.not_nil! + size = ImageSize.get data + sizes << { + "width" => size.width, + "height" => size.height, + } + rescue e + Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}" + sizes << {"width" => 1000_i32, "height" => 1000_i32} + end + end + sizes + end + + def examine : Bool + existence = File.exists? @dir_path + return false unless existence + files = DirEntry.image_files @dir_path + signature = Dir.directory_entry_signature @dir_path + existence = files.size > 0 && @signature == signature + @sorted_files = nil unless existence + + # For more efficient, update a directory entry with new property + # and return true like Title.examine + existence + end + + def sorted_files + cached_sorted_files = @sorted_files + return cached_sorted_files if cached_sorted_files + @sorted_files = DirEntry.sorted_image_files @dir_path + @sorted_files.not_nil! + end + + def self.image_files(dir_path) + Dir.entries(dir_path) + .reject(&.starts_with? ".") + .map { |fn| File.join dir_path, fn } + .select { |fn| is_supported_image_file fn } + .reject { |fn| File.directory? fn } + .select { |fn| File.readable? fn } + end + + def self.sorted_image_files(dir_path) + self.image_files(dir_path) + .sort { |a, b| compare_numerically a, b } + end + + def self.is_valid?(path : String) : Bool + image_files(path).size > 0 + end +end diff --git a/src/library/entry.cr b/src/library/entry.cr index dd50ed34..16666eaf 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -1,66 +1,55 @@ require "image_size" -require "yaml" -class Entry - include YAML::Serializable - - getter zip_path : String, book : Title, title : String, - size : String, pages : Int32, id : String, encoded_path : String, - encoded_title : String, mtime : Time, err_msg : String? +private def node_has_key(node : YAML::Nodes::Mapping, key : String) + node.nodes + .map_with_index { |n, i| {n, i} } + .select(&.[1].even?) + .map(&.[0]) + .select(YAML::Nodes::Scalar) + .map(&.as(YAML::Nodes::Scalar).value) + .includes? key +end - @[YAML::Field(ignore: true)] - @sort_title : String? +abstract class Entry + getter id : String, book : Title, title : String, path : String, + size : String, pages : Int32, mtime : Time, + encoded_path : String, encoded_title : String, err_msg : String? - def initialize(@zip_path, @book) - storage = Storage.default - @encoded_path = URI.encode @zip_path - @title = File.basename @zip_path, File.extname @zip_path - @encoded_title = URI.encode @title - @size = (File.size @zip_path).humanize_bytes - id = storage.get_entry_id @zip_path, File.signature(@zip_path) - if id.nil? - id = random_str - storage.insert_entry_id({ - path: @zip_path, - id: id, - signature: File.signature(@zip_path).to_s, - }) - end - @id = id - @mtime = File.info(@zip_path).modification_time - - unless File.readable? @zip_path - @err_msg = "File #{@zip_path} is not readable." - Logger.warn "#{@err_msg} Please make sure the " \ - "file permission is configured correctly." - return - end + def initialize( + @id, @title, @book, @path, + @size, @pages, @mtime, + @encoded_path, @encoded_title, @err_msg + ) + end - archive_exception = validate_archive @zip_path - unless archive_exception.nil? - @err_msg = "Archive error: #{archive_exception}" - Logger.warn "Unable to extract archive #{@zip_path}. " \ - "Ignoring it. #{@err_msg}" - return + def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) + unless node.is_a? YAML::Nodes::Mapping + raise "Unexpected node type in YAML" end - - file = ArchiveFile.new @zip_path - @pages = file.entries.count do |e| - SUPPORTED_IMG_TYPES.includes? \ - MIME.from_filename? e.filename + # Doing YAML::Any.new(ctx, node) here causes a weird error, so + # instead we are using a more hacky approach (see `node_has_key`). + # TODO: Use a more elegant approach + if node_has_key node, "zip_path" + ArchiveEntry.new ctx, node + elsif node_has_key node, "dir_path" + DirEntry.new ctx, node + else + raise "Unknown entry found in YAML cache. Try deleting the " \ + "`library.yml.gz` file" end - file.close end def build_json(*, slim = false) JSON.build do |json| json.object do - {% for str in %w(zip_path title size id) %} - json.field {{str}}, @{{str.id}} + {% for str in %w(path title size id) %} + json.field {{str}}, {{str.id}} {% end %} if err_msg json.field "err_msg", err_msg end + json.field "zip_path", path # for API backward compatability + json.field "path", path json.field "title_id", @book.id json.field "title_title", @book.title json.field "sort_title", sort_title @@ -74,6 +63,9 @@ class Entry end end + @[YAML::Field(ignore: true)] + @sort_title : String? + def sort_title sort_title_cached = @sort_title return sort_title_cached if sort_title_cached @@ -131,58 +123,6 @@ class Entry url end - private def sorted_archive_entries - ArchiveFile.open @zip_path do |file| - entries = file.entries - .select { |e| - SUPPORTED_IMG_TYPES.includes? \ - MIME.from_filename? e.filename - } - .sort! { |a, b| - compare_numerically a.filename, b.filename - } - yield file, entries - end - end - - def read_page(page_num) - raise "Unreadble archive. #{@err_msg}" if @err_msg - img = nil - begin - sorted_archive_entries do |file, entries| - page = entries[page_num - 1] - data = file.read_entry page - if data - img = Image.new data, MIME.from_filename(page.filename), - page.filename, data.size - end - end - rescue e - Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}" - end - img - end - - def page_dimensions - sizes = [] of Hash(String, Int32) - sorted_archive_entries do |file, entries| - entries.each_with_index do |e, i| - begin - data = file.read_entry(e).not_nil! - size = ImageSize.get data - sizes << { - "width" => size.width, - "height" => size.height, - } - rescue e - Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}" - sizes << {"width" => 1000_i32, "height" => 1000_i32} - end - end - end - sizes - end - def next_entry(username) entries = @book.sorted_entries username idx = entries.index self @@ -197,20 +137,6 @@ class Entry entries[idx - 1] end - def date_added - date_added = nil - TitleInfo.new @book.dir do |info| - info_da = info.date_added[@title]? - if info_da.nil? - date_added = info.date_added[@title] = ctime @zip_path - info.save - else - date_added = info_da - end - end - date_added.not_nil! # is it ok to set not_nil! here? - end - # For backward backward compatibility with v0.1.0, we save entry titles # instead of IDs in info.json def save_progress(username, page) @@ -290,7 +216,7 @@ class Entry end Storage.default.save_thumbnail @id, img rescue e - Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}" + Logger.warn "Failed to generate thumbnail for file #{path}. #{e}" end img @@ -299,4 +225,34 @@ class Entry def get_thumbnail : Image? Storage.default.get_thumbnail @id end + + def date_added : Time + date_added = Time::UNIX_EPOCH + TitleInfo.new @book.dir do |info| + info_da = info.date_added[@title]? + if info_da.nil? + date_added = info.date_added[@title] = ctime path + info.save + else + date_added = info_da + end + end + date_added + end + + # Hack to have abstract class methods + # https://github.com/crystal-lang/crystal/issues/5956 + private module ClassMethods + abstract def is_valid?(path : String) : Bool + end + + macro inherited + extend ClassMethods + end + + abstract def read_page(page_num) + + abstract def page_dimensions + + abstract def examine : Bool? end diff --git a/src/library/title.cr b/src/library/title.cr index e3d79d55..9c3ad78f 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -49,13 +49,18 @@ class Title path = File.join dir, fn if File.directory? path title = Title.new path, @id, cache - next if title.entries.size == 0 && title.titles.size == 0 - Library.default.title_hash[title.id] = title - @title_ids << title.id + unless title.entries.size == 0 && title.titles.size == 0 + Library.default.title_hash[title.id] = title + @title_ids << title.id + end + if DirEntry.is_valid? path + entry = DirEntry.new path, self + @entries << entry if entry.pages > 0 || entry.err_msg + end next end if is_supported_file path - entry = Entry.new path, self + entry = ArchiveEntry.new path, self @entries << entry if entry.pages > 0 || entry.err_msg end end @@ -127,12 +132,12 @@ class Title previous_entries_size = @entries.size @entries.select! do |entry| - existence = File.exists? entry.zip_path + existence = entry.examine Fiber.yield context["deleted_entry_ids"] << entry.id unless existence existence end - remained_entry_zip_paths = @entries.map &.zip_path + remained_entry_paths = @entries.map &.path is_titles_added = false is_entries_added = false @@ -140,29 +145,43 @@ class Title next if fn.starts_with? "." path = File.join dir, fn if File.directory? path + unless remained_entry_paths.includes? path + if DirEntry.is_valid? path + entry = DirEntry.new path, self + if entry.pages > 0 || entry.err_msg + @entries << entry + is_entries_added = true + context["deleted_entry_ids"].select! do |deleted_entry_id| + entry.id != deleted_entry_id + end + end + end + end + next if remained_title_dirs.includes? path title = Title.new path, @id, context["cached_contents_signature"] - next if title.entries.size == 0 && title.titles.size == 0 - Library.default.title_hash[title.id] = title - @title_ids << title.id - is_titles_added = true - - # We think they are removed, but they are here! - # Cancel reserved jobs - revival_title_ids = [title.id] + title.deep_titles.map &.id - context["deleted_title_ids"].select! do |deleted_title_id| - !(revival_title_ids.includes? deleted_title_id) - end - revival_entry_ids = title.deep_entries.map &.id - context["deleted_entry_ids"].select! do |deleted_entry_id| - !(revival_entry_ids.includes? deleted_entry_id) + unless title.entries.size == 0 && title.titles.size == 0 + Library.default.title_hash[title.id] = title + @title_ids << title.id + is_titles_added = true + + # We think they are removed, but they are here! + # Cancel reserved jobs + revival_title_ids = [title.id] + title.deep_titles.map &.id + context["deleted_title_ids"].select! do |deleted_title_id| + !(revival_title_ids.includes? deleted_title_id) + end + revival_entry_ids = title.deep_entries.map &.id + context["deleted_entry_ids"].select! do |deleted_entry_id| + !(revival_entry_ids.includes? deleted_entry_id) + end end next end if is_supported_file path - next if remained_entry_zip_paths.includes? path - entry = Entry.new path, self + next if remained_entry_paths.includes? path + entry = ArchiveEntry.new path, self if entry.pages > 0 || entry.err_msg @entries << entry is_entries_added = true @@ -613,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 @@ -627,7 +656,7 @@ class Title @entries.each do |e| next if da.has_key? e.title - da[e.title] = ctime e.zip_path + da[e.title] = ctime e.path end TitleInfo.new @dir do |info| diff --git a/src/library/types.cr b/src/library/types.cr index 973aa5ea..d6a014f6 100644 --- a/src/library/types.cr +++ b/src/library/types.cr @@ -1,13 +1,3 @@ -SUPPORTED_IMG_TYPES = %w( - image/jpeg - image/png - image/webp - image/apng - image/avif - image/gif - image/svg+xml -) - enum SortMethod Auto Title diff --git a/src/logger.cr b/src/logger.cr index 040e5aa8..3fc56431 100644 --- a/src/logger.cr +++ b/src/logger.cr @@ -38,6 +38,7 @@ class Logger Log.setup do |c| c.bind "*", @@severity, @backend c.bind "db.*", :error, @backend + c.bind "duktape", :none, @backend end end diff --git a/src/mango.cr b/src/mango.cr index c8c8260c..ed0af490 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -7,7 +7,7 @@ require "option_parser" require "clim" require "tallboy" -MANGO_VERSION = "0.26.2" +MANGO_VERSION = "0.27.0" # From http://www.network-science.de/ascii/ BANNER = %{ diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 5175b3a0..d553ae8a 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? @@ -223,6 +224,10 @@ class Plugin 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 " \ @@ -315,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 @@ -327,6 +332,15 @@ class Plugin 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 @@ -435,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" @@ -448,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/routes/api.cr b/src/routes/api.cr index e664b281..8d31fea2 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -40,7 +40,7 @@ struct APIRouter Koa.schema "entry", { "pages" => Int32, "mtime" => Int64, - }.merge(s %w(zip_path title size id title_id display_name cover_url)), + }.merge(s %w(zip_path path title size id title_id display_name cover_url)), desc: "An entry in a book" Koa.schema "title", { @@ -142,8 +142,13 @@ struct APIRouter env.response.status_code = 304 "" else + if entry.is_a? DirEntry + cache_control = "no-cache, max-age=86400" + else + cache_control = "public, max-age=86400" + end env.response.headers["ETag"] = e_tag - env.response.headers["Cache-Control"] = "public, max-age=86400" + env.response.headers["Cache-Control"] = cache_control send_img env, img end rescue e @@ -866,13 +871,15 @@ struct APIRouter "version" => Int32, "settings" => {} of String => String, }, + "subscribable" => Bool, } get "/api/admin/plugin/info" do |env| begin plugin = Plugin.new env.params.query["plugin"].as String send_json env, { - "success" => true, - "info" => plugin.info, + "success" => true, + "info" => plugin.info, + "subscribable" => plugin.can_subscribe?, }.to_json rescue e Logger.error e @@ -1138,15 +1145,24 @@ struct APIRouter entry = title.get_entry eid raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? - file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s) + if entry.is_a? DirEntry + file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size) + else + file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s) + end e_tag = "W/#{file_hash}" if e_tag == prev_e_tag env.response.status_code = 304 send_text env, "" else sizes = entry.page_dimensions + if entry.is_a? DirEntry + cache_control = "no-cache, max-age=86400" + else + cache_control = "public, max-age=86400" + end env.response.headers["ETag"] = e_tag - env.response.headers["Cache-Control"] = "public, max-age=86400" + env.response.headers["Cache-Control"] = cache_control send_json env, { "success" => true, "dimensions" => sizes, @@ -1172,7 +1188,7 @@ struct APIRouter title = (Library.default.get_title env.params.url["tid"]).not_nil! entry = (title.get_entry env.params.url["eid"]).not_nil! - send_attachment env, entry.zip_path + send_attachment env, entry.path rescue e Logger.error e env.response.status_code = 404 diff --git a/src/routes/reader.cr b/src/routes/reader.cr index 40b86aa7..f76dc2d7 100644 --- a/src/routes/reader.cr +++ b/src/routes/reader.cr @@ -53,6 +53,7 @@ struct ReaderRouter render "src/views/reader.html.ecr" rescue e Logger.error e + Logger.debug e.backtrace? env.response.status_code = 404 end end diff --git a/src/util/signature.cr b/src/util/signature.cr index 5ca3e14e..74c8b8e3 100644 --- a/src/util/signature.cr +++ b/src/util/signature.cr @@ -19,7 +19,7 @@ class File # information as long as the above changes do not happen together with # a file/folder rename, with no library scan in between. def self.signature(filename) : UInt64 - if is_supported_file filename + if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename) File.info(filename).inode else 0u64 @@ -67,7 +67,9 @@ class Dir else # Only add its signature value to `signatures` when it is a # supported file - signatures << fn if is_supported_file fn + if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn) + signatures << fn + end end Fiber.yield end @@ -76,4 +78,19 @@ class Dir cache[dirname] = hash hash end + + def self.directory_entry_signature(dirname, cache = {} of String => String) + return cache[dirname + "?entry"] if cache[dirname + "?entry"]? + Fiber.yield + signatures = [] of String + image_files = DirEntry.sorted_image_files dirname + if image_files.size > 0 + image_files.each do |path| + signatures << File.signature(path).to_s + end + end + hash = Digest::SHA1.hexdigest(signatures.join) + cache[dirname + "?entry"] = hash + hash + end end diff --git a/src/util/util.cr b/src/util/util.cr index e7b1b1aa..50606945 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -1,8 +1,19 @@ IMGS_PER_PAGE = 5 ENTRIES_IN_HOME_SECTIONS = 8 UPLOAD_URL_PREFIX = "/uploads" -STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt) -SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"] +STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt + /manifest.json) +SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"] +SUPPORTED_IMG_TYPES = %w( + image/jpeg + image/png + image/webp + image/apng + image/avif + image/gif + image/svg+xml + image/jxl +) def random_str UUID.random.to_s.gsub "-", "" @@ -40,6 +51,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 @@ -49,6 +61,10 @@ def is_supported_file(path) SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase end +def is_supported_image_file(path) + SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path +end + struct Int def or(other : Int) if self == 0 @@ -80,9 +96,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/opds/title.xml.ecr b/src/views/opds/title.xml.ecr index b1596879..1d824901 100644 --- a/src/views/opds/title.xml.ecr +++ b/src/views/opds/title.xml.ecr @@ -29,7 +29,7 @@ - + diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index 7c3b4d55..3ff107b7 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -133,8 +133,10 @@ - - + + + +

    diff --git a/src/views/reader-error.html.ecr b/src/views/reader-error.html.ecr index 62a80fcc..ad3580f1 100644 --- a/src/views/reader-error.html.ecr +++ b/src/views/reader-error.html.ecr @@ -5,7 +5,7 @@

    Error

    -

    <%= entry.zip_path %>

    +

    <%= entry.path %>

    <%= entry.err_msg %>

    diff --git a/src/views/reader.html.ecr b/src/views/reader.html.ecr index 395de413..21357ee3 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;">
    <%- if next_entry_url -%> @@ -40,18 +40,18 @@ <%- end -%>
    -
    +
    @@ -67,7 +67,7 @@

    <%= entry.display_name %>

    -

    <%= entry.zip_path %>

    +

    <%= entry.path %>

    @@ -94,6 +94,17 @@
    +
    + +
    + +
    +
    +