mirror of
https://github.com/hkalexling/Mango.git
synced 2026-05-01 00:00:55 -04:00
Compare commits
60 Commits
rc/0.26.0
...
e7c4123dec
| Author | SHA1 | Date | |
|---|---|---|---|
| e7c4123dec | |||
| 2d2486a598 | |||
| b6a1ad889e | |||
| f2d6d28a72 | |||
| 49425ff714 | |||
| f3eb62a271 | |||
| 2e91028ead | |||
| 19a8f3100b | |||
| 3b5e764d36 | |||
| 32ce26a133 | |||
| 31df058f81 | |||
| fe440d82d4 | |||
| 44636e051e | |||
| a639392ca0 | |||
| 17a9c8ecd3 | |||
| bbc0c2cbb7 | |||
| be46dd1f86 | |||
| ae583cf2a9 | |||
| ea35faee91 | |||
| 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 | |||
| a101526672 | |||
| eca47e3d32 |
@@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.26.0
|
Mango - Manga Server and Web Reader. Version 0.26.2
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ upload_path: ~/mango/uploads
|
|||||||
plugin_path: ~/mango/plugins
|
plugin_path: ~/mango/plugins
|
||||||
download_timeout_seconds: 30
|
download_timeout_seconds: 30
|
||||||
library_cache_path: ~/mango/library.yml.gz
|
library_cache_path: ~/mango/library.yml.gz
|
||||||
cache_enabled: false
|
cache_enabled: true
|
||||||
cache_size_mbs: 50
|
cache_size_mbs: 50
|
||||||
cache_log_enabled: true
|
cache_log_enabled: true
|
||||||
disable_login: false
|
disable_login: false
|
||||||
|
|||||||
+22
-8
@@ -29,14 +29,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,7 +60,7 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,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 +259,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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ shards:
|
|||||||
git: https://github.com/luislavena/radix.git
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
|
|
||||||
|
sanitize:
|
||||||
|
git: https://github.com/hkalexling/sanitize.git
|
||||||
|
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.26.0
|
version: 0.26.2
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -42,3 +42,5 @@ dependencies:
|
|||||||
branch: master
|
branch: master
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
|
sanitize:
|
||||||
|
github: hkalexling/sanitize
|
||||||
|
|||||||
+19
-2
@@ -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
|
||||||
|
|||||||
+43
-23
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+70
-114
@@ -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
|
||||||
|
|||||||
+51
-22
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.26.0"
|
MANGO_VERSION = "0.26.2"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
|
|||||||
+5
-3
@@ -1,3 +1,5 @@
|
|||||||
|
require "sanitize"
|
||||||
|
|
||||||
struct AdminRouter
|
struct AdminRouter
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
@@ -14,13 +16,13 @@ struct AdminRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user/edit" do |env|
|
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"]?
|
admin = env.params.query["admin"]?
|
||||||
if admin
|
if admin
|
||||||
admin = admin == "true"
|
admin = admin == "true"
|
||||||
end
|
end
|
||||||
error = env.params.query["error"]?
|
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
||||||
current_user = get_username env
|
|
||||||
new_user = username.nil? && admin.nil?
|
new_user = username.nil? && admin.nil?
|
||||||
layout "user-edit"
|
layout "user-edit"
|
||||||
end
|
end
|
||||||
|
|||||||
+19
-5
@@ -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
|
||||||
@@ -1138,15 +1143,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 +1186,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
-2
@@ -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
|
||||||
|
|||||||
+17
-2
@@ -3,6 +3,16 @@ 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"]
|
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 +50,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 +60,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 +95,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 %>" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 -%>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
'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:${mode === 'width' ? '100vw' : 'auto'};
|
||||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user