mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
90 Commits
feature/pl
...
fix/hide-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ce1dcff229 | ||
|
|
4f599fb719 | ||
|
|
c831879c23 | ||
|
|
171b44643c | ||
|
|
a353029fcd | ||
|
|
75e26d8624 | ||
|
|
ebe2c8efed | ||
|
|
b8ce1cc7f1 | ||
|
|
24c90e7283 | ||
|
|
9ffc34e8e6 | ||
|
|
d1de8b7a4e | ||
|
|
7ae0577e4e | ||
|
|
e9b1bccbc9 | ||
|
|
293fb84e1d | ||
|
|
9c07944390 | ||
|
|
173d69eb26 | ||
|
|
21d8d0e8a7 | ||
|
|
61e85dd49f | ||
|
|
c778364ca2 | ||
|
|
7ecdb1c0dd | ||
|
|
a5a7396edd | ||
|
|
461398d219 | ||
|
|
0d52544617 | ||
|
|
c3736d222c | ||
|
|
2091053221 | ||
|
|
703e6d076b | ||
|
|
a101526672 | ||
|
|
eca47e3d32 |
@@ -12,3 +12,4 @@ Layout/LineLength:
|
||||
MaxLength: 80
|
||||
Excluded:
|
||||
- src/routes/api.cr
|
||||
- spec/plugin_spec.cr
|
||||
|
||||
@@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango - Manga Server and Web Reader. Version 0.25.0
|
||||
Mango - Manga Server and Web Reader. Version 0.26.2
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -88,15 +88,16 @@ upload_path: ~/mango/uploads
|
||||
plugin_path: ~/mango/plugins
|
||||
download_timeout_seconds: 30
|
||||
library_cache_path: ~/mango/library.yml.gz
|
||||
cache_enabled: false
|
||||
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
|
||||
```
|
||||
|
||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const component = () => {
|
||||
return {
|
||||
plugins: [],
|
||||
subscribable: false,
|
||||
info: undefined,
|
||||
pid: undefined,
|
||||
chapters: undefined, // undefined: not searched yet, []: empty
|
||||
@@ -60,6 +61,7 @@ const component = () => {
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.info = data.info;
|
||||
this.subscribable = data.subscribable;
|
||||
this.pid = pid;
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -70,6 +72,9 @@ const component = () => {
|
||||
});
|
||||
},
|
||||
pluginChanged() {
|
||||
this.manga = undefined;
|
||||
this.chapters = undefined;
|
||||
this.mid = undefined;
|
||||
this.loadPlugin(this.pid);
|
||||
localStorage.setItem("plugin", this.pid);
|
||||
},
|
||||
@@ -140,6 +145,7 @@ const component = () => {
|
||||
if (!query) return;
|
||||
|
||||
this.manga = undefined;
|
||||
this.mid = undefined;
|
||||
if (this.info.version === 1) {
|
||||
this.searchChapters(query);
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ const readerComponent = () => {
|
||||
margin: 30,
|
||||
preloadLookahead: 3,
|
||||
enableRightToLeft: false,
|
||||
fitType: 'vert',
|
||||
|
||||
/**
|
||||
* Initialize the component by fetching the page dimensions
|
||||
@@ -29,14 +30,16 @@ const readerComponent = () => {
|
||||
return {
|
||||
id: i + 1,
|
||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
width: d.width == 0 ? "100%" : d.width,
|
||||
height: d.height == 0 ? "100%" : d.height,
|
||||
};
|
||||
});
|
||||
|
||||
const avgRatio = this.items.reduce((acc, cur) => {
|
||||
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
|
||||
// TODO: support more image types in image_size.cr
|
||||
const avgRatio = dimensions.reduce((acc, cur) => {
|
||||
return acc + cur.height / cur.width
|
||||
}, 0) / this.items.length;
|
||||
}, 0) / dimensions.length;
|
||||
|
||||
console.log(avgRatio);
|
||||
this.longPages = avgRatio > 2;
|
||||
@@ -58,11 +61,16 @@ const readerComponent = () => {
|
||||
|
||||
// Preload Images
|
||||
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||
const limit = Math.min(page + this.preloadLookahead, this.items.length);
|
||||
for (let idx = page + 1; idx <= limit; idx++) {
|
||||
this.preloadImage(this.items[idx - 1].url);
|
||||
}
|
||||
|
||||
const savedFitType = localStorage.getItem('fitType');
|
||||
if (savedFitType) {
|
||||
this.fitType = savedFitType;
|
||||
$('#fit-select').val(savedFitType);
|
||||
}
|
||||
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||
|
||||
@@ -135,7 +143,11 @@ const readerComponent = () => {
|
||||
const idx = parseInt(this.curItem.id);
|
||||
const newIdx = idx + (isNext ? 1 : -1);
|
||||
|
||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||
if (newIdx <= 0) return;
|
||||
if (newIdx > this.items.length) {
|
||||
this.showControl(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||
@@ -253,12 +265,20 @@ const readerComponent = () => {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Shows the control modal
|
||||
* Handles clicked image
|
||||
*
|
||||
* @param {Event} event - The triggering event
|
||||
*/
|
||||
showControl(event) {
|
||||
clickImage(event) {
|
||||
const idx = event.currentTarget.id;
|
||||
this.showControl(idx);
|
||||
},
|
||||
/**
|
||||
* Shows the control modal
|
||||
*
|
||||
* @param {number} idx - selected page index
|
||||
*/
|
||||
showControl(idx) {
|
||||
this.selectedIndex = idx;
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
},
|
||||
@@ -321,6 +341,11 @@ const readerComponent = () => {
|
||||
this.toPage(this.selectedIndex);
|
||||
},
|
||||
|
||||
fitChanged(){
|
||||
this.fitType = $('#fit-select').val();
|
||||
localStorage.setItem('fitType', this.fitType);
|
||||
},
|
||||
|
||||
preloadLookaheadChanged() {
|
||||
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ shards:
|
||||
|
||||
koa:
|
||||
git: https://github.com/hkalexling/koa.git
|
||||
version: 0.8.0
|
||||
version: 0.9.0
|
||||
|
||||
mg:
|
||||
git: https://github.com/hkalexling/mg.git
|
||||
@@ -68,6 +68,10 @@ shards:
|
||||
git: https://github.com/luislavena/radix.git
|
||||
version: 0.4.1
|
||||
|
||||
sanitize:
|
||||
git: https://github.com/hkalexling/sanitize.git
|
||||
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
||||
|
||||
sqlite3:
|
||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||
version: 0.18.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.25.0
|
||||
version: 0.26.2
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -42,3 +42,5 @@ dependencies:
|
||||
branch: master
|
||||
mg:
|
||||
github: hkalexling/mg
|
||||
sanitize:
|
||||
github: hkalexling/sanitize
|
||||
|
||||
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"
|
||||
|
||||
describe Config do
|
||||
it "creates config if it does not exist" do
|
||||
with_default_config do |_, path|
|
||||
it "creates default config if it does not exist" do
|
||||
with_default_config do |config, path|
|
||||
File.exists?(path).should be_true
|
||||
config.port.should eq 9000
|
||||
end
|
||||
end
|
||||
|
||||
it "correctly loads config" do
|
||||
config = Config.load "spec/asset/test-config.yml"
|
||||
config.port.should eq 3000
|
||||
config.base_url.should eq "/"
|
||||
end
|
||||
|
||||
it "correctly reads config defaults from ENV" do
|
||||
ENV["LOG_LEVEL"] = "debug"
|
||||
config = Config.load "spec/asset/test-config.yml"
|
||||
config.log_level.should eq "debug"
|
||||
config.base_url.should eq "/"
|
||||
end
|
||||
|
||||
it "correctly handles ENV truthiness" do
|
||||
ENV["CACHE_ENABLED"] = "false"
|
||||
config = Config.load "spec/asset/test-config.yml"
|
||||
config.cache_enabled.should be_false
|
||||
config.cache_log_enabled.should be_true
|
||||
config.disable_login.should be_false
|
||||
end
|
||||
end
|
||||
|
||||
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/config"
|
||||
require "../src/main_fiber"
|
||||
require "../src/plugin/plugin"
|
||||
|
||||
class State
|
||||
@@hash = {} of String => String
|
||||
@@ -54,3 +55,10 @@ def with_storage
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_plugin
|
||||
with_default_config do
|
||||
plugin = Plugin.new "test", "spec/asset/plugins"
|
||||
yield plugin
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
require "yaml"
|
||||
|
||||
class Config
|
||||
private OPTIONS = {
|
||||
"host" => "0.0.0.0",
|
||||
"port" => 9000,
|
||||
"base_url" => "/",
|
||||
"session_secret" => "mango-session-secret",
|
||||
"library_path" => "~/mango/library",
|
||||
"library_cache_path" => "~/mango/library.yml.gz",
|
||||
"db_path" => "~/mango.db",
|
||||
"queue_db_path" => "~/mango/queue.db",
|
||||
"scan_interval_minutes" => 5,
|
||||
"thumbnail_generation_interval_hours" => 24,
|
||||
"log_level" => "info",
|
||||
"upload_path" => "~/mango/uploads",
|
||||
"plugin_path" => "~/mango/plugins",
|
||||
"download_timeout_seconds" => 30,
|
||||
"cache_enabled" => true,
|
||||
"cache_size_mbs" => 50,
|
||||
"cache_log_enabled" => true,
|
||||
"disable_login" => false,
|
||||
"default_username" => "",
|
||||
"auth_proxy_header_name" => "",
|
||||
"plugin_update_interval_hours" => 24,
|
||||
}
|
||||
|
||||
include YAML::Serializable
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
property path = ""
|
||||
property host = "0.0.0.0"
|
||||
property port : Int32 = 9000
|
||||
property base_url = "/"
|
||||
property session_secret = "mango-session-secret"
|
||||
property library_path = "~/mango/library"
|
||||
property library_cache_path = "~/mango/library.yml.gz"
|
||||
property db_path = "~/mango/mango.db"
|
||||
property queue_db_path = "~/mango/queue.db"
|
||||
property scan_interval_minutes : Int32 = 5
|
||||
property thumbnail_generation_interval_hours : Int32 = 24
|
||||
property log_level = "info"
|
||||
property upload_path = "~/mango/uploads"
|
||||
property plugin_path = "~/mango/plugins"
|
||||
property download_timeout_seconds : Int32 = 30
|
||||
property cache_enabled = true
|
||||
property cache_size_mbs = 50
|
||||
property cache_log_enabled = true
|
||||
property disable_login = false
|
||||
property default_username = ""
|
||||
property auth_proxy_header_name = ""
|
||||
property plugin_update_interval_hours : Int32 = 24
|
||||
property path : String = ""
|
||||
|
||||
# Go through the options constant above and define them as properties.
|
||||
# Allow setting the default values through environment variables.
|
||||
# Overall precedence: config file > environment variable > default value
|
||||
{% begin %}
|
||||
{% for k, v in OPTIONS %}
|
||||
{% if v.is_a? StringLiteral %}
|
||||
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
|
||||
{% elsif v.is_a? NumberLiteral %}
|
||||
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
|
||||
{% elsif v.is_a? BoolLiteral %}
|
||||
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
|
||||
{% else %}
|
||||
raise "Unknown type in config option: {{ v.class_name.id }}"
|
||||
{% end %}
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
@@singlet : Config?
|
||||
|
||||
@@ -38,7 +58,7 @@ class Config
|
||||
end
|
||||
|
||||
def self.load(path : String?)
|
||||
path = "~/.config/mango/config.yml" if path.nil?
|
||||
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
|
||||
cfg_path = File.expand_path path, home: true
|
||||
if File.exists? cfg_path
|
||||
config = self.from_yaml File.read cfg_path
|
||||
|
||||
@@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler
|
||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||
|
||||
BASIC = "Basic"
|
||||
BEARER = "Bearer"
|
||||
AUTH = "Authorization"
|
||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||
"You have to login with proper credentials"
|
||||
@@ -18,8 +19,14 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
def require_auth(env)
|
||||
env.session.string "callback", env.request.path
|
||||
redirect env, "/login"
|
||||
if request_path_startswith env, ["/api"]
|
||||
# Do not redirect API requests
|
||||
env.response.status_code = 401
|
||||
send_text env, "Unauthorized"
|
||||
else
|
||||
env.session.string "callback", env.request.path
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_token(env)
|
||||
@@ -35,13 +42,18 @@ class AuthHandler < Kemal::Handler
|
||||
def validate_auth_header(env)
|
||||
if env.request.headers[AUTH]?
|
||||
if value = env.request.headers[AUTH]
|
||||
if value.size > 0 && value.starts_with?(BASIC)
|
||||
if value.starts_with? BASIC
|
||||
token = verify_user value
|
||||
return false if token.nil?
|
||||
|
||||
env.session.string "token", token
|
||||
return true
|
||||
end
|
||||
if value.starts_with? BEARER
|
||||
session_id = value.split(" ")[1]
|
||||
token = Kemal::Session.get(session_id).try &.string? "token"
|
||||
return !token.nil? && Storage.default.verify_token token
|
||||
end
|
||||
end
|
||||
end
|
||||
false
|
||||
@@ -54,6 +66,10 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
def call(env)
|
||||
# OPTIONS requests do not require authentication
|
||||
if env.request.method === "OPTIONS"
|
||||
return call_next(env)
|
||||
end
|
||||
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||
# or a static file
|
||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||
@@ -62,8 +78,8 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
# Check user is logged in
|
||||
if validate_token env
|
||||
# Skip if the request has a valid token
|
||||
if validate_token(env) || validate_auth_header(env)
|
||||
# Skip if the request has a valid token (either from cookies or header)
|
||||
elsif Config.current.disable_login
|
||||
# Check default username if login is disabled
|
||||
unless Storage.default.username_exists Config.current.default_username
|
||||
|
||||
8
src/handlers/cors_handler.cr
Normal file
8
src/handlers/cors_handler.cr
Normal file
@@ -0,0 +1,8 @@
|
||||
class CORSHandler < Kemal::Handler
|
||||
def call(env)
|
||||
if request_path_startswith env, ["/api"]
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
end
|
||||
call_next env
|
||||
end
|
||||
end
|
||||
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_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
"#{sig}:sorted_entries"
|
||||
end
|
||||
end
|
||||
@@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
||||
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
"#{sig}:sorted_titles"
|
||||
end
|
||||
end
|
||||
|
||||
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,63 +1,55 @@
|
||||
require "image_size"
|
||||
require "yaml"
|
||||
|
||||
class Entry
|
||||
include YAML::Serializable
|
||||
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
|
||||
node.nodes
|
||||
.map_with_index { |n, i| {n, i} }
|
||||
.select(&.[1].even?)
|
||||
.map(&.[0])
|
||||
.select(YAML::Nodes::Scalar)
|
||||
.map(&.as(YAML::Nodes::Scalar).value)
|
||||
.includes? key
|
||||
end
|
||||
|
||||
getter zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, encoded_path : String,
|
||||
encoded_title : String, mtime : Time, err_msg : String?
|
||||
abstract class Entry
|
||||
getter id : String, book : Title, title : String, path : String,
|
||||
size : String, pages : Int32, mtime : Time,
|
||||
encoded_path : String, encoded_title : String, err_msg : String?
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
def initialize(
|
||||
@id, @title, @book, @path,
|
||||
@size, @pages, @mtime,
|
||||
@encoded_path, @encoded_title, @err_msg
|
||||
)
|
||||
end
|
||||
|
||||
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,
|
||||
})
|
||||
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
|
||||
unless node.is_a? YAML::Nodes::Mapping
|
||||
raise "Unexpected node type in YAML"
|
||||
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
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
def build_json(*, slim = false)
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% for str in %w(path title size id) %}
|
||||
json.field {{str}}, {{str.id}}
|
||||
{% end %}
|
||||
if err_msg
|
||||
json.field "err_msg", err_msg
|
||||
end
|
||||
json.field "zip_path", path # for API backward compatability
|
||||
json.field "path", path
|
||||
json.field "title_id", @book.id
|
||||
json.field "title_title", @book.title
|
||||
json.field "sort_title", sort_title
|
||||
@@ -71,6 +63,9 @@ class Entry
|
||||
end
|
||||
end
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
|
||||
def sort_title
|
||||
sort_title_cached = @sort_title
|
||||
return sort_title_cached if sort_title_cached
|
||||
@@ -128,58 +123,6 @@ class Entry
|
||||
url
|
||||
end
|
||||
|
||||
private def sorted_archive_entries
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
entries = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort! { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
yield file, entries
|
||||
end
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||
img = nil
|
||||
begin
|
||||
sorted_archive_entries do |file, entries|
|
||||
page = entries[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename),
|
||||
page.filename, data.size
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
|
||||
end
|
||||
img
|
||||
end
|
||||
|
||||
def page_dimensions
|
||||
sizes = [] of Hash(String, Int32)
|
||||
sorted_archive_entries do |file, entries|
|
||||
entries.each_with_index do |e, i|
|
||||
begin
|
||||
data = file.read_entry(e).not_nil!
|
||||
size = ImageSize.get data
|
||||
sizes << {
|
||||
"width" => size.width,
|
||||
"height" => size.height,
|
||||
}
|
||||
rescue e
|
||||
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
||||
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||
end
|
||||
end
|
||||
end
|
||||
sizes
|
||||
end
|
||||
|
||||
def next_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
@@ -194,20 +137,6 @@ class Entry
|
||||
entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
date_added = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime @zip_path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added.not_nil! # is it ok to set not_nil! here?
|
||||
end
|
||||
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
@@ -287,7 +216,7 @@ class Entry
|
||||
end
|
||||
Storage.default.save_thumbnail @id, img
|
||||
rescue e
|
||||
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
|
||||
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
|
||||
end
|
||||
|
||||
img
|
||||
@@ -296,4 +225,34 @@ class Entry
|
||||
def get_thumbnail : Image?
|
||||
Storage.default.get_thumbnail @id
|
||||
end
|
||||
|
||||
def date_added : Time
|
||||
date_added = Time::UNIX_EPOCH
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added
|
||||
end
|
||||
|
||||
# Hack to have abstract class methods
|
||||
# https://github.com/crystal-lang/crystal/issues/5956
|
||||
private module ClassMethods
|
||||
abstract def is_valid?(path : String) : Bool
|
||||
end
|
||||
|
||||
macro inherited
|
||||
extend ClassMethods
|
||||
end
|
||||
|
||||
abstract def read_page(page_num)
|
||||
|
||||
abstract def page_dimensions
|
||||
|
||||
abstract def examine : Bool?
|
||||
end
|
||||
|
||||
@@ -139,14 +139,31 @@ class Library
|
||||
titles.flat_map &.deep_entries
|
||||
end
|
||||
|
||||
def build_json(*, slim = false, depth = -1)
|
||||
def build_json(*, slim = false, depth = -1, sort_context = nil,
|
||||
percentage = false)
|
||||
_titles = if sort_context
|
||||
sorted_titles sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
self.titles
|
||||
end
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
json.raw title.build_json(slim: slim, depth: depth)
|
||||
_titles.each do |title|
|
||||
json.raw title.build_json(slim: slim, depth: depth,
|
||||
sort_context: sort_context, percentage: percentage)
|
||||
end
|
||||
end
|
||||
end
|
||||
if percentage && sort_context
|
||||
json.field "title_percentages" do
|
||||
json.array do
|
||||
_titles.each do |title|
|
||||
json.number title.load_percentage sort_context[:username]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,13 +49,18 @@ class Title
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id, cache
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
unless title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
end
|
||||
if DirEntry.is_valid? path
|
||||
entry = DirEntry.new path, self
|
||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||
end
|
||||
next
|
||||
end
|
||||
if is_supported_file path
|
||||
entry = Entry.new path, self
|
||||
entry = ArchiveEntry.new path, self
|
||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||
end
|
||||
end
|
||||
@@ -127,12 +132,12 @@ class Title
|
||||
|
||||
previous_entries_size = @entries.size
|
||||
@entries.select! do |entry|
|
||||
existence = File.exists? entry.zip_path
|
||||
existence = entry.examine
|
||||
Fiber.yield
|
||||
context["deleted_entry_ids"] << entry.id unless existence
|
||||
existence
|
||||
end
|
||||
remained_entry_zip_paths = @entries.map &.zip_path
|
||||
remained_entry_paths = @entries.map &.path
|
||||
|
||||
is_titles_added = false
|
||||
is_entries_added = false
|
||||
@@ -140,29 +145,43 @@ class Title
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
unless remained_entry_paths.includes? path
|
||||
if DirEntry.is_valid? path
|
||||
entry = DirEntry.new path, self
|
||||
if entry.pages > 0 || entry.err_msg
|
||||
@entries << entry
|
||||
is_entries_added = true
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
entry.id != deleted_entry_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
next if remained_title_dirs.includes? path
|
||||
title = Title.new path, @id, context["cached_contents_signature"]
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
is_titles_added = true
|
||||
unless title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
is_titles_added = true
|
||||
|
||||
# We think they are removed, but they are here!
|
||||
# Cancel reserved jobs
|
||||
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||
!(revival_title_ids.includes? deleted_title_id)
|
||||
end
|
||||
revival_entry_ids = title.deep_entries.map &.id
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
!(revival_entry_ids.includes? deleted_entry_id)
|
||||
# We think they are removed, but they are here!
|
||||
# Cancel reserved jobs
|
||||
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||
!(revival_title_ids.includes? deleted_title_id)
|
||||
end
|
||||
revival_entry_ids = title.deep_entries.map &.id
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
!(revival_entry_ids.includes? deleted_entry_id)
|
||||
end
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
if is_supported_file path
|
||||
next if remained_entry_zip_paths.includes? path
|
||||
entry = Entry.new path, self
|
||||
next if remained_entry_paths.includes? path
|
||||
entry = ArchiveEntry.new path, self
|
||||
if entry.pages > 0 || entry.err_msg
|
||||
@entries << entry
|
||||
is_entries_added = true
|
||||
@@ -202,7 +221,21 @@ class Title
|
||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||
|
||||
def build_json(*, slim = false, depth = -1,
|
||||
sort_context : SortContext? = nil)
|
||||
sort_context : SortContext? = nil,
|
||||
percentage = false)
|
||||
_titles = if sort_context
|
||||
sorted_titles sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
self.titles
|
||||
end
|
||||
_entries = if sort_context
|
||||
sorted_entries sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
@entries
|
||||
end
|
||||
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
@@ -218,25 +251,39 @@ class Title
|
||||
unless depth == 0
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
_titles.each do |title|
|
||||
json.raw title.build_json(slim: slim,
|
||||
depth: depth > 0 ? depth - 1 : depth)
|
||||
depth: depth > 0 ? depth - 1 : depth,
|
||||
sort_context: sort_context, percentage: percentage)
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "entries" do
|
||||
json.array do
|
||||
_entries = if sort_context
|
||||
sorted_entries sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
@entries
|
||||
end
|
||||
_entries.each do |entry|
|
||||
json.raw entry.build_json(slim: slim)
|
||||
end
|
||||
end
|
||||
end
|
||||
if percentage && sort_context
|
||||
json.field "title_percentages" do
|
||||
json.array do
|
||||
_titles.each do |t|
|
||||
json.number t.load_percentage sort_context[:username]
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "entry_percentages" do
|
||||
json.array do
|
||||
load_percentage_for_all_entries(
|
||||
sort_context[:username],
|
||||
sort_context[:opt]
|
||||
).each do |p|
|
||||
json.number p.nan? ? 0 : p
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "parents" do
|
||||
json.array do
|
||||
@@ -585,6 +632,16 @@ class Title
|
||||
|
||||
if last_read_entry && last_read_entry.finished? username
|
||||
last_read_entry = last_read_entry.next_entry username
|
||||
if last_read_entry.nil?
|
||||
# The last entry is finished. Return the first unfinished entry
|
||||
# (if any)
|
||||
sorted_entries(username).each do |e|
|
||||
unless e.finished? username
|
||||
last_read_entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
last_read_entry
|
||||
@@ -599,7 +656,7 @@ class Title
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
da[e.title] = ctime e.path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
SUPPORTED_IMG_TYPES = %w(
|
||||
image/jpeg
|
||||
image/png
|
||||
image/webp
|
||||
image/apng
|
||||
image/avif
|
||||
image/gif
|
||||
image/svg+xml
|
||||
)
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
Title
|
||||
@@ -55,6 +45,13 @@ class SortOptions
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
|
||||
def to_json
|
||||
{
|
||||
"method" => method.to_s.underscore,
|
||||
"ascend" => ascend,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
|
||||
@@ -38,6 +38,7 @@ class Logger
|
||||
Log.setup do |c|
|
||||
c.bind "*", @@severity, @backend
|
||||
c.bind "db.*", :error, @backend
|
||||
c.bind "duktape", :none, @backend
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ require "option_parser"
|
||||
require "clim"
|
||||
require "tallboy"
|
||||
|
||||
MANGO_VERSION = "0.25.0"
|
||||
MANGO_VERSION = "0.26.2"
|
||||
|
||||
# From http://www.network-science.de/ascii/
|
||||
BANNER = %{
|
||||
|
||||
@@ -105,9 +105,10 @@ class Plugin
|
||||
getter js_path = ""
|
||||
getter storage_path = ""
|
||||
|
||||
def self.build_info_ary
|
||||
def self.build_info_ary(dir : String? = nil)
|
||||
@@info_ary.clear
|
||||
dir = Config.current.plugin_path
|
||||
dir ||= Config.current.plugin_path
|
||||
|
||||
Dir.mkdir_p dir unless Dir.exists? dir
|
||||
|
||||
Dir.each_child dir do |f|
|
||||
@@ -160,8 +161,8 @@ class Plugin
|
||||
list.save
|
||||
end
|
||||
|
||||
def initialize(id : String)
|
||||
Plugin.build_info_ary
|
||||
def initialize(id : String, dir : String? = nil)
|
||||
Plugin.build_info_ary dir
|
||||
|
||||
@info = @@info_ary.find &.id.== id
|
||||
if @info.nil?
|
||||
@@ -223,6 +224,10 @@ class Plugin
|
||||
raise Error.new "Missing required fields in the Page type"
|
||||
end
|
||||
|
||||
def can_subscribe? : Bool
|
||||
info.version > 1 && eval_exists?("newChapters")
|
||||
end
|
||||
|
||||
def search_manga(query : String)
|
||||
if info.version == 1
|
||||
raise Error.new "Manga searching is only available for plugins " \
|
||||
@@ -315,7 +320,7 @@ class Plugin
|
||||
json
|
||||
end
|
||||
|
||||
private def eval(str)
|
||||
def eval(str)
|
||||
@rt.eval str
|
||||
rescue e : Duktape::SyntaxError
|
||||
raise SyntaxError.new e.message
|
||||
@@ -327,6 +332,15 @@ class Plugin
|
||||
JSON.parse eval(str).as String
|
||||
end
|
||||
|
||||
private def eval_exists?(str) : Bool
|
||||
@rt.eval str
|
||||
true
|
||||
rescue e : Duktape::ReferenceError
|
||||
false
|
||||
rescue e : Duktape::Error
|
||||
raise Error.new e.message
|
||||
end
|
||||
|
||||
private def def_helper_functions(sbx)
|
||||
sbx.push_object
|
||||
|
||||
@@ -435,9 +449,15 @@ class Plugin
|
||||
env = Duktape::Sandbox.new ptr
|
||||
html = env.require_string 0
|
||||
|
||||
str = XML.parse(html).inner_text
|
||||
begin
|
||||
parser = Myhtml::Parser.new html
|
||||
str = parser.body!.children.first.inner_text
|
||||
|
||||
env.push_string str
|
||||
rescue
|
||||
env.push_string ""
|
||||
end
|
||||
|
||||
env.push_string str
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "text"
|
||||
@@ -448,8 +468,9 @@ class Plugin
|
||||
name = env.require_string 1
|
||||
|
||||
begin
|
||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
||||
env.push_string attr
|
||||
parser = Myhtml::Parser.new html
|
||||
attr = parser.body!.children.first.attribute_by name
|
||||
env.push_string attr.not_nil!
|
||||
rescue
|
||||
env.push_undefined
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require "sanitize"
|
||||
|
||||
struct AdminRouter
|
||||
def initialize
|
||||
get "/admin" do |env|
|
||||
@@ -14,13 +16,13 @@ struct AdminRouter
|
||||
end
|
||||
|
||||
get "/admin/user/edit" do |env|
|
||||
username = env.params.query["username"]?
|
||||
sanitizer = Sanitize::Policy::Text.new
|
||||
username = env.params.query["username"]?.try { |s| sanitizer.process s }
|
||||
admin = env.params.query["admin"]?
|
||||
if admin
|
||||
admin = admin == "true"
|
||||
end
|
||||
error = env.params.query["error"]?
|
||||
current_user = get_username env
|
||||
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
||||
new_user = username.nil? && admin.nil?
|
||||
layout "user-edit"
|
||||
end
|
||||
|
||||
@@ -40,14 +40,19 @@ struct APIRouter
|
||||
Koa.schema "entry", {
|
||||
"pages" => Int32,
|
||||
"mtime" => Int64,
|
||||
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
||||
}.merge(s %w(zip_path path title size id title_id display_name cover_url)),
|
||||
desc: "An entry in a book"
|
||||
|
||||
Koa.schema "title", {
|
||||
"mtime" => Int64,
|
||||
"entries" => ["entry"],
|
||||
"titles" => ["title"],
|
||||
"parents" => [String],
|
||||
"parents" => [{
|
||||
"title" => String,
|
||||
"id" => String,
|
||||
}],
|
||||
"title_percentages" => [Float64?],
|
||||
"entry_percentages" => [Float64?],
|
||||
}.merge(s %w(dir title id display_name cover_url)),
|
||||
desc: "A manga title (a collection of entries and sub-titles)"
|
||||
|
||||
@@ -80,6 +85,12 @@ struct APIRouter
|
||||
"username" => String,
|
||||
"password" => String,
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"session_id" => String?,
|
||||
"is_admin" => Bool?,
|
||||
}
|
||||
Koa.tag "users"
|
||||
post "/api/login" do |env|
|
||||
begin
|
||||
@@ -88,11 +99,18 @@ struct APIRouter
|
||||
token = Storage.default.verify_user(username, password).not_nil!
|
||||
|
||||
env.session.string "token", token
|
||||
"Authenticated"
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"session_id" => env.session.id,
|
||||
"is_admin" => Storage.default.username_is_admin username,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 403
|
||||
e.message
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -124,14 +142,19 @@ struct APIRouter
|
||||
env.response.status_code = 304
|
||||
""
|
||||
else
|
||||
if entry.is_a? DirEntry
|
||||
cache_control = "no-cache, max-age=86400"
|
||||
else
|
||||
cache_control = "public, max-age=86400"
|
||||
end
|
||||
env.response.headers["ETag"] = e_tag
|
||||
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
env.response.headers["Cache-Control"] = cache_control
|
||||
send_img env, img
|
||||
end
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 500
|
||||
e.message
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
@@ -168,11 +191,13 @@ struct APIRouter
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 500
|
||||
e.message
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||
The entries and titles will be sorted by the default sorting method for the logged-in user.
|
||||
- Supply the `percentage` query parameter to include the reading progress
|
||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
||||
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
||||
@@ -183,8 +208,7 @@ struct APIRouter
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.query "slim"
|
||||
Koa.query "depth"
|
||||
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
|
||||
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
|
||||
Koa.query "percentage"
|
||||
Koa.response 200, schema: "title"
|
||||
Koa.response 404, "Title not found"
|
||||
Koa.tag "library"
|
||||
@@ -192,29 +216,104 @@ struct APIRouter
|
||||
begin
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.new
|
||||
get_sort_opt
|
||||
|
||||
tid = env.params.url["tid"]
|
||||
title = Library.default.get_title tid
|
||||
raise "Title ID `#{tid}` not found" if title.nil?
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
|
||||
slim = !env.params.query["slim"]?.nil?
|
||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||
percentage = !env.params.query["percentage"]?.nil?
|
||||
|
||||
send_json env, title.build_json(slim: slim, depth: depth,
|
||||
sort_context: {username: username,
|
||||
opt: sort_opt})
|
||||
opt: sort_opt}, percentage: percentage)
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 404
|
||||
e.message
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the sorting option of a title or the library", <<-MD
|
||||
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
|
||||
- If the query parameter `tid` is missing, returns the sorting option of the library.
|
||||
MD
|
||||
Koa.query "tid"
|
||||
Koa.response 200, schema: {
|
||||
"method" => String?,
|
||||
"ascend" => Bool?,
|
||||
"error" => String?,
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/sort_opt" do |env|
|
||||
username = get_username env
|
||||
|
||||
tid = env.params.query["tid"]?
|
||||
dir = if tid
|
||||
(Library.default.get_title tid).not_nil!.dir
|
||||
else
|
||||
Library.default.dir
|
||||
end
|
||||
sort_opt = SortOptions.from_info_json dir, username
|
||||
send_json env, sort_opt.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Updates the sorting option of a title or the library", <<-MD
|
||||
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
|
||||
- When the `tid` field is missing in the body, updates the sorting option of the library.
|
||||
MD
|
||||
Koa.body schema: {
|
||||
"tid" => String?,
|
||||
"method" => String,
|
||||
"ascend" => Bool,
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
}
|
||||
Koa.tag "library"
|
||||
put "/api/sort_opt" do |env|
|
||||
username = get_username env
|
||||
|
||||
tid = env.params.json["tid"]?.try &.as String
|
||||
dir = if tid
|
||||
(Library.default.get_title tid).not_nil!.dir
|
||||
else
|
||||
Library.default.dir
|
||||
end
|
||||
|
||||
method = env.params.json["sort"].as String
|
||||
ascend = env.params.json["ascend"].as Bool
|
||||
sort_opt = SortOptions.new method, ascend
|
||||
|
||||
TitleInfo.new dir do |info|
|
||||
info.sort_by[username] = sort_opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||
The titles will be sorted by the default sorting method for the logged-in user.
|
||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
||||
- Supply the `percentage` query parameter to include the reading progress
|
||||
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
||||
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
||||
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
||||
@@ -222,16 +321,162 @@ struct APIRouter
|
||||
MD
|
||||
Koa.query "slim"
|
||||
Koa.query "depth"
|
||||
Koa.query "percentage"
|
||||
Koa.response 200, schema: {
|
||||
"dir" => String,
|
||||
"titles" => ["title"],
|
||||
"dir" => String,
|
||||
"titles" => ["title"],
|
||||
"title_percentage" => [Float64?],
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/library" do |env|
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||
|
||||
slim = !env.params.query["slim"]?.nil?
|
||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||
percentage = !env.params.query["percentage"]?.nil?
|
||||
|
||||
send_json env, Library.default.build_json(slim: slim, depth: depth)
|
||||
send_json env, Library.default.build_json(slim: slim, depth: depth,
|
||||
sort_context: {username: username,
|
||||
opt: sort_opt}, percentage: percentage)
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Returns the continue reading entries"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"entries" => ["entry"],
|
||||
"entry_percentages" => [Float64],
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/library/continue_reading" do |env|
|
||||
username = get_username env
|
||||
cr_entries = Library.default.get_continue_reading_entries username
|
||||
|
||||
json = JSON.build do |j|
|
||||
j.object do
|
||||
j.field "success" do
|
||||
j.bool true
|
||||
end
|
||||
j.field "entries" do
|
||||
j.array do
|
||||
cr_entries.each do |e|
|
||||
j.raw e[:entry].build_json
|
||||
end
|
||||
end
|
||||
end
|
||||
j.field "entry_percentages" do
|
||||
j.array do
|
||||
cr_entries.each do |e|
|
||||
j.number e[:percentage]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
send_json env, json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Returns the start reading titles"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"titles" => ["title"],
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/library/start_reading" do |env|
|
||||
username = get_username env
|
||||
titles = Library.default.get_start_reading_titles username
|
||||
|
||||
json = JSON.build do |j|
|
||||
j.object do
|
||||
j.field "success" do
|
||||
j.bool true
|
||||
end
|
||||
j.field "titles" do
|
||||
j.array do
|
||||
titles.each do |t|
|
||||
j.raw t.build_json depth: 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
send_json env, json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Returns the recently added items"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"items" => [{
|
||||
"item" => "title | entry",
|
||||
"percentage" => Float64,
|
||||
"count" => Int32,
|
||||
}],
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/library/recently_added" do |env|
|
||||
username = get_username env
|
||||
ra_entries = Library.default.get_recently_added_entries username
|
||||
|
||||
json = JSON.build do |j|
|
||||
j.object do
|
||||
j.field "success" do
|
||||
j.bool true
|
||||
end
|
||||
j.field "items" do
|
||||
j.array do
|
||||
ra_entries.each do |e|
|
||||
j.object do
|
||||
j.field "item" do
|
||||
if e[:grouped_count] === 1
|
||||
j.raw e[:entry].build_json
|
||||
else
|
||||
j.raw e[:entry].book.build_json depth: 0
|
||||
end
|
||||
end
|
||||
j.field "percentage" do
|
||||
j.number e[:percentage]
|
||||
end
|
||||
j.field "count" do
|
||||
j.number e[:grouped_count]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
send_json env, json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Triggers a library scan"
|
||||
@@ -267,6 +512,7 @@ struct APIRouter
|
||||
spawn do
|
||||
Library.default.generate_thumbnails
|
||||
end
|
||||
send_text env, ""
|
||||
end
|
||||
|
||||
Koa.describe "Deletes a user with `username`"
|
||||
@@ -625,13 +871,15 @@ struct APIRouter
|
||||
"version" => Int32,
|
||||
"settings" => {} of String => String,
|
||||
},
|
||||
"subscribable" => Bool,
|
||||
}
|
||||
get "/api/admin/plugin/info" do |env|
|
||||
begin
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"info" => plugin.info,
|
||||
"success" => true,
|
||||
"info" => plugin.info,
|
||||
"subscribable" => plugin.can_subscribe?,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
@@ -897,15 +1145,24 @@ struct APIRouter
|
||||
entry = title.get_entry eid
|
||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||
|
||||
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
||||
if entry.is_a? DirEntry
|
||||
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size)
|
||||
else
|
||||
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s)
|
||||
end
|
||||
e_tag = "W/#{file_hash}"
|
||||
if e_tag == prev_e_tag
|
||||
env.response.status_code = 304
|
||||
""
|
||||
send_text env, ""
|
||||
else
|
||||
sizes = entry.page_dimensions
|
||||
if entry.is_a? DirEntry
|
||||
cache_control = "no-cache, max-age=86400"
|
||||
else
|
||||
cache_control = "public, max-age=86400"
|
||||
end
|
||||
env.response.headers["ETag"] = e_tag
|
||||
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
env.response.headers["Cache-Control"] = cache_control
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"dimensions" => sizes,
|
||||
@@ -931,10 +1188,11 @@ struct APIRouter
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||
|
||||
send_attachment env, entry.zip_path
|
||||
send_attachment env, entry.path
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 404
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ struct ReaderRouter
|
||||
render "src/views/reader.html.ecr"
|
||||
rescue e
|
||||
Logger.error e
|
||||
Logger.debug e.backtrace?
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,17 @@ class Server
|
||||
APIRouter.new
|
||||
OPDSRouter.new
|
||||
|
||||
{% for path in %w(/api/* /uploads/* /img/*) %}
|
||||
options {{path}} do |env|
|
||||
cors
|
||||
halt env
|
||||
end
|
||||
{% end %}
|
||||
|
||||
static_headers do |response|
|
||||
response.headers.add("Access-Control-Allow-Origin", "*")
|
||||
end
|
||||
|
||||
Kemal.config.logging = false
|
||||
add_handler LogHandler.new
|
||||
add_handler AuthHandler.new
|
||||
|
||||
@@ -19,7 +19,7 @@ class File
|
||||
# information as long as the above changes do not happen together with
|
||||
# a file/folder rename, with no library scan in between.
|
||||
def self.signature(filename) : UInt64
|
||||
if is_supported_file filename
|
||||
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
|
||||
File.info(filename).inode
|
||||
else
|
||||
0u64
|
||||
@@ -67,7 +67,9 @@ class Dir
|
||||
else
|
||||
# Only add its signature value to `signatures` when it is a
|
||||
# supported file
|
||||
signatures << fn if is_supported_file fn
|
||||
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
|
||||
signatures << fn
|
||||
end
|
||||
end
|
||||
Fiber.yield
|
||||
end
|
||||
@@ -76,4 +78,19 @@ class Dir
|
||||
cache[dirname] = hash
|
||||
hash
|
||||
end
|
||||
|
||||
def self.directory_entry_signature(dirname, cache = {} of String => String)
|
||||
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
|
||||
Fiber.yield
|
||||
signatures = [] of String
|
||||
image_files = DirEntry.sorted_image_files dirname
|
||||
if image_files.size > 0
|
||||
image_files.each do |path|
|
||||
signatures << File.signature(path).to_s
|
||||
end
|
||||
end
|
||||
hash = Digest::SHA1.hexdigest(signatures.join)
|
||||
cache[dirname + "?entry"] = hash
|
||||
hash
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,16 @@ ENTRIES_IN_HOME_SECTIONS = 8
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||
SUPPORTED_IMG_TYPES = %w(
|
||||
image/jpeg
|
||||
image/png
|
||||
image/webp
|
||||
image/apng
|
||||
image/avif
|
||||
image/gif
|
||||
image/svg+xml
|
||||
image/jxl
|
||||
)
|
||||
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
@@ -40,6 +50,7 @@ def register_mime_types
|
||||
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||
".apng" => "image/apng",
|
||||
".avif" => "image/avif",
|
||||
".jxl" => "image/jxl",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
@@ -49,6 +60,10 @@ def is_supported_file(path)
|
||||
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||
end
|
||||
|
||||
def is_supported_image_file(path)
|
||||
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
|
||||
end
|
||||
|
||||
struct Int
|
||||
def or(other : Int)
|
||||
if self == 0
|
||||
@@ -80,9 +95,9 @@ class String
|
||||
end
|
||||
end
|
||||
|
||||
def env_is_true?(key : String) : Bool
|
||||
def env_is_true?(key : String, default : Bool = false) : Bool
|
||||
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||
return false unless val
|
||||
return default unless val
|
||||
val.downcase.in? "1", "true"
|
||||
end
|
||||
|
||||
|
||||
@@ -39,13 +39,28 @@ macro send_error_page(msg)
|
||||
end
|
||||
|
||||
macro send_img(env, img)
|
||||
cors
|
||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||
end
|
||||
|
||||
def get_token_from_auth_header(env) : String?
|
||||
value = env.request.headers["Authorization"]
|
||||
if value && value.starts_with? "Bearer"
|
||||
session_id = value.split(" ")[1]
|
||||
return Kemal::Session.get(session_id).try &.string? "token"
|
||||
end
|
||||
end
|
||||
|
||||
macro get_username(env)
|
||||
begin
|
||||
token = env.session.string "token"
|
||||
(Storage.default.verify_token token).not_nil!
|
||||
# Check if we can get the session id from the cookie
|
||||
token = env.session.string? "token"
|
||||
if token.nil?
|
||||
# If not, check if we can get the session id from the auth header
|
||||
token = get_token_from_auth_header env
|
||||
end
|
||||
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
|
||||
(Storage.default.verify_token token.not_nil!).not_nil!
|
||||
rescue e
|
||||
if Config.current.disable_login
|
||||
Config.current.default_username
|
||||
@@ -57,12 +72,29 @@ macro get_username(env)
|
||||
end
|
||||
end
|
||||
|
||||
macro cors
|
||||
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
|
||||
"DELETE,OPTIONS"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
|
||||
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
|
||||
"Authorization"
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
end
|
||||
|
||||
def send_json(env, json)
|
||||
cors
|
||||
env.response.content_type = "application/json"
|
||||
env.response.print json
|
||||
end
|
||||
|
||||
def send_text(env, text)
|
||||
cors
|
||||
env.response.content_type = "text/plain"
|
||||
env.response.print text
|
||||
end
|
||||
|
||||
def send_attachment(env, path)
|
||||
cors
|
||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||
end
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<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/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="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||
|
||||
@@ -133,8 +133,10 @@
|
||||
</template>
|
||||
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</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>
|
||||
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
|
||||
<span x-show="subscribable">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
|
||||
</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>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<%= render_component "head" %>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</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>
|
||||
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||
<img
|
||||
@@ -30,7 +30,7 @@
|
||||
:height="item.height"
|
||||
:id="item.id"
|
||||
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||
@click="showControl($event)"
|
||||
@click="clickImage($event)"
|
||||
/>
|
||||
</template>
|
||||
<%- if next_entry_url -%>
|
||||
@@ -40,18 +40,18 @@
|
||||
<%- end -%>
|
||||
</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="{
|
||||
'uk-align-center': true,
|
||||
'uk-animation-slide-left': flipAnimation === 'left',
|
||||
'uk-animation-slide-right': flipAnimation === 'right'
|
||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
|
||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
|
||||
width:${fitType === 'horz' ? '100vw' : 'auto'};
|
||||
height:${fitType === 'vert' ? '100vh' : 'auto'};
|
||||
margin-bottom:0;
|
||||
max-width:100%;
|
||||
max-height:100%;
|
||||
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
|
||||
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
|
||||
object-fit: contain;
|
||||
`" />
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<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 class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
@@ -94,6 +94,17 @@
|
||||
</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'">
|
||||
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||
<div class="uk-form-controls">
|
||||
|
||||
Reference in New Issue
Block a user