mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-03 03:15:31 -04:00
Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7a7d9eb3a1 | ||
|
1fb48648ad | ||
|
7ceb91f051 | ||
|
9ea4ced729 | ||
|
4c2f802e2e | ||
|
7258b3cece | ||
|
bf885a8b30 | ||
|
98a0c54499 | ||
|
cb3df432d0 | ||
|
47af6ee284 | ||
|
9fe269ab13 | ||
|
75a30a88e0 | ||
|
5daeac72cb | ||
|
dc3ac42dec | ||
|
624283643c | ||
|
6ddbe8d436 | ||
|
db5e99b3f0 | ||
|
405b958deb | ||
|
e7c4123dec | ||
|
2d2486a598 | ||
|
b6a1ad889e | ||
|
f2d6d28a72 | ||
|
49425ff714 | ||
|
f3eb62a271 | ||
|
2e91028ead | ||
|
19a8f3100b | ||
|
3b5e764d36 | ||
|
32ce26a133 | ||
|
31df058f81 | ||
|
fe440d82d4 | ||
|
44636e051e | ||
|
a639392ca0 | ||
|
17a9c8ecd3 | ||
|
bbc0c2cbb7 | ||
|
be46dd1f86 | ||
|
ae583cf2a9 | ||
|
ea35faee91 | ||
|
5b58d8ac59 | ||
|
30d5ad0c19 | ||
|
d9dce4a881 | ||
|
2d97faa7c0 | ||
|
9ce8e918f0 | ||
|
8e4bb995d3 | ||
|
39a331c879 | ||
|
df618704ea | ||
|
2fb620211d | ||
|
5b23a112b2 | ||
|
e6dbeb623b | ||
|
872e6dc6d6 | ||
|
82c60ccc1d | ||
|
ae503ae099 | ||
|
648cdd772c | ||
|
238539c27d | ||
|
1f5aed64f7 | ||
|
f18f6a5418 | ||
|
0ed565519b | ||
|
3da5d9ba4e | ||
|
3a60286c3a | ||
|
9f6be70995 | ||
|
caf4cfb6cd | ||
|
137e84dfb6 | ||
|
3b3a0738e8 | ||
|
55ccd928a2 | ||
|
10587f48cb | ||
|
ea6cbbd9ce | ||
|
883e01bbdd | ||
|
5f59b7ee42 | ||
|
eac274a211 | ||
|
0e4169cb22 | ||
|
28656695c6 | ||
|
61dc92838a | ||
|
ce1dcff229 | ||
|
4f599fb719 |
@ -12,3 +12,4 @@ Layout/LineLength:
|
|||||||
MaxLength: 80
|
MaxLength: 80
|
||||||
Excluded:
|
Excluded:
|
||||||
- src/routes/api.cr
|
- src/routes/api.cr
|
||||||
|
- spec/plugin_spec.cr
|
||||||
|
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](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
|
||||||
@ -51,7 +54,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.26.1
|
Mango - Manga Server and Web Reader. Version 0.27.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@ -60,6 +61,7 @@ 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) => {
|
||||||
@ -70,6 +72,9 @@ 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);
|
||||||
},
|
},
|
||||||
@ -140,6 +145,7 @@ 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 {
|
||||||
|
@ -14,6 +14,7 @@ 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
|
||||||
@ -29,14 +30,16 @@ 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,
|
width: d.width == 0 ? "100%" : d.width,
|
||||||
height: d.height,
|
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
|
return acc + cur.height / cur.width
|
||||||
}, 0) / this.items.length;
|
}, 0) / dimensions.length;
|
||||||
|
|
||||||
console.log(avgRatio);
|
console.log(avgRatio);
|
||||||
this.longPages = avgRatio > 2;
|
this.longPages = avgRatio > 2;
|
||||||
@ -58,11 +61,16 @@ 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 + 1);
|
const limit = Math.min(page + this.preloadLookahead, this.items.length);
|
||||||
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';
|
||||||
|
|
||||||
@ -135,7 +143,11 @@ 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 || 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) {
|
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);
|
||||||
@ -253,12 +265,20 @@ const readerComponent = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Shows the control modal
|
* Handles clicked image
|
||||||
*
|
*
|
||||||
* @param {Event} event - The triggering event
|
* @param {Event} event - The triggering event
|
||||||
*/
|
*/
|
||||||
showControl(event) {
|
clickImage(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();
|
||||||
},
|
},
|
||||||
@ -321,6 +341,11 @@ 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);
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.26.1
|
version: 0.27.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
|
0
spec/asset/plugins/plugin/index.js
Normal file
0
spec/asset/plugins/plugin/index.js
Normal file
6
spec/asset/plugins/plugin/info.json
Normal file
6
spec/asset/plugins/plugin/info.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "test",
|
||||||
|
"title": "Test Plugin",
|
||||||
|
"placeholder": "placeholder",
|
||||||
|
"wait_seconds": 1
|
||||||
|
}
|
@ -1,14 +1,31 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Config do
|
describe Config do
|
||||||
it "creates config if it does not exist" do
|
it "creates default config if it does not exist" do
|
||||||
with_default_config do |_, path|
|
with_default_config do |config, 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
|
||||||
|
70
spec/plugin_spec.cr
Normal file
70
spec/plugin_spec.cr
Normal file
@ -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('<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
|
@ -3,6 +3,7 @@ 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
|
||||||
@ -54,3 +55,10 @@ 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
|
||||||
|
@ -1,31 +1,51 @@
|
|||||||
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 = ""
|
property path : String = ""
|
||||||
property host = "0.0.0.0"
|
|
||||||
property port : Int32 = 9000
|
# Go through the options constant above and define them as properties.
|
||||||
property base_url = "/"
|
# Allow setting the default values through environment variables.
|
||||||
property session_secret = "mango-session-secret"
|
# Overall precedence: config file > environment variable > default value
|
||||||
property library_path = "~/mango/library"
|
{% begin %}
|
||||||
property library_cache_path = "~/mango/library.yml.gz"
|
{% for k, v in OPTIONS %}
|
||||||
property db_path = "~/mango/mango.db"
|
{% if v.is_a? StringLiteral %}
|
||||||
property queue_db_path = "~/mango/queue.db"
|
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
|
||||||
property scan_interval_minutes : Int32 = 5
|
{% elsif v.is_a? NumberLiteral %}
|
||||||
property thumbnail_generation_interval_hours : Int32 = 24
|
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
|
||||||
property log_level = "info"
|
{% elsif v.is_a? BoolLiteral %}
|
||||||
property upload_path = "~/mango/uploads"
|
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
|
||||||
property plugin_path = "~/mango/plugins"
|
{% else %}
|
||||||
property download_timeout_seconds : Int32 = 30
|
raise "Unknown type in config option: {{ v.class_name.id }}"
|
||||||
property cache_enabled = true
|
{% end %}
|
||||||
property cache_size_mbs = 50
|
{% end %}
|
||||||
property cache_log_enabled = true
|
{% end %}
|
||||||
property disable_login = false
|
|
||||||
property default_username = ""
|
|
||||||
property auth_proxy_header_name = ""
|
|
||||||
property plugin_update_interval_hours : Int32 = 24
|
|
||||||
|
|
||||||
@@singlet : Config?
|
@@singlet : Config?
|
||||||
|
|
||||||
@ -38,7 +58,7 @@ class Config
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.load(path : String?)
|
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
|
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
|
||||||
|
111
src/library/archive_entry.cr
Normal file
111
src/library/archive_entry.cr
Normal file
@ -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
|
@ -76,8 +76,8 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
|||||||
entries : Array(Entry), opt : SortOptions?)
|
entries : Array(Entry), opt : SortOptions?)
|
||||||
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
|
||||||
(opt ? opt.to_tuple.to_s : "nil"))
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
"#{sig}:sorted_entries"
|
"#{sig}:sorted_entries"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
|||||||
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||||
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
|
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
|
||||||
(opt ? opt.to_tuple.to_s : "nil"))
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
"#{sig}:sorted_titles"
|
"#{sig}:sorted_titles"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
132
src/library/dir_entry.cr
Normal file
132
src/library/dir_entry.cr
Normal file
@ -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
|
@ -1,66 +1,55 @@
|
|||||||
require "image_size"
|
require "image_size"
|
||||||
require "yaml"
|
|
||||||
|
|
||||||
class Entry
|
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
|
||||||
include YAML::Serializable
|
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
|
||||||
|
|
||||||
getter zip_path : String, book : Title, title : String,
|
abstract class Entry
|
||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
getter id : String, book : Title, title : String, path : String,
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
size : String, pages : Int32, mtime : Time,
|
||||||
|
encoded_path : String, encoded_title : String, err_msg : String?
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
def initialize(
|
||||||
@sort_title : String?
|
@id, @title, @book, @path,
|
||||||
|
@size, @pages, @mtime,
|
||||||
|
@encoded_path, @encoded_title, @err_msg
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(@zip_path, @book)
|
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
|
||||||
storage = Storage.default
|
unless node.is_a? YAML::Nodes::Mapping
|
||||||
@encoded_path = URI.encode @zip_path
|
raise "Unexpected node type in YAML"
|
||||||
@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
|
end
|
||||||
@id = id
|
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
|
||||||
@mtime = File.info(@zip_path).modification_time
|
# instead we are using a more hacky approach (see `node_has_key`).
|
||||||
|
# TODO: Use a more elegant approach
|
||||||
unless File.readable? @zip_path
|
if node_has_key node, "zip_path"
|
||||||
@err_msg = "File #{@zip_path} is not readable."
|
ArchiveEntry.new ctx, node
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
elsif node_has_key node, "dir_path"
|
||||||
"file permission is configured correctly."
|
DirEntry.new ctx, node
|
||||||
return
|
else
|
||||||
|
raise "Unknown entry found in YAML cache. Try deleting the " \
|
||||||
|
"`library.yml.gz` file"
|
||||||
end
|
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
|
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(zip_path title size id) %}
|
{% for str in %w(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
|
||||||
@ -74,6 +63,9 @@ 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
|
||||||
@ -131,58 +123,6 @@ 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
|
||||||
@ -197,20 +137,6 @@ 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)
|
||||||
@ -290,7 +216,7 @@ 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 #{@zip_path}. #{e}"
|
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
|
||||||
end
|
end
|
||||||
|
|
||||||
img
|
img
|
||||||
@ -299,4 +225,34 @@ 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
|
||||||
|
@ -49,13 +49,18 @@ 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
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
unless 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 = Entry.new path, self
|
entry = ArchiveEntry.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
|
||||||
@ -127,12 +132,12 @@ class Title
|
|||||||
|
|
||||||
previous_entries_size = @entries.size
|
previous_entries_size = @entries.size
|
||||||
@entries.select! do |entry|
|
@entries.select! do |entry|
|
||||||
existence = File.exists? entry.zip_path
|
existence = entry.examine
|
||||||
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_zip_paths = @entries.map &.zip_path
|
remained_entry_paths = @entries.map &.path
|
||||||
|
|
||||||
is_titles_added = false
|
is_titles_added = false
|
||||||
is_entries_added = false
|
is_entries_added = false
|
||||||
@ -140,29 +145,43 @@ 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"]
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
unless 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
|
||||||
|
|
||||||
# We think they are removed, but they are here!
|
# We think they are removed, but they are here!
|
||||||
# Cancel reserved jobs
|
# Cancel reserved jobs
|
||||||
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||||
context["deleted_title_ids"].select! do |deleted_title_id|
|
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||||
!(revival_title_ids.includes? deleted_title_id)
|
!(revival_title_ids.includes? deleted_title_id)
|
||||||
end
|
end
|
||||||
revival_entry_ids = title.deep_entries.map &.id
|
revival_entry_ids = title.deep_entries.map &.id
|
||||||
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_zip_paths.includes? path
|
next if remained_entry_paths.includes? path
|
||||||
entry = Entry.new path, self
|
entry = ArchiveEntry.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
|
||||||
@ -613,6 +632,16 @@ 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
|
||||||
@ -627,7 +656,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.zip_path
|
da[e.title] = ctime e.path
|
||||||
end
|
end
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
|
@ -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
|
enum SortMethod
|
||||||
Auto
|
Auto
|
||||||
Title
|
Title
|
||||||
|
@ -38,6 +38,7 @@ 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
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.26.1"
|
MANGO_VERSION = "0.27.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
|
@ -105,9 +105,10 @@ class Plugin
|
|||||||
getter js_path = ""
|
getter js_path = ""
|
||||||
getter storage_path = ""
|
getter storage_path = ""
|
||||||
|
|
||||||
def self.build_info_ary
|
def self.build_info_ary(dir : String? = nil)
|
||||||
@@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|
|
||||||
@ -160,8 +161,8 @@ class Plugin
|
|||||||
list.save
|
list.save
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(id : String)
|
def initialize(id : String, dir : String? = nil)
|
||||||
Plugin.build_info_ary
|
Plugin.build_info_ary dir
|
||||||
|
|
||||||
@info = @@info_ary.find &.id.== id
|
@info = @@info_ary.find &.id.== id
|
||||||
if @info.nil?
|
if @info.nil?
|
||||||
@ -223,6 +224,10 @@ 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 " \
|
||||||
@ -315,7 +320,7 @@ class Plugin
|
|||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
private def eval(str)
|
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
|
||||||
@ -327,6 +332,15 @@ 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
|
||||||
|
|
||||||
@ -435,9 +449,15 @@ class Plugin
|
|||||||
env = Duktape::Sandbox.new ptr
|
env = Duktape::Sandbox.new ptr
|
||||||
html = env.require_string 0
|
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
|
env.call_success
|
||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "text"
|
sbx.put_prop_string -2, "text"
|
||||||
@ -448,8 +468,9 @@ class Plugin
|
|||||||
name = env.require_string 1
|
name = env.require_string 1
|
||||||
|
|
||||||
begin
|
begin
|
||||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
parser = Myhtml::Parser.new html
|
||||||
env.push_string attr
|
attr = parser.body!.children.first.attribute_by name
|
||||||
|
env.push_string attr.not_nil!
|
||||||
rescue
|
rescue
|
||||||
env.push_undefined
|
env.push_undefined
|
||||||
end
|
end
|
||||||
|
@ -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 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"
|
desc: "An entry in a book"
|
||||||
|
|
||||||
Koa.schema "title", {
|
Koa.schema "title", {
|
||||||
@ -142,8 +142,13 @@ 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"] = "public, max-age=86400"
|
env.response.headers["Cache-Control"] = cache_control
|
||||||
send_img env, img
|
send_img env, img
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
@ -866,13 +871,15 @@ 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
|
||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
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
|
||||||
@ -1138,15 +1145,24 @@ 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?
|
||||||
|
|
||||||
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}"
|
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"] = "public, max-age=86400"
|
env.response.headers["Cache-Control"] = cache_control
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
"dimensions" => sizes,
|
"dimensions" => sizes,
|
||||||
@ -1172,7 +1188,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.zip_path
|
send_attachment env, entry.path
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
|
@ -53,6 +53,7 @@ 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
|
||||||
|
@ -23,6 +23,7 @@ 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|
|
||||||
|
@ -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 is_supported_file filename
|
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
|
||||||
File.info(filename).inode
|
File.info(filename).inode
|
||||||
else
|
else
|
||||||
0u64
|
0u64
|
||||||
@ -67,7 +67,9 @@ 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
|
||||||
signatures << fn if is_supported_file fn
|
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
|
||||||
|
signatures << fn
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
end
|
end
|
||||||
@ -76,4 +78,19 @@ 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
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
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
|
||||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
/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
|
def random_str
|
||||||
UUID.random.to_s.gsub "-", ""
|
UUID.random.to_s.gsub "-", ""
|
||||||
@ -40,6 +51,7 @@ 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
|
||||||
@ -49,6 +61,10 @@ 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
|
||||||
@ -80,9 +96,9 @@ class String
|
|||||||
end
|
end
|
||||||
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]?
|
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||||
return false unless val
|
return default unless val
|
||||||
val.downcase.in? "1", "true"
|
val.downcase.in? "1", "true"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -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.zip_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.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 %>" />
|
||||||
|
@ -133,8 +133,10 @@
|
|||||||
</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 class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
|
<span x-show="subscribable">
|
||||||
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
|
<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>
|
||||||
|
</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>
|
||||||
|
@ -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.zip_path %></p>
|
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.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">
|
||||||
|
@ -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'">
|
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'">
|
||||||
|
|
||||||
<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'}">
|
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;">
|
||||||
<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="showControl($event)"
|
@click="clickImage($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:100vh">
|
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`">
|
||||||
|
|
||||||
<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="showControl($event)" :style="`
|
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
|
||||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
width:${fitType === 'horz' ? '100vw' : 'auto'};
|
||||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
height:${fitType === 'vert' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
max-width:100%;
|
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
|
||||||
max-height:100%;
|
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
|
||||||
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.zip_path %></p>
|
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@ -94,6 +94,17 @@
|
|||||||
</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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user