Compare commits

..

30 Commits

Author SHA1 Message Date
Alex Ling
1b244c68b8 Bump version to v0.8.0 2020-07-17 08:18:24 +00:00
Alex Ling
281f626e8c More tie-breaking 2020-07-16 13:17:58 +00:00
Alex Ling
5be4f51d7e Name partially downloaded cbz files .part (#90) 2020-07-16 13:16:36 +00:00
Alex Ling
cd7782ba1e Respect custom sorting method in continue reading
(#86)
2020-07-15 17:06:54 +00:00
Alex Ling
6d97bc083c Break library.cr into multiple files 2020-07-15 16:12:36 +00:00
Alex Ling
ff4b1be9ae Template cleanup 2020-07-15 16:04:03 +00:00
Alex Ling
ba16c3db2f Add SortOptions.from_info_json 2020-07-15 15:33:26 +00:00
Alex Ling
69b06a8352 Use auto sort to break ties when sorting 2020-07-15 15:13:38 +00:00
Alex Ling
687788767f Use auto when an unknown sorting method is passed 2020-07-15 10:47:27 +00:00
Alex Ling
94a1e63963 Handle library/title sorting on backend (#86) 2020-07-15 10:34:53 +00:00
Alex Ling
360913ee78 Add chapter sort 2020-07-12 08:59:40 +00:00
Alex Ling
ea366f263a Move require "big" to relevant util file 2020-07-12 08:53:46 +00:00
Alex Ling
0d11cb59e9 Break util.cr into multiple files 2020-07-12 08:53:04 +00:00
Alex Ling
2208f90d8e Properly close archive files after validating them 2020-07-11 15:51:57 +00:00
Alex Ling
07100121ef Bump version to v0.7.3 2020-07-05 14:36:12 +00:00
Alex Ling
a0e550569e Use archive.cr v0.3.0 for 32bit support 2020-07-05 14:34:19 +00:00
Alex Ling
bbbe2e0588 Move uikit.less 2020-07-04 11:17:27 +00:00
Alex Ling
9d31b24e8c Fix nested a tags 2020-07-04 11:10:32 +00:00
Alex Ling
38ba324fa9 Save the sorting option in local storage (#76) 2020-07-04 09:47:51 +00:00
Alex Ling
c00016fa19 Remove link to title page from the entry modal 2020-07-04 08:56:58 +00:00
Alex Ling
4d5a305d1b Reduce card font size and link to the title pages
(#84)
2020-07-04 08:56:58 +00:00
Alex Ling
f9ca52ee2f Keep progress label color in dark mode (#85) 2020-07-03 06:53:39 +00:00
Alex Ling
f6c393545c Only show started entries in "continue reading"
(#83)
2020-07-03 06:52:50 +00:00
Alex Ling
466aee62fe Bump version to v0.7.2 2020-07-01 14:15:29 +00:00
Alex Ling
eab0800376 Improve scan performance (#79) 2020-07-01 14:01:26 +00:00
Alex Ling
1725f42698 Use HTML.escape to escape XML 2020-07-01 13:27:30 +00:00
Alex Ling
f5cdf8b7b6 Explicitly register supported mime types (#82) 2020-07-01 13:21:14 +00:00
Alex Ling
fe082e7537 Escape illegal characters in XML (#82) 2020-06-30 16:46:47 +00:00
Alex Ling
c87b96dd0b Improve performance for library and title pages 2020-06-24 16:29:34 +00:00
Alex Ling
9d76ca8c24 Improve home page loading time (#81) 2020-06-24 15:52:26 +00:00
38 changed files with 1378 additions and 980 deletions

View File

@@ -50,7 +50,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.7.1
Mango - Manga Server and Web Reader. Version 0.8.0
Usage:

View File

@@ -12,7 +12,7 @@ gulp.task('minify-js', () => {
});
gulp.task('less', () => {
return gulp.src('src/assets/*.less')
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});

View File

@@ -1,74 +1,99 @@
.uk-alert-close {
color: black !important;
color: black !important;
}
.uk-card-body {
padding: 20px;
padding: 20px;
}
.uk-card-media-top {
height: 250px;
height: 250px;
}
@media (min-width: 600px) {
.uk-card-media-top {
height: 300px;
.uk-card-media-top {
height: 300px;
}
}
.uk-card-media-top>img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-media-top > img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title {
height: 3em;
max-height: 3em;
}
.acard:hover {
text-decoration: none;
cursor: pointer;
}
.uk-list li {
cursor: pointer;
cursor: pointer;
}
.reader-bg {
background-color: black;
background-color: black;
}
#scan-status {
cursor: auto;
cursor: auto;
}
.break-word {
word-wrap: break-word;
word-wrap: break-word;
}
.uk-logo > img {
height: 90px;
width: 90px;
.uk-logo>img {
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;
width: 100%;
}
#selectable .ui-selecting {
background: #EEE6B9;
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
background: #F4E487;
}
#selectable .ui-selecting.dark {
background: #5E5731;
background: #5E5731;
}
#selectable .ui-selected.dark {
background: #9D9252;
background: #9D9252;
}
td > .uk-dropdown {
white-space: pre-line;
td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid > div {
height: 300px;
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}

View File

@@ -1,13 +1,12 @@
const truncate = () => {
$('.acard .uk-card-title').each((i, e) => {
$('.uk-card-title').each((i, e) => {
$(e).dotdotdot({
truncate: 'letter',
watch: true,
callback: (truncated) => {
if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
}
else {
} else {
$(e).removeAttr('uk-tooltip');
}
}

View File

@@ -1,123 +1,15 @@
$(() => {
const sortItems = () => {
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
let items = $('.item');
items.remove();
const ctxAry = [];
const keyRange = {};
if (by === 'auto') {
// intelligent sorting
items.each((i, item) => {
const name = $(item).find('.uk-card-title').text();
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
const numbers = {};
let match = regex.exec(name);
while (match) {
const key = match[1];
const num = parseFloat(match[2]);
numbers[key] = num;
if (!keyRange[key]) {
keyRange[key] = [num, num, 1];
}
else {
keyRange[key][2] += 1;
if (num < keyRange[key][0]) {
keyRange[key][0] = num;
}
else if (num > keyRange[key][1]) {
keyRange[key][1] = num;
}
}
match = regex.exec(name);
}
ctxAry.push({index: i, numbers: numbers});
});
console.log(keyRange);
const sortedKeys = Object.keys(keyRange).filter(k => {
return keyRange[k][2] >= items.length / 2;
});
sortedKeys.sort((a, b) => {
// sort by frequency of the key first
if (keyRange[a][2] !== keyRange[b][2]) {
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
}
// then sort by range of the key
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
});
console.log(sortedKeys);
ctxAry.sort((a, b) => {
for (let i = 0; i < sortedKeys.length; i++) {
const key = sortedKeys[i];
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
continue;
if (a.numbers[key] === undefined)
return 1;
if (b.numbers[key] === undefined)
return -1;
if (a.numbers[key] === b.numbers[key])
continue;
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
}
return 0;
});
const sortedItems = [];
ctxAry.forEach(ctx => {
sortedItems.push(items[ctx.index]);
});
items = sortedItems;
if (dir === 'down') {
items.reverse();
}
}
else {
items.sort((a, b) => {
var res;
if (by === 'name')
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else if (by === 'progress') {
const ap = parseFloat($(a).attr('data-progress'));
const bp = parseFloat($(b).attr('data-progress'));
if (ap === bp)
// if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else
res = ap > bp;
}
if (dir === 'up')
return res ? 1 : -1;
else
return !res ? 1 : -1;
});
}
$('#item-container').append(items);
};
$('#sort-select').change(() => {
sortItems();
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
sort: by,
ascend: dir === 'up' ? 1 : 0
})}`;
window.location.href = newURL;
});
if ($('option#auto-up').length > 0)
$('option#auto-up').attr('selected', '');
else
$('option#name-up').attr('selected', '');
sortItems();
});

View File

@@ -1,3 +1,24 @@
$(() => {
setupAcard();
});
const setupAcard = () => {
$('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard');
showModal(
$(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'),
$(card).attr('data-book-id'),
$(card).attr('data-id')
);
});
};
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
@@ -15,9 +36,6 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#continue-btn').text('Continue from ' + percentage + '%');
}
$('#modal-title-link').text(title);
$('#modal-title-link').attr('href', `${base_url}book/${titleID}`);
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);

View File

@@ -6,7 +6,7 @@ shards:
archive:
github: hkalexling/archive.cr
version: 0.2.0
version: 0.3.0
baked_file_system:
github: schovi/baked_file_system

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.7.1
version: 0.8.0
authors:
- Alex Ling <hkalexling@gmail.com>

View File

@@ -1,10 +1,10 @@
require "./spec_helper"
describe "compare_alphanumerically" do
describe "compare_numerically" do
it "sorts filenames with leading zeros correctly" do
ary = ["010.jpg", "001.jpg", "002.png"]
ary.sort! { |a, b|
compare_alphanumerically a, b
compare_numerically a, b
}
ary.should eq ["001.jpg", "002.png", "010.jpg"]
end
@@ -12,7 +12,7 @@ describe "compare_alphanumerically" do
it "sorts filenames without leading zeros correctly" do
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
ary.sort! { |a, b|
compare_alphanumerically a, b
compare_numerically a, b
}
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
end
@@ -22,7 +22,7 @@ describe "compare_alphanumerically" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort { |a, b|
compare_alphanumerically a, b
compare_numerically a, b
}.should eq ary
end
@@ -30,7 +30,17 @@ describe "compare_alphanumerically" do
it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b|
compare_alphanumerically a, b
compare_numerically a, b
}.should eq ary
end
end
describe "chapter_sort" do
it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b|
sorter.compare a, b
end.should eq ary
end
end

View File

@@ -1,6 +1,6 @@
require "kemal"
require "../storage"
require "../util"
require "../util/*"
class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub

View File

@@ -1,6 +1,6 @@
require "baked_file_system"
require "kemal"
require "../util"
require "../util/*"
class FS
extend BakedFileSystem

View File

@@ -1,5 +1,5 @@
require "kemal"
require "../util"
require "../util/*"
class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String)

View File

@@ -1,599 +0,0 @@
require "mime"
require "json"
require "uri"
require "./util"
require "./archive"
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = ArchiveFile.new path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
@id = storage.get_id @zip_path, false
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def read_page(page_num)
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_alphanumerically a.filename, b.filename
}
.[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
img
end
def next_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == @book.entries.size - 1
@book.entries[idx + 1]
end
def previous_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == 0
@book.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)
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
else
info.progress[username][@title] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {@title => Time.utc}
else
info.last_read[username][@title] = Time.utc
end
info.save
end
end
def load_progress(username)
progress = 0
TitleInfo.new @book.dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][@title]?.nil?
progress = info.progress[username][@title]
end
end
[progress, @pages].min
end
def load_percentage(username)
page = load_progress username
page / @pages
end
def load_last_read(username)
last_read = nil
TitleInfo.new @book.dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][@title]?.nil?
last_read = info.last_read[username][@title]
end
end
last_read
end
def finished?(username)
load_progress(username) == @pages
end
def started?(username)
load_progress(username) > 0
end
end
class Title
property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@library : Library)
@id = storage.get_id @dir, true
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@entries = [] of Entry
@mtime = File.info(dir).modification_time
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
unless File.readable? path
Logger.warn "File #{path} is not readable. Please make sure the " \
"file permission is configured correctly."
next
end
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_alphanumerically @library.title_hash[a].title,
@library.title_hash[b].title
end
@entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
end
# Get all entries, including entries in nested titles
def deep_entries
return @entries if title_ids.empty?
@entries + titles.map { |t| t.deep_entries }.flatten
end
def deep_titles
return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary.reverse
end
def size
@entries.size + @title_ids.size
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
def display_name
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
URI.encode display_name
end
def display_name(entry_name)
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
dn
end
def set_display_name(dn)
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "#{Config.current.base_url}img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
e.save_progress username, e.pages
end
titles.each do |t|
t.read_all username
end
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
e.save_progress username, 0
end
titles.each do |t|
t.unread_all username
end
end
def deep_read_page_count(username) : Int32
entries.map { |e| e.load_progress username }.sum +
titles.map { |t| t.deep_read_page_count username }.flatten.sum
end
def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum +
titles.map { |t| t.deep_total_page_count }.flatten.sum
end
def load_percentage(username)
deep_read_page_count(username) / deep_total_page_count
end
def get_continue_reading_entry(username)
in_progress_entries = @entries.select do |e|
load_progress(username, e.title) > 0
end
return nil if in_progress_entries.empty?
latest_read_entry = in_progress_entries[-1]
if load_progress(username, latest_read_entry.title) ==
latest_read_entry.pages
next_entry latest_read_entry
else
latest_read_entry
end
end
end
class TitleInfo
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
@[JSON::Field(ignore: true)]
property dir : String = ""
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
end
end
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
storage : Storage, title_hash : Hash(String, Title)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@storage = Storage.default
@dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@title_ids = [] of String
@title_hash = {} of String => Title
return scan if @scan_interval < 1
spawn do
loop do
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def titles
@title_ids.map { |tid| self.get_title!(tid) }
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
# For each Title, get the last read entry. If the user has finished
# reading this entry, get the next entry
.map { |t|
last_read_entry = t.entries.reverse_each.find do |e|
e.started? username
end
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry
end
last_read_entry
}
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
last_read = e.load_last_read username
pe = e.previous_entry
if last_read.nil? && pe
last_read = pe.load_last_read username
end
{
entry: e,
percentage: e.load_percentage(username),
last_read: last_read,
}
}
# Sort by by last_read, most recent first (nils at the end)
cr_entries.sort { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}[0..11]
end
alias RA = NamedTuple(
entry: Entry,
percentage: Float64,
grouped_count: Int32)
def get_recently_added_entries(username)
recently_added = [] of RA
titles.map { |t| t.deep_entries }
.flatten
.select { |e| e.date_added > 1.month.ago }
.sort { |a, b| b.date_added <=> a.date_added }
.each do |e|
last = recently_added.last?
if last && e.title_id == last[:entry].title_id &&
(e.date_added - last[:entry].date_added).duration < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32)
last_hash[:grouped_count] = count + 1
# Setting the percentage to a negative value will hide the
# percentage badge on the card
last_hash[:percentage] = -1.0
recently_added[recently_added.size - 1] = RA.from last_hash
else
recently_added << {
entry: e,
percentage: e.load_percentage(username),
grouped_count: 1,
}
end
end
recently_added[0..11]
end
end

164
src/library/entry.cr Normal file
View File

@@ -0,0 +1,164 @@
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = ArchiveFile.new path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
id = storage.get_id @zip_path, false
if id.nil?
id = random_str
storage.insert_id({
path: @zip_path,
id: id,
is_title: false,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def read_page(page_num)
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_numerically a.filename, b.filename
}
.[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
img
end
def next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == entries.size - 1
entries[idx + 1]
end
def previous_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == 0
@book.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)
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
else
info.progress[username][@title] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {@title => Time.utc}
else
info.last_read[username][@title] = Time.utc
end
info.save
end
end
def load_progress(username)
progress = 0
TitleInfo.new @book.dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][@title]?.nil?
progress = info.progress[username][@title]
end
end
[progress, @pages].min
end
def load_percentage(username)
page = load_progress username
page / @pages
end
def load_last_read(username)
last_read = nil
TitleInfo.new @book.dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][@title]?.nil?
last_read = info.last_read[username][@title]
end
end
last_read
end
def finished?(username)
load_progress(username) == @pages
end
def started?(username)
load_progress(username) > 0
end
end

188
src/library/library.cr Normal file
View File

@@ -0,0 +1,188 @@
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
title_hash : Hash(String, Title)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
register_mime_types
@dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@title_ids = [] of String
@title_hash = {} of String => Title
return scan if @scan_interval < 1
spawn do
loop do
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def titles
@title_ids.map { |tid| self.get_title!(tid) }
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
storage = Storage.new auto_close: false
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
storage.bulk_insert_ids
storage.close
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
.map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0..11]
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
last_read = e.load_last_read username
pe = e.previous_entry
if last_read.nil? && pe
last_read = pe.load_last_read username
end
{
entry: e,
percentage: e.load_percentage(username),
last_read: last_read,
}
}
# Sort by by last_read, most recent first (nils at the end)
cr_entries.sort { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}
end
alias RA = NamedTuple(
entry: Entry,
percentage: Float64,
grouped_count: Int32)
def get_recently_added_entries(username)
recently_added = [] of RA
last_date_added = nil
titles.map { |t| t.deep_entries_with_date_added }.flatten
.select { |e| e[:date_added] > 1.month.ago }
.sort { |a, b| b[:date_added] <=> a[:date_added] }
.each do |e|
break if recently_added.size > 12
last = recently_added.last?
if last && e[:entry].title_id == last[:entry].title_id &&
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32)
last_hash[:grouped_count] = count + 1
# Setting the percentage to a negative value will hide the
# percentage badge on the card
last_hash[:percentage] = -1.0
recently_added[recently_added.size - 1] = RA.from last_hash
else
last_date_added = e[:date_added]
recently_added << {
entry: e[:entry],
percentage: e[:entry].load_percentage(username),
grouped_count: 1,
}
end
end
recently_added[0..11]
end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
end

368
src/library/title.cr Normal file
View File

@@ -0,0 +1,368 @@
require "../archive"
class Title
property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@library : Library)
id = storage.get_id @dir, true
if id.nil?
id = random_str
storage.insert_id({
path: @dir,
id: id,
is_title: true,
})
end
@id = id
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@entries = [] of Entry
@mtime = File.info(dir).modification_time
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
unless File.readable? path
Logger.warn "File #{path} is not readable. Please make sure the " \
"file permission is configured correctly."
next
end
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title,
@library.title_hash[b].title
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b|
sorter.compare a.title, b.title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
end
# Get all entries, including entries in nested titles
def deep_entries
return @entries if title_ids.empty?
@entries + titles.map { |t| t.deep_entries }.flatten
end
def deep_titles
return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary.reverse
end
def size
@entries.size + @title_ids.size
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
def display_name
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
URI.encode display_name
end
def display_name(entry_name)
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
dn
end
def set_display_name(dn)
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "#{Config.current.base_url}img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
e.save_progress username, e.pages
end
titles.each do |t|
t.read_all username
end
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
e.save_progress username, 0
end
titles.each do |t|
t.unread_all username
end
end
def deep_read_page_count(username) : Int32
load_progress_for_all_entries(username).sum +
titles.map { |t| t.deep_read_page_count username }.flatten.sum
end
def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum +
titles.map { |t| t.deep_total_page_count }.flatten.sum
end
def load_percentage(username)
deep_read_page_count(username) / deep_total_page_count
end
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
ary.map do |e|
info_progress = 0
if progress && progress.has_key? e.title
info_progress = [progress[e.title], e.pages].min
end
info_progress
end
end
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
progress = load_progress_for_all_entries username, opt, unsorted
ary.map_with_index do |e, i|
progress[i] / e.pages
end
end
# Returns the sorted entries array
#
# When `opt` is nil, it uses the preferred sorting options in info.json, or
# use the default (auto, ascending)
# When `opt` is not nil, it saves the options to info.json
def sorted_entries(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
case opt.not_nil!.method
when .title?
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
when .time_modified?
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .time_added?
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
compare_numerically a.title, b.title }
when .progress?
percentage_ary = load_percentage_for_all_entries username, opt, true
ary = @entries.zip(percentage_ary)
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
compare_numerically a_tp[0].title, b_tp[0].title }
.map { |tp| tp[0] }
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
ary = @entries.sort do |a, b|
sorter.compare(a.title, b.title).or \
compare_numerically a.title, b.title
end
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
# === helper methods ===
# Gets the last read entry in the title. If the entry has been completed,
# returns the next entry. Returns nil when no entry has been read yet,
# or when all entries are completed
def get_last_read_entry(username) : Entry?
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
return if progress.nil?
last_read_entry = nil
sorted_entries(username).reverse_each do |e|
if progress.has_key?(e.title) && progress[e.title] > 0
last_read_entry = e
break
end
end
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username
end
last_read_entry
end
# Equivalent to `@entries.map &. date_added`, but much more efficient
def get_date_added_for_all_entries
da = {} of String => Time
TitleInfo.new @dir do |info|
da = info.date_added
end
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.zip_path
end
TitleInfo.new @dir do |info|
info.date_added = da
info.save
end
@entries.map { |e| da[e.title] }
end
def deep_entries_with_date_added
da_ary = get_date_added_for_all_entries
zip = @entries.map_with_index do |e, i|
{entry: e, date_added: da_ary[i]}
end
return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
end
end

102
src/library/types.cr Normal file
View File

@@ -0,0 +1,102 @@
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
enum SortMethod
Auto
Title
Progress
TimeModified
TimeAdded
end
class SortOptions
property method : SortMethod, ascend : Bool
def initialize(in_method : String? = nil, @ascend = true)
@method = SortMethod::Auto
SortMethod.each do |m, _|
if in_method && m.to_s.underscore == in_method
@method = m
return
end
end
end
def initialize(in_method : SortMethod? = nil, @ascend = true)
if in_method
@method = in_method
else
@method = SortMethod::Auto
end
end
def self.from_tuple(tp : Tuple(String, Bool))
method, ascend = tp
self.new method, ascend
end
def self.from_info_json(dir, username)
opt = SortOptions.new
TitleInfo.new dir do |info|
if info.sort_by.has_key? username
opt = SortOptions.from_tuple info.sort_by[username]
end
end
opt
end
def to_tuple
{@method.to_s.underscore, ascend}
end
end
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class TitleInfo
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
property sort_by = {} of String => Tuple(String, Bool)
@[JSON::Field(ignore: true)]
property dir : String = ""
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
end
end

View File

@@ -319,7 +319,7 @@ module MangaDex
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz"
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1
@@ -369,9 +369,12 @@ module MangaDex
Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
writer.close
Logger.debug "cbz File created at #{zip_path}"
filename = File.join File.dirname(zip_path), File.basename(zip_path,
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive zip_path
zip_exception = validate_archive filename
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job

View File

@@ -4,7 +4,7 @@ require "./mangadex/*"
require "option_parser"
require "clim"
MANGO_VERSION = "0.7.1"
MANGO_VERSION = "0.8.0"
macro common_option
option "-c PATH", "--config=PATH", type: String,

View File

@@ -39,9 +39,14 @@ class MainRouter < Router
get "/library" do |env|
begin
titles = @context.library.titles
username = get_username env
sort_opt = SortOptions.from_info_json @context.library.dir, username
get_sort_opt
titles = @context.library.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
@@ -53,12 +58,18 @@ class MainRouter < Router
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
percentage = title.entries.map &.load_percentage username
sort_opt = SortOptions.from_info_json title.dir, username
get_sort_opt
entries = title.sorted_entries username, sort_opt
percentage = title.load_percentage_for_all_entries username, sort_opt
title_percentage = title.titles.map &.load_percentage username
layout "title"
rescue e
@context.error e
env.response.status_code = 404
env.response.status_code = 500
end
end

View File

@@ -48,7 +48,7 @@ class ReaderRouter < Router
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}"
next_entry = entry.next_entry
next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end

View File

@@ -1,8 +1,8 @@
require "kemal"
require "kemal-session"
require "./library"
require "./library/*"
require "./handlers/*"
require "./util"
require "./util/*"
require "./routes/*"
class Context

View File

@@ -2,7 +2,7 @@ require "sqlite3"
require "crypto/bcrypt"
require "uuid"
require "base64"
require "./util"
require "./util/*"
def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s
@@ -14,6 +14,12 @@ end
class Storage
@path : String
@db : DB::Database?
@insert_ids = [] of IDTuple
alias IDTuple = NamedTuple(path: String,
id: String,
is_title: Bool)
def self.default : self
unless @@default
@@ -22,7 +28,8 @@ class Storage
@@default.not_nil!
end
def initialize(db_path : String? = nil, init_user = true)
def initialize(db_path : String? = nil, init_user = true, *,
@auto_close = true)
@path = db_path || Config.current.db_path
dir = File.dirname @path
unless Dir.exists? dir
@@ -60,6 +67,9 @@ class Storage
init_admin if init_user
end
end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
end
end
macro init_admin
@@ -71,8 +81,18 @@ class Storage
"#{{"username" => "admin", "password" => random_pw}}"
end
private def get_db(&block : DB::Database ->)
if @db.nil?
DB.open "sqlite3://#{@path}" do |db|
yield db
end
else
yield @db.not_nil!
end
end
def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
begin
hash, token = db.query_one "select password, token from " \
"users where username = (?)",
@@ -97,7 +117,7 @@ class Storage
def verify_token(token)
username = nil
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
begin
username = db.query_one "select username from users where " \
"token = (?)", token, as: String
@@ -110,7 +130,7 @@ class Storage
def verify_admin(token)
is_admin = false
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
begin
is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool
@@ -123,7 +143,7 @@ class Storage
def list_users
results = Array(Tuple(String, Bool)).new
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
db.query "select username, admin from users" do |rs|
rs.each do
results << {rs.read(String), rs.read(Bool)}
@@ -137,7 +157,7 @@ class Storage
validate_username username
validate_password password
admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
@@ -148,7 +168,7 @@ class Storage
admin = (admin ? 1 : 0)
validate_username username
validate_password password unless password.empty?
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
if password.empty?
db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",
@@ -163,13 +183,13 @@ class Storage
end
def delete_user(username)
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
db.exec "delete from users where username = (?)", username
end
end
def logout(token)
DB.open "sqlite3://#{@path}" do |db|
get_db do |db|
begin
db.exec "update users set token = (?) where token = (?)", nil, token
rescue
@@ -178,18 +198,36 @@ class Storage
end
def get_id(path, is_title)
id = random_str
DB.open "sqlite3://#{@path}" do |db|
begin
id = db.query_one "select id from ids where path = (?)", path,
as: {String}
rescue
db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
end
id = nil
get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
end
id
end
def insert_id(tp : IDTuple)
@insert_ids << tp
end
def bulk_insert_ids
get_db do |db|
db.transaction do |tx|
@insert_ids.each do |tp|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
end
end
end
@insert_ids.clear
end
def close
unless @db.nil?
@db.not_nil!.close
end
end
def to_json(json : JSON::Builder)
json.string self
end

View File

@@ -1,4 +1,4 @@
require "./util"
require "./util/*"
class Upload
def initialize(@dir : String)

View File

@@ -1,156 +0,0 @@
require "big"
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def requesting_static_file(env)
request_path_startswith env, STATIC_DIRS
end
macro layout(name)
base_url = Config.current.base_url
begin
is_admin = false
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def send_attachment(env, path)
MIME.register ".cbz", "application/vnd.comicbook+zip"
MIME.register ".cbr", "application/vnd.comicbook-rar"
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
false
end
def is_numeric(str)
/^\d+/.match(str) != nil
end
def split_by_alphanumeric(str)
arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" }
end
arr
end
def compare_alphanumerically(c, d)
is_c_bigger = c.size <=> d.size
if c.size > d.size
d += [nil] * (c.size - d.size)
elsif c.size < d.size
c += [nil] * (d.size - c.size)
end
c.zip(d) do |a, b|
return -1 if a.nil?
return 1 if b.nil?
if is_numeric(a) && is_numeric(b)
compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0
else
compare = a <=> b
return compare if compare != 0
end
end
is_c_bigger
end
def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end
def validate_archive(path : String) : Exception?
file = ArchiveFile.new path
file.check
file.close
return
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def validate_username(username)
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
end
def validate_password(password)
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end

112
src/util/chapter_sort.cr Normal file
View File

@@ -0,0 +1,112 @@
# Helper method used to sort chapters in a folder
# It respects the keywords like "Vol." and "Ch." in the filenames
# This sorting method was initially implemented in JS and done in the frontend.
# see https://github.com/hkalexling/Mango/blob/
# 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87
require "big"
private class Item
getter numbers : Hash(String, BigDecimal)
def initialize(@numbers)
end
# Compare with another Item using keys
def <=>(other : Item, keys : Array(String))
keys.each do |key|
if !@numbers.has_key?(key) && !other.numbers.has_key?(key)
next
elsif !@numbers.has_key? key
return 1
elsif !other.numbers.has_key? key
return -1
elsif @numbers[key] == other.numbers[key]
next
else
return @numbers[key] <=> other.numbers[key]
end
end
0
end
end
private class KeyRange
getter min : BigDecimal, max : BigDecimal, count : Int32
def initialize(value : BigDecimal)
@min = @max = value
@count = 1
end
def update(value : BigDecimal)
@min = value if value < @min
@max = value if value > @max
@count += 1
end
def range
@max - @min
end
end
class ChapterSorter
@sorted_keys = [] of String
def initialize(str_ary : Array(String))
keys = {} of String => KeyRange
str_ary.each do |str|
scan str do |k, v|
if keys.has_key? k
keys[k].update v
else
keys[k] = KeyRange.new v
end
end
end
# Get the array of keys string and sort them
@sorted_keys = keys.keys
# Only use keys that are present in over half of the strings
.select do |key|
keys[key].count >= str_ary.size / 2
end
.sort do |a_key, b_key|
a = keys[a_key]
b = keys[b_key]
# Sort keys by the number of times they appear
count_compare = b.count <=> a.count
if count_compare == 0
# Then sort by value range
b.range <=> a.range
else
count_compare
end
end
end
def compare(a : String, b : String)
item_a = str_to_item a
item_b = str_to_item b
item_a.<=>(item_b, @sorted_keys)
end
private def scan(str, &)
str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
key = match[1]
num = match[2].to_big_d
yield key, num
end
end
private def str_to_item(str)
numbers = {} of String => BigDecimal
scan str do |k, v|
numbers[k] = v
end
Item.new numbers
end
end

42
src/util/numeric_sort.cr Normal file
View File

@@ -0,0 +1,42 @@
# Properly sort alphanumeric strings
# Used to sort the images files inside the archives
# https://github.com/hkalexling/Mango/issues/12
require "big"
def is_numeric(str)
/^\d+/.match(str) != nil
end
def split_by_alphanumeric(str)
arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" }
end
arr
end
def compare_numerically(c, d)
is_c_bigger = c.size <=> d.size
if c.size > d.size
d += [nil] * (c.size - d.size)
elsif c.size < d.size
c += [nil] * (d.size - c.size)
end
c.zip(d) do |a, b|
return -1 if a.nil?
return 1 if b.nil?
if is_numeric(a) && is_numeric(b)
compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0
else
compare = a <=> b
return compare if compare != 0
end
end
is_c_bigger
end
def compare_numerically(a : String, b : String)
compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end

47
src/util/util.cr Normal file
View File

@@ -0,0 +1,47 @@
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def random_str
UUID.random.to_s.gsub "-", ""
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end
def register_mime_types
{
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar",
}.each do |k, v|
MIME.register k, v
end
end
struct Int
def or(other : Int)
if self == 0
other
else
self
end
end
end
struct Nil
def or(other : Int)
other
end
end

31
src/util/validation.cr Normal file
View File

@@ -0,0 +1,31 @@
def validate_username(username)
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
end
def validate_password(password)
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
def validate_archive(path : String) : Exception?
file = nil
begin
file = ArchiveFile.new path
file.check
file.close
return
rescue e
file.close unless file.nil?
e
end
end

83
src/util/web.cr Normal file
View File

@@ -0,0 +1,83 @@
# Web related helper functions/macros
macro layout(name)
base_url = Config.current.base_url
begin
is_admin = false
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def send_attachment(env, path)
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
false
end
def requesting_static_file(env)
request_path_startswith env, STATIC_DIRS
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
macro get_sort_opt
sort_method = env.params.query["sort"]?
if sort_method
is_ascending = true
ascend = env.params.query["ascend"]?
if ascend && ascend.to_i? == 0
is_ascending = false
end
sort_opt = SortOptions.new sort_method, is_ascending
end
end

View File

@@ -9,42 +9,58 @@
<% grouped_count = 1 %>
<% end %>
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress %>"
<div class="item"
<% if item.is_a? Entry %>
id="<%= item.id %>"
<% end %>>
<a class="acard"
<% unless item.is_a? Entry %>
href="<%= base_url %>book/<%= item.id %>"
<div class="acard
<% if item.is_a? Entry %>
<%= "is_entry" %>
<% end %>
"
<% if item.is_a? Entry %>
data-encoded-path="<%= item.encoded_path %>"
data-pages="<%= item.pages %>"
data-progress="<%= (progress * 100).round(1) %>"
data-encoded-book-title="<%= item.book.encoded_display_name %>"
data-encoded-title="<%= item.encoded_display_name %>"
data-book-id="<%= item.book.id %>"
data-id="<%= item.id %>"
<% else %>
onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>>
<div class="uk-card uk-card-default"
<% if item.is_a? Entry %>
onclick="showModal(&quot;<%= item.encoded_path %>&quot;, '<%= item.pages %>', <%= (progress * 100).round(1) %>, &quot;<%= item.book.encoded_display_name %>&quot;, &quot;<%= item.encoded_display_name %>&quot;, '<%= item.title_id %>', '<%= item.id %>')"
<% end %>>
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<% unless progress < 0 || progress > 100 %>
<div class="uk-card-badge uk-label"><%= (progress * 100).round(1) %>%</div>
<div class="uk-card-badge label"><%= (progress * 100).round(1) %>%</div>
<% end %>
<h3 class="uk-card-title break-word" data-title="<%= item.display_name.gsub("\"", "&quot;") %>"><%= item.display_name %></h3>
<h3 class="uk-card-title break-word
<% if page == "home" && item.is_a? Entry %>
<%= "uk-margin-remove-bottom" %>
<% end %>
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
</h3>
<% if page == "home" && item.is_a? Entry %>
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
<% end %>
<% if item.is_a? Entry %>
<p><%= item.pages %> pages</p>
<p class="uk-text-meta"><%= item.pages %> pages</p>
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p><%= item.size %> entries</p>
<p class="uk-text-meta"><%= item.size %> entries</p>
<% else %>
<p><%= grouped_count %> new entries</p>
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %>
<% end %>
</div>
</div>
</a>
</div>
</div>

View File

@@ -3,9 +3,6 @@
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<% if page == "home" %>
<h4 class="uk-margin-remove-bottom"><a id="modal-title-link"></a></h4>
<% end %>
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
&nbsp;
<% unless page == "home" %>

View File

@@ -1,8 +1,14 @@
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<% hash.each do |k, v| %>
<option id="<%= k %>-up">â–˛ <%= v %></option>
<option id="<%= k %>-down">â–Ľ <%= v %></option>
<option id="<%= k %>-up"
<% if sort_opt && k == sort_opt.method.to_s.underscore && sort_opt.ascend %>
<%= "selected" %>
<% end %>>â–˛ <%= v %></option>
<option id="<%= k %>-down"
<% if sort_opt && k == sort_opt.method.to_s.underscore && !sort_opt.ascend %>
<%= "selected" %>
<% end %>>â–Ľ <%= v %></option>
<% end %>
</select>
</div>

View File

@@ -9,8 +9,8 @@
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"name" => "Name",
"date" => "Date Modified",
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>

View File

@@ -14,7 +14,7 @@
<% titles.each do |t| %>
<entry>
<title><%= t.display_name %></title>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>

View File

@@ -5,7 +5,7 @@
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title><%= title.display_name %></title>
<title><%= HTML.escape(title.display_name) %></title>
<author>
<name>Mango</name>
@@ -14,7 +14,7 @@
<% title.titles.each do |t| %>
<entry>
<title><%= t.display_name %></title>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
@@ -22,7 +22,7 @@
<% title.entries.each do |e| %>
<entry>
<title><%= e.display_name %></title>
<title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id>
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />

View File

@@ -24,8 +24,9 @@
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"name" => "Name",
"date" => "Date Modified",
"title" => "Name",
"time_modified" => "Date Modified",
"time_added" => "Date Added",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
@@ -36,7 +37,7 @@
<% progress = title_percentage[i] %>
<%= render_component "card" %>
<% end %>
<% title.entries.each_with_index do |item, i| %>
<% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>