Compare commits

..

No commits in common. "master" and "v0.26.0" have entirely different histories.

31 changed files with 225 additions and 693 deletions

View File

@ -12,4 +12,3 @@ Layout/LineLength:
MaxLength: 80 MaxLength: 80
Excluded: Excluded:
- src/routes/api.cr - src/routes/api.cr
- spec/plugin_spec.cr

View File

@ -4,9 +4,6 @@
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q) [![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q)
> [!CAUTION]
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
Mango is a self-hosted manga server and reader. Its features include Mango is a self-hosted manga server and reader. Its features include
- Multi-user support - Multi-user support
@ -54,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.27.0 Mango - Manga Server and Web Reader. Version 0.26.0
Usage: Usage:

View File

@ -1,7 +1,6 @@
const component = () => { const component = () => {
return { return {
plugins: [], plugins: [],
subscribable: false,
info: undefined, info: undefined,
pid: undefined, pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty chapters: undefined, // undefined: not searched yet, []: empty
@ -61,7 +60,6 @@ const component = () => {
.then((data) => { .then((data) => {
if (!data.success) throw new Error(data.error); if (!data.success) throw new Error(data.error);
this.info = data.info; this.info = data.info;
this.subscribable = data.subscribable;
this.pid = pid; this.pid = pid;
}) })
.catch((e) => { .catch((e) => {
@ -72,9 +70,6 @@ const component = () => {
}); });
}, },
pluginChanged() { pluginChanged() {
this.manga = undefined;
this.chapters = undefined;
this.mid = undefined;
this.loadPlugin(this.pid); this.loadPlugin(this.pid);
localStorage.setItem("plugin", this.pid); localStorage.setItem("plugin", this.pid);
}, },
@ -145,7 +140,6 @@ const component = () => {
if (!query) return; if (!query) return;
this.manga = undefined; this.manga = undefined;
this.mid = undefined;
if (this.info.version === 1) { if (this.info.version === 1) {
this.searchChapters(query); this.searchChapters(query);
} else { } else {

View File

@ -14,7 +14,6 @@ const readerComponent = () => {
margin: 30, margin: 30,
preloadLookahead: 3, preloadLookahead: 3,
enableRightToLeft: false, enableRightToLeft: false,
fitType: 'vert',
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
@ -30,16 +29,14 @@ const readerComponent = () => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width == 0 ? "100%" : d.width, width: d.width,
height: d.height == 0 ? "100%" : d.height, height: d.height,
}; };
}); });
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`. const avgRatio = this.items.reduce((acc, cur) => {
// TODO: support more image types in image_size.cr
const avgRatio = dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width return acc + cur.height / cur.width
}, 0) / dimensions.length; }, 0) / this.items.length;
console.log(avgRatio); console.log(avgRatio);
this.longPages = avgRatio > 2; this.longPages = avgRatio > 2;
@ -61,16 +58,11 @@ const readerComponent = () => {
// Preload Images // Preload Images
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3); this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
const limit = Math.min(page + this.preloadLookahead, this.items.length); const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
for (let idx = page + 1; idx <= limit; idx++) { for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url); 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'); const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true'; this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
@ -143,11 +135,7 @@ const readerComponent = () => {
const idx = parseInt(this.curItem.id); const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1); const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0) return; if (newIdx <= 0 || newIdx > this.items.length) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx + this.preloadLookahead < this.items.length + 1) { if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url); this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
@ -265,20 +253,12 @@ const readerComponent = () => {
}); });
}, },
/** /**
* Handles clicked image * Shows the control modal
* *
* @param {Event} event - The triggering event * @param {Event} event - The triggering event
*/ */
clickImage(event) { showControl(event) {
const idx = event.currentTarget.id; const idx = event.currentTarget.id;
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx; this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
}, },
@ -341,11 +321,6 @@ const readerComponent = () => {
this.toPage(this.selectedIndex); this.toPage(this.selectedIndex);
}, },
fitChanged(){
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
preloadLookaheadChanged() { preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead); localStorage.setItem('preloadLookahead', this.preloadLookahead);
}, },

View File

@ -68,10 +68,6 @@ shards:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git
version: 0.4.1 version: 0.4.1
sanitize:
git: https://github.com/hkalexling/sanitize.git
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0 version: 0.18.0

View File

@ -1,5 +1,5 @@
name: mango name: mango
version: 0.27.0 version: 0.26.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@ -42,5 +42,3 @@ dependencies:
branch: master branch: master
mg: mg:
github: hkalexling/mg github: hkalexling/mg
sanitize:
github: hkalexling/sanitize

View File

@ -1,6 +0,0 @@
{
"id": "test",
"title": "Test Plugin",
"placeholder": "placeholder",
"wait_seconds": 1
}

View File

@ -1,31 +1,14 @@
require "./spec_helper" require "./spec_helper"
describe Config do describe Config do
it "creates default config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |config, path| with_default_config do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
config.port.should eq 9000
end end
end end
it "correctly loads config" do it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml" config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000 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
end end

View File

@ -1,70 +0,0 @@
require "./spec_helper"
describe Plugin do
describe "helper functions" do
it "mango.text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<a href="https://github.com">Click Me<a>');
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('<img src="https://github.com" />');
JS
res.should eq ""
end
end
it "mango.css" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
JS
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
end
end
it "mango.css returns empty array when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
JS
res.should eq [] of String
end
end
it "mango.attribute" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<a href="https://github.com">Click Me<a>', '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('<div />', '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('<div data-a="<img />" data-b="test" />', 'data-b');
JS
res.should eq "test"
end
end
end
end

View File

@ -3,7 +3,6 @@ require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber" require "../src/main_fiber"
require "../src/plugin/plugin"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@ -55,10 +54,3 @@ def with_storage
end end
end end
end end
def with_plugin
with_default_config do
plugin = Plugin.new "test", "spec/asset/plugins"
yield plugin
end
end

View File

@ -1,51 +1,31 @@
require "yaml" require "yaml"
class Config 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 include YAML::Serializable
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
property path : String = "" property path = ""
property host = "0.0.0.0"
# Go through the options constant above and define them as properties. property port : Int32 = 9000
# Allow setting the default values through environment variables. property base_url = "/"
# Overall precedence: config file > environment variable > default value property session_secret = "mango-session-secret"
{% begin %} property library_path = "~/mango/library"
{% for k, v in OPTIONS %} property library_cache_path = "~/mango/library.yml.gz"
{% if v.is_a? StringLiteral %} property db_path = "~/mango/mango.db"
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }} property queue_db_path = "~/mango/queue.db"
{% elsif v.is_a? NumberLiteral %} property scan_interval_minutes : Int32 = 5
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i property thumbnail_generation_interval_hours : Int32 = 24
{% elsif v.is_a? BoolLiteral %} property log_level = "info"
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }} property upload_path = "~/mango/uploads"
{% else %} property plugin_path = "~/mango/plugins"
raise "Unknown type in config option: {{ v.class_name.id }}" property download_timeout_seconds : Int32 = 30
{% end %} property cache_enabled = true
{% end %} property cache_size_mbs = 50
{% end %} property cache_log_enabled = true
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property plugin_update_interval_hours : Int32 = 24
@@singlet : Config? @@singlet : Config?
@ -58,7 +38,7 @@ class Config
end end
def self.load(path : String?) def self.load(path : String?)
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path

View File

@ -1,111 +0,0 @@
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

View File

@ -1,132 +0,0 @@
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

View File

@ -1,55 +1,66 @@
require "image_size" require "image_size"
require "yaml"
private def node_has_key(node : YAML::Nodes::Mapping, key : String) class Entry
node.nodes include YAML::Serializable
.map_with_index { |n, i| {n, i} }
.select(&.[1].even?) getter zip_path : String, book : Title, title : String,
.map(&.[0]) size : String, pages : Int32, id : String, encoded_path : String,
.select(YAML::Nodes::Scalar) encoded_title : String, mtime : Time, err_msg : String?
.map(&.as(YAML::Nodes::Scalar).value)
.includes? key @[YAML::Field(ignore: true)]
@sort_title : 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 end
abstract class Entry archive_exception = validate_archive @zip_path
getter id : String, book : Title, title : String, path : String, unless archive_exception.nil?
size : String, pages : Int32, mtime : Time, @err_msg = "Archive error: #{archive_exception}"
encoded_path : String, encoded_title : String, err_msg : String? Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
def initialize( return
@id, @title, @book, @path,
@size, @pages, @mtime,
@encoded_path, @encoded_title, @err_msg
)
end end
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) file = ArchiveFile.new @zip_path
unless node.is_a? YAML::Nodes::Mapping @pages = file.entries.count do |e|
raise "Unexpected node type in YAML" SUPPORTED_IMG_TYPES.includes? \
end 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 end
file.close
end end
def build_json(*, slim = false) def build_json(*, slim = false)
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for str in %w(path title size id) %} {% for str in %w(zip_path title size id) %}
json.field {{str}}, {{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
if err_msg if err_msg
json.field "err_msg", err_msg json.field "err_msg", err_msg
end end
json.field "zip_path", path # for API backward compatability
json.field "path", path
json.field "title_id", @book.id json.field "title_id", @book.id
json.field "title_title", @book.title json.field "title_title", @book.title
json.field "sort_title", sort_title json.field "sort_title", sort_title
@ -63,9 +74,6 @@ abstract class Entry
end end
end end
@[YAML::Field(ignore: true)]
@sort_title : String?
def sort_title def sort_title
sort_title_cached = @sort_title sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached return sort_title_cached if sort_title_cached
@ -123,6 +131,58 @@ abstract class Entry
url url
end 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) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
@ -137,6 +197,20 @@ abstract class Entry
entries[idx - 1] entries[idx - 1]
end 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 # For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json # instead of IDs in info.json
def save_progress(username, page) def save_progress(username, page)
@ -216,7 +290,7 @@ abstract class Entry
end end
Storage.default.save_thumbnail @id, img Storage.default.save_thumbnail @id, img
rescue e rescue e
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}" Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end end
img img
@ -225,34 +299,4 @@ abstract class Entry
def get_thumbnail : Image? def get_thumbnail : Image?
Storage.default.get_thumbnail @id Storage.default.get_thumbnail @id
end 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 end

View File

@ -49,18 +49,13 @@ class Title
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, cache title = Title.new path, @id, cache
unless title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @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 next
end end
if is_supported_file path if is_supported_file path
entry = ArchiveEntry.new path, self entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
@ -132,12 +127,12 @@ class Title
previous_entries_size = @entries.size previous_entries_size = @entries.size
@entries.select! do |entry| @entries.select! do |entry|
existence = entry.examine existence = File.exists? entry.zip_path
Fiber.yield Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence context["deleted_entry_ids"] << entry.id unless existence
existence existence
end end
remained_entry_paths = @entries.map &.path remained_entry_zip_paths = @entries.map &.zip_path
is_titles_added = false is_titles_added = false
is_entries_added = false is_entries_added = false
@ -145,22 +140,9 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path 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 next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"] title = Title.new path, @id, context["cached_contents_signature"]
unless title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
is_titles_added = true is_titles_added = true
@ -175,13 +157,12 @@ class Title
context["deleted_entry_ids"].select! do |deleted_entry_id| context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id) !(revival_entry_ids.includes? deleted_entry_id)
end end
end
next next
end end
if is_supported_file path if is_supported_file path
next if remained_entry_paths.includes? path next if remained_entry_zip_paths.includes? path
entry = ArchiveEntry.new path, self entry = Entry.new path, self
if entry.pages > 0 || entry.err_msg if entry.pages > 0 || entry.err_msg
@entries << entry @entries << entry
is_entries_added = true is_entries_added = true
@ -632,16 +613,6 @@ class Title
if last_read_entry && last_read_entry.finished? username if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry 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 end
last_read_entry last_read_entry
@ -656,7 +627,7 @@ class Title
@entries.each do |e| @entries.each do |e|
next if da.has_key? e.title next if da.has_key? e.title
da[e.title] = ctime e.path da[e.title] = ctime e.zip_path
end end
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|

View File

@ -1,3 +1,13 @@
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
)
enum SortMethod enum SortMethod
Auto Auto
Title Title

View File

@ -38,7 +38,6 @@ class Logger
Log.setup do |c| Log.setup do |c|
c.bind "*", @@severity, @backend c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend c.bind "db.*", :error, @backend
c.bind "duktape", :none, @backend
end end
end end

View File

@ -7,7 +7,7 @@ require "option_parser"
require "clim" require "clim"
require "tallboy" require "tallboy"
MANGO_VERSION = "0.27.0" MANGO_VERSION = "0.26.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{

View File

@ -105,10 +105,9 @@ class Plugin
getter js_path = "" getter js_path = ""
getter storage_path = "" getter storage_path = ""
def self.build_info_ary(dir : String? = nil) def self.build_info_ary
@@info_ary.clear @@info_ary.clear
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
Dir.each_child dir do |f| Dir.each_child dir do |f|
@ -161,8 +160,8 @@ class Plugin
list.save list.save
end end
def initialize(id : String, dir : String? = nil) def initialize(id : String)
Plugin.build_info_ary dir Plugin.build_info_ary
@info = @@info_ary.find &.id.== id @info = @@info_ary.find &.id.== id
if @info.nil? if @info.nil?
@ -224,10 +223,6 @@ class Plugin
raise Error.new "Missing required fields in the Page type" raise Error.new "Missing required fields in the Page type"
end end
def can_subscribe? : Bool
info.version > 1 && eval_exists?("newChapters")
end
def search_manga(query : String) def search_manga(query : String)
if info.version == 1 if info.version == 1
raise Error.new "Manga searching is only available for plugins " \ raise Error.new "Manga searching is only available for plugins " \
@ -320,7 +315,7 @@ class Plugin
json json
end end
def eval(str) private def eval(str)
@rt.eval str @rt.eval str
rescue e : Duktape::SyntaxError rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message raise SyntaxError.new e.message
@ -332,15 +327,6 @@ class Plugin
JSON.parse eval(str).as String JSON.parse eval(str).as String
end 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) private def def_helper_functions(sbx)
sbx.push_object sbx.push_object
@ -449,15 +435,9 @@ class Plugin
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
begin str = XML.parse(html).inner_text
parser = Myhtml::Parser.new html
str = parser.body!.children.first.inner_text
env.push_string str env.push_string str
rescue
env.push_string ""
end
env.call_success env.call_success
end end
sbx.put_prop_string -2, "text" sbx.put_prop_string -2, "text"
@ -468,9 +448,8 @@ class Plugin
name = env.require_string 1 name = env.require_string 1
begin begin
parser = Myhtml::Parser.new html attr = XML.parse(html).first_element_child.not_nil![name]
attr = parser.body!.children.first.attribute_by name env.push_string attr
env.push_string attr.not_nil!
rescue rescue
env.push_undefined env.push_undefined
end end

View File

@ -1,5 +1,3 @@
require "sanitize"
struct AdminRouter struct AdminRouter
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
@ -16,13 +14,13 @@ struct AdminRouter
end end
get "/admin/user/edit" do |env| get "/admin/user/edit" do |env|
sanitizer = Sanitize::Policy::Text.new username = env.params.query["username"]?
username = env.params.query["username"]?.try { |s| sanitizer.process s }
admin = env.params.query["admin"]? admin = env.params.query["admin"]?
if admin if admin
admin = admin == "true" admin = admin == "true"
end end
error = env.params.query["error"]?.try { |s| sanitizer.process s } error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil? new_user = username.nil? && admin.nil?
layout "user-edit" layout "user-edit"
end end

View File

@ -40,7 +40,7 @@ struct APIRouter
Koa.schema "entry", { Koa.schema "entry", {
"pages" => Int32, "pages" => Int32,
"mtime" => Int64, "mtime" => Int64,
}.merge(s %w(zip_path path title size id title_id display_name cover_url)), }.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book" desc: "An entry in a book"
Koa.schema "title", { Koa.schema "title", {
@ -142,13 +142,8 @@ struct APIRouter
env.response.status_code = 304 env.response.status_code = 304
"" ""
else 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["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control env.response.headers["Cache-Control"] = "public, max-age=86400"
send_img env, img send_img env, img
end end
rescue e rescue e
@ -871,7 +866,6 @@ struct APIRouter
"version" => Int32, "version" => Int32,
"settings" => {} of String => String, "settings" => {} of String => String,
}, },
"subscribable" => Bool,
} }
get "/api/admin/plugin/info" do |env| get "/api/admin/plugin/info" do |env|
begin begin
@ -879,7 +873,6 @@ struct APIRouter
send_json env, { send_json env, {
"success" => true, "success" => true,
"info" => plugin.info, "info" => plugin.info,
"subscribable" => plugin.can_subscribe?,
}.to_json }.to_json
rescue e rescue e
Logger.error e Logger.error e
@ -1145,24 +1138,15 @@ struct APIRouter
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
if entry.is_a? DirEntry file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
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}" e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag if e_tag == prev_e_tag
env.response.status_code = 304 env.response.status_code = 304
send_text env, "" send_text env, ""
else else
sizes = entry.page_dimensions 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["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control env.response.headers["Cache-Control"] = "public, max-age=86400"
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
@ -1188,7 +1172,7 @@ struct APIRouter
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil! entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.path send_attachment env, entry.zip_path
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404

View File

@ -53,7 +53,6 @@ struct ReaderRouter
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
Logger.error e Logger.error e
Logger.debug e.backtrace?
env.response.status_code = 404 env.response.status_code = 404
end end
end end

View File

@ -23,7 +23,6 @@ class Server
AdminRouter.new AdminRouter.new
ReaderRouter.new ReaderRouter.new
APIRouter.new APIRouter.new
OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %} {% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env| options {{path}} do |env|

View File

@ -19,7 +19,7 @@ class File
# information as long as the above changes do not happen together with # information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between. # a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64 def self.signature(filename) : UInt64
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename) if is_supported_file filename
File.info(filename).inode File.info(filename).inode
else else
0u64 0u64
@ -67,9 +67,7 @@ class Dir
else else
# Only add its signature value to `signatures` when it is a # Only add its signature value to `signatures` when it is a
# supported file # supported file
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn) signatures << fn if is_supported_file fn
signatures << fn
end
end end
Fiber.yield Fiber.yield
end end
@ -78,19 +76,4 @@ class Dir
cache[dirname] = hash cache[dirname] = hash
hash hash
end 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 end

View File

@ -1,19 +1,8 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8 ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
/manifest.json)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"] 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 def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
@ -51,7 +40,6 @@ def register_mime_types
# defiend by Crystal in `MIME.DEFAULT_TYPES` # defiend by Crystal in `MIME.DEFAULT_TYPES`
".apng" => "image/apng", ".apng" => "image/apng",
".avif" => "image/avif", ".avif" => "image/avif",
".jxl" => "image/jxl",
}.each do |k, v| }.each do |k, v|
MIME.register k, v MIME.register k, v
end end
@ -61,10 +49,6 @@ def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end end
def is_supported_image_file(path)
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
end
struct Int struct Int
def or(other : Int) def or(other : Int)
if self == 0 if self == 0
@ -96,9 +80,9 @@ class String
end end
end end
def env_is_true?(key : String, default : Bool = false) : Bool def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]? val = ENV[key.upcase]? || ENV[key.downcase]?
return default unless val return false unless val
val.downcase.in? "1", "true" val.downcase.in? "1", "true"
end end

View File

@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />

View File

@ -133,10 +133,8 @@
</template> </template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button> <button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button> <button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span x-show="subscribable">
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span> <span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button> <button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
</form> </form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p> <p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>

View File

@ -5,7 +5,7 @@
<div> <div>
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3> <h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
</div> </div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p> <p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">

View File

@ -5,7 +5,7 @@
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()"> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'"> <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@ -19,7 +19,7 @@
</div> </div>
<div <div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;"> :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak> <div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-if="!loading && mode === 'continuous'" x-for="item in items"> <template x-if="!loading && mode === 'continuous'" x-for="item in items">
<img <img
@ -30,7 +30,7 @@
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`" :style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="clickImage($event)" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
@ -40,18 +40,18 @@
<%- end -%> <%- end -%>
</div> </div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`"> <div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{ <img uk-img :class="{
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${fitType === 'horz' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${fitType === 'vert' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' }; max-width:100%;
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'}; max-height:100%;
object-fit: contain; object-fit: contain;
`" /> `" />
@ -67,7 +67,7 @@
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3> <h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <div class="uk-margin">
@ -94,17 +94,6 @@
</div> </div>
</div> </div>
<div class="uk-margin" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="mode-select">Page fit</label>
<div class="uk-form-controls">
<select id="fit-select" class="uk-select" @change="fitChanged()">
<option value="vert">Fit height</option>
<option value="horz">Fit width</option>
<option value="real">Real size</option>
</select>
</div>
</div>
<div class="uk-margin" x-show="mode === 'continuous'"> <div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label> <label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls"> <div class="uk-form-controls">