mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b244c68b8 | |||
| 281f626e8c | |||
| 5be4f51d7e | |||
| cd7782ba1e | |||
| 6d97bc083c | |||
| ff4b1be9ae | |||
| ba16c3db2f | |||
| 69b06a8352 | |||
| 687788767f | |||
| 94a1e63963 | |||
| 360913ee78 | |||
| ea366f263a | |||
| 0d11cb59e9 | |||
| 2208f90d8e | |||
| 07100121ef | |||
| a0e550569e | |||
| bbbe2e0588 | |||
| 9d31b24e8c | |||
| 38ba324fa9 | |||
| c00016fa19 | |||
| 4d5a305d1b | |||
| f9ca52ee2f | |||
| f6c393545c | |||
| 466aee62fe | |||
| eab0800376 | |||
| 1725f42698 | |||
| f5cdf8b7b6 | |||
| fe082e7537 | |||
| c87b96dd0b | |||
| 9d76ca8c24 |
@@ -50,7 +50,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.7.1
|
Mango - Manga Server and Web Reader. Version 0.8.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ gulp.task('minify-js', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('less', () => {
|
gulp.task('less', () => {
|
||||||
return gulp.src('src/assets/*.less')
|
return gulp.src('public/css/*.less')
|
||||||
.pipe(less())
|
.pipe(less())
|
||||||
.pipe(gulp.dest('public/css'));
|
.pipe(gulp.dest('public/css'));
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-8
@@ -1,74 +1,99 @@
|
|||||||
.uk-alert-close {
|
.uk-alert-close {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-card-body {
|
.uk-card-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-card-media-top {
|
.uk-card-media-top {
|
||||||
height: 250px;
|
height: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
.uk-card-media-top {
|
.uk-card-media-top {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.uk-card-media-top > img {
|
.uk-card-media-top>img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-card-title {
|
.uk-card-title {
|
||||||
height: 3em;
|
max-height: 3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acard:hover {
|
.acard:hover {
|
||||||
text-decoration: none;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-list li {
|
.uk-list li {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-bg {
|
.reader-bg {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#scan-status {
|
#scan-status {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.break-word {
|
.break-word {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
.uk-logo > img {
|
|
||||||
|
.uk-logo>img {
|
||||||
height: 90px;
|
height: 90px;
|
||||||
width: 90px;
|
width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-search {
|
.uk-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selecting {
|
#selectable .ui-selecting {
|
||||||
background: #EEE6B9;
|
background: #EEE6B9;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selected {
|
#selectable .ui-selected {
|
||||||
background: #F4E487;
|
background: #F4E487;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selecting.dark {
|
#selectable .ui-selecting.dark {
|
||||||
background: #5E5731;
|
background: #5E5731;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selected.dark {
|
#selectable .ui-selected.dark {
|
||||||
background: #9D9252;
|
background: #9D9252;
|
||||||
}
|
}
|
||||||
td > .uk-dropdown {
|
|
||||||
|
td>.uk-dropdown {
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
#edit-modal .uk-grid > div {
|
|
||||||
|
#edit-modal .uk-grid>div {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal #cover {
|
#edit-modal #cover {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal #cover-upload {
|
#edit-modal #cover-upload {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal .uk-modal-body .uk-inline {
|
#edit-modal .uk-modal-body .uk-inline {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item .uk-card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,13 +1,12 @@
|
|||||||
const truncate = () => {
|
const truncate = () => {
|
||||||
$('.acard .uk-card-title').each((i, e) => {
|
$('.uk-card-title').each((i, e) => {
|
||||||
$(e).dotdotdot({
|
$(e).dotdotdot({
|
||||||
truncate: 'letter',
|
truncate: 'letter',
|
||||||
watch: true,
|
watch: true,
|
||||||
callback: (truncated) => {
|
callback: (truncated) => {
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$(e).removeAttr('uk-tooltip');
|
$(e).removeAttr('uk-tooltip');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-115
@@ -1,123 +1,15 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
const sortItems = () => {
|
$('#sort-select').change(() => {
|
||||||
const sort = $('#sort-select').find(':selected').attr('id');
|
const sort = $('#sort-select').find(':selected').attr('id');
|
||||||
const ary = sort.split('-');
|
const ary = sort.split('-');
|
||||||
const by = ary[0];
|
const by = ary[0];
|
||||||
const dir = ary[1];
|
const dir = ary[1];
|
||||||
|
|
||||||
let items = $('.item');
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
items.remove();
|
const newURL = `${url}?${$.param({
|
||||||
|
sort: by,
|
||||||
const ctxAry = [];
|
ascend: dir === 'up' ? 1 : 0
|
||||||
const keyRange = {};
|
})}`;
|
||||||
if (by === 'auto') {
|
window.location.href = newURL;
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($('option#auto-up').length > 0)
|
|
||||||
$('option#auto-up').attr('selected', '');
|
|
||||||
else
|
|
||||||
$('option#name-up').attr('selected', '');
|
|
||||||
|
|
||||||
sortItems();
|
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-3
@@ -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) {
|
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
||||||
const zipPath = decodeURIComponent(encodedPath);
|
const zipPath = decodeURIComponent(encodedPath);
|
||||||
const title = decodeURIComponent(encodedeTitle);
|
const title = decodeURIComponent(encodedeTitle);
|
||||||
@@ -15,9 +36,6 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
$('#continue-btn').text('Continue from ' + percentage + '%');
|
$('#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').find('span').text(entry);
|
||||||
$('#modal-entry-title').next().attr('data-id', titleID);
|
$('#modal-entry-title').next().attr('data-id', titleID);
|
||||||
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ shards:
|
|||||||
|
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
github: hkalexling/archive.cr
|
||||||
version: 0.2.0
|
version: 0.3.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.7.1
|
version: 0.8.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
|
|||||||
+15
-5
@@ -1,10 +1,10 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe "compare_alphanumerically" do
|
describe "compare_numerically" do
|
||||||
it "sorts filenames with leading zeros correctly" do
|
it "sorts filenames with leading zeros correctly" do
|
||||||
ary = ["010.jpg", "001.jpg", "002.png"]
|
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||||
end
|
end
|
||||||
@@ -12,7 +12,7 @@ describe "compare_alphanumerically" do
|
|||||||
it "sorts filenames without leading zeros correctly" do
|
it "sorts filenames without leading zeros correctly" do
|
||||||
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||||
end
|
end
|
||||||
@@ -22,7 +22,7 @@ describe "compare_alphanumerically" do
|
|||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,7 +30,17 @@ describe "compare_alphanumerically" do
|
|||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../storage"
|
require "../storage"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class AuthHandler < Kemal::Handler
|
class AuthHandler < Kemal::Handler
|
||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require "baked_file_system"
|
require "baked_file_system"
|
||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class FS
|
class FS
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class UploadHandler < Kemal::Handler
|
class UploadHandler < Kemal::Handler
|
||||||
def initialize(@upload_dir : String)
|
def initialize(@upload_dir : String)
|
||||||
|
|||||||
-599
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -319,7 +319,7 @@ module MangaDex
|
|||||||
unless File.exists? manga_dir
|
unless File.exists? manga_dir
|
||||||
Dir.mkdir_p manga_dir
|
Dir.mkdir_p manga_dir
|
||||||
end
|
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
|
# Find the number of digits needed to store the number of pages
|
||||||
len = Math.log10(chapter.pages.size).to_i + 1
|
len = Math.log10(chapter.pages.size).to_i + 1
|
||||||
@@ -369,9 +369,12 @@ module MangaDex
|
|||||||
Logger.debug "Download completed. " \
|
Logger.debug "Download completed. " \
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
"#{fail_count}/#{page_jobs.size} failed"
|
||||||
writer.close
|
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?
|
if !zip_exception.nil?
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
@queue.add_message "The downloaded archive is corrupted. " \
|
||||||
"Error: #{zip_exception}", job
|
"Error: #{zip_exception}", job
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ require "./mangadex/*"
|
|||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
require "clim"
|
||||||
|
|
||||||
MANGO_VERSION = "0.7.1"
|
MANGO_VERSION = "0.8.0"
|
||||||
|
|
||||||
macro common_option
|
macro common_option
|
||||||
option "-c PATH", "--config=PATH", type: String,
|
option "-c PATH", "--config=PATH", type: String,
|
||||||
|
|||||||
+14
-3
@@ -39,9 +39,14 @@ class MainRouter < Router
|
|||||||
|
|
||||||
get "/library" do |env|
|
get "/library" do |env|
|
||||||
begin
|
begin
|
||||||
titles = @context.library.titles
|
|
||||||
username = get_username env
|
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
|
percentage = titles.map &.load_percentage username
|
||||||
|
|
||||||
layout "library"
|
layout "library"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
@context.error e
|
||||||
@@ -53,12 +58,18 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
username = get_username env
|
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
|
title_percentage = title.titles.map &.load_percentage username
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
@context.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class ReaderRouter < Router
|
|||||||
next_page = page + IMGS_PER_PAGE
|
next_page = page + IMGS_PER_PAGE
|
||||||
next_url = next_entry_url = nil
|
next_url = next_entry_url = nil
|
||||||
exit_url = "#{base_url}book/#{title.id}"
|
exit_url = "#{base_url}book/#{title.id}"
|
||||||
next_entry = entry.next_entry
|
next_entry = entry.next_entry username
|
||||||
unless next_page > entry.pages
|
unless next_page > entry.pages
|
||||||
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||||
end
|
end
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "kemal-session"
|
require "kemal-session"
|
||||||
require "./library"
|
require "./library/*"
|
||||||
require "./handlers/*"
|
require "./handlers/*"
|
||||||
require "./util"
|
require "./util/*"
|
||||||
require "./routes/*"
|
require "./routes/*"
|
||||||
|
|
||||||
class Context
|
class Context
|
||||||
|
|||||||
+55
-17
@@ -2,7 +2,7 @@ require "sqlite3"
|
|||||||
require "crypto/bcrypt"
|
require "crypto/bcrypt"
|
||||||
require "uuid"
|
require "uuid"
|
||||||
require "base64"
|
require "base64"
|
||||||
require "./util"
|
require "./util/*"
|
||||||
|
|
||||||
def hash_password(pw)
|
def hash_password(pw)
|
||||||
Crypto::Bcrypt::Password.create(pw).to_s
|
Crypto::Bcrypt::Password.create(pw).to_s
|
||||||
@@ -14,6 +14,12 @@ end
|
|||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
@path : String
|
@path : String
|
||||||
|
@db : DB::Database?
|
||||||
|
@insert_ids = [] of IDTuple
|
||||||
|
|
||||||
|
alias IDTuple = NamedTuple(path: String,
|
||||||
|
id: String,
|
||||||
|
is_title: Bool)
|
||||||
|
|
||||||
def self.default : self
|
def self.default : self
|
||||||
unless @@default
|
unless @@default
|
||||||
@@ -22,7 +28,8 @@ class Storage
|
|||||||
@@default.not_nil!
|
@@default.not_nil!
|
||||||
end
|
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
|
@path = db_path || Config.current.db_path
|
||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
@@ -60,6 +67,9 @@ class Storage
|
|||||||
init_admin if init_user
|
init_admin if init_user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
unless @auto_close
|
||||||
|
@db = DB.open "sqlite3://#{@path}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
macro init_admin
|
macro init_admin
|
||||||
@@ -71,8 +81,18 @@ class Storage
|
|||||||
"#{{"username" => "admin", "password" => random_pw}}"
|
"#{{"username" => "admin", "password" => random_pw}}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_user(username, password)
|
private def get_db(&block : DB::Database ->)
|
||||||
|
if @db.nil?
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
yield db
|
||||||
|
end
|
||||||
|
else
|
||||||
|
yield @db.not_nil!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_user(username, password)
|
||||||
|
get_db do |db|
|
||||||
begin
|
begin
|
||||||
hash, token = db.query_one "select password, token from " \
|
hash, token = db.query_one "select password, token from " \
|
||||||
"users where username = (?)",
|
"users where username = (?)",
|
||||||
@@ -97,7 +117,7 @@ class Storage
|
|||||||
|
|
||||||
def verify_token(token)
|
def verify_token(token)
|
||||||
username = nil
|
username = nil
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
begin
|
begin
|
||||||
username = db.query_one "select username from users where " \
|
username = db.query_one "select username from users where " \
|
||||||
"token = (?)", token, as: String
|
"token = (?)", token, as: String
|
||||||
@@ -110,7 +130,7 @@ class Storage
|
|||||||
|
|
||||||
def verify_admin(token)
|
def verify_admin(token)
|
||||||
is_admin = false
|
is_admin = false
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
begin
|
begin
|
||||||
is_admin = db.query_one "select admin from users where " \
|
is_admin = db.query_one "select admin from users where " \
|
||||||
"token = (?)", token, as: Bool
|
"token = (?)", token, as: Bool
|
||||||
@@ -123,7 +143,7 @@ class Storage
|
|||||||
|
|
||||||
def list_users
|
def list_users
|
||||||
results = Array(Tuple(String, Bool)).new
|
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|
|
db.query "select username, admin from users" do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
results << {rs.read(String), rs.read(Bool)}
|
results << {rs.read(String), rs.read(Bool)}
|
||||||
@@ -137,7 +157,7 @@ class Storage
|
|||||||
validate_username username
|
validate_username username
|
||||||
validate_password password
|
validate_password password
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
hash = hash_password password
|
hash = hash_password password
|
||||||
db.exec "insert into users values (?, ?, ?, ?)",
|
db.exec "insert into users values (?, ?, ?, ?)",
|
||||||
username, hash, nil, admin
|
username, hash, nil, admin
|
||||||
@@ -148,7 +168,7 @@ class Storage
|
|||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
validate_username username
|
validate_username username
|
||||||
validate_password password unless password.empty?
|
validate_password password unless password.empty?
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
if password.empty?
|
if password.empty?
|
||||||
db.exec "update users set username = (?), admin = (?) " \
|
db.exec "update users set username = (?), admin = (?) " \
|
||||||
"where username = (?)",
|
"where username = (?)",
|
||||||
@@ -163,13 +183,13 @@ class Storage
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete_user(username)
|
def delete_user(username)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
db.exec "delete from users where username = (?)", username
|
db.exec "delete from users where username = (?)", username
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logout(token)
|
def logout(token)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
begin
|
begin
|
||||||
db.exec "update users set token = (?) where token = (?)", nil, token
|
db.exec "update users set token = (?) where token = (?)", nil, token
|
||||||
rescue
|
rescue
|
||||||
@@ -178,18 +198,36 @@ class Storage
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_id(path, is_title)
|
def get_id(path, is_title)
|
||||||
id = random_str
|
id = nil
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
get_db do |db|
|
||||||
begin
|
id = db.query_one? "select id from ids where path = (?)", path,
|
||||||
id = db.query_one "select id from ids where path = (?)", path,
|
|
||||||
as: {String}
|
as: {String}
|
||||||
rescue
|
|
||||||
db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
id
|
id
|
||||||
end
|
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)
|
def to_json(json : JSON::Builder)
|
||||||
json.string self
|
json.string self
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
require "./util"
|
require "./util/*"
|
||||||
|
|
||||||
class Upload
|
class Upload
|
||||||
def initialize(@dir : String)
|
def initialize(@dir : String)
|
||||||
|
|||||||
-156
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -9,42 +9,58 @@
|
|||||||
<% grouped_count = 1 %>
|
<% grouped_count = 1 %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress %>"
|
<div class="item"
|
||||||
<% if item.is_a? Entry %>
|
<% if item.is_a? Entry %>
|
||||||
id="<%= item.id %>"
|
id="<%= item.id %>"
|
||||||
<% end %>>
|
<% end %>>
|
||||||
|
|
||||||
<a class="acard"
|
<div class="acard
|
||||||
<% unless item.is_a? Entry %>
|
|
||||||
href="<%= base_url %>book/<%= item.id %>"
|
|
||||||
<% end %>>
|
|
||||||
|
|
||||||
<div class="uk-card uk-card-default"
|
|
||||||
<% if item.is_a? Entry %>
|
<% if item.is_a? Entry %>
|
||||||
onclick="showModal("<%= item.encoded_path %>", '<%= item.pages %>', <%= (progress * 100).round(1) %>, "<%= item.book.encoded_display_name %>", "<%= item.encoded_display_name %>", '<%= item.title_id %>', '<%= item.id %>')"
|
<%= "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 %>>
|
<% end %>>
|
||||||
|
|
||||||
|
<div class="uk-card uk-card-default">
|
||||||
<div class="uk-card-media-top">
|
<div class="uk-card-media-top">
|
||||||
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
|
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-card-body">
|
<div class="uk-card-body">
|
||||||
<% unless progress < 0 || progress > 100 %>
|
<% 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 %>
|
<% end %>
|
||||||
|
|
||||||
<h3 class="uk-card-title break-word" data-title="<%= item.display_name.gsub("\"", """) %>"><%= 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 %>
|
<% if item.is_a? Entry %>
|
||||||
<p><%= item.pages %> pages</p>
|
<p class="uk-text-meta"><%= item.pages %> pages</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if item.is_a? Title %>
|
<% if item.is_a? Title %>
|
||||||
<% if grouped_count == 1 %>
|
<% if grouped_count == 1 %>
|
||||||
<p><%= item.size %> entries</p>
|
<p class="uk-text-meta"><%= item.size %> entries</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p><%= grouped_count %> new entries</p>
|
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
<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">
|
||||||
<div>
|
<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>
|
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
|
||||||
|
|
||||||
<% unless page == "home" %>
|
<% unless page == "home" %>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<div class="uk-form-horizontal">
|
<div class="uk-form-horizontal">
|
||||||
<select class="uk-select" id="sort-select">
|
<select class="uk-select" id="sort-select">
|
||||||
<% hash.each do |k, v| %>
|
<% hash.each do |k, v| %>
|
||||||
<option id="<%= k %>-up">â–˛ <%= v %></option>
|
<option id="<%= k %>-up"
|
||||||
<option id="<%= k %>-down">â–Ľ <%= v %></option>
|
<% 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 %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
<% hash = {
|
<% hash = {
|
||||||
"name" => "Name",
|
"auto" => "Auto",
|
||||||
"date" => "Date Modified",
|
"time_modified" => "Date Modified",
|
||||||
"progress" => "Progress"
|
"progress" => "Progress"
|
||||||
} %>
|
} %>
|
||||||
<%= render_component "sort-form" %>
|
<%= render_component "sort-form" %>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<% titles.each do |t| %>
|
<% titles.each do |t| %>
|
||||||
<entry>
|
<entry>
|
||||||
<title><%= t.display_name %></title>
|
<title><%= HTML.escape(t.display_name) %></title>
|
||||||
<id>urn:mango:<%= t.id %></id>
|
<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 %>" />
|
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
|
||||||
</entry>
|
</entry>
|
||||||
|
|||||||
@@ -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="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" />
|
<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>
|
<author>
|
||||||
<name>Mango</name>
|
<name>Mango</name>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<% title.titles.each do |t| %>
|
<% title.titles.each do |t| %>
|
||||||
<entry>
|
<entry>
|
||||||
<title><%= t.display_name %></title>
|
<title><%= HTML.escape(t.display_name) %></title>
|
||||||
<id>urn:mango:<%= t.id %></id>
|
<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 %>" />
|
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
|
||||||
</entry>
|
</entry>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<% title.entries.each do |e| %>
|
<% title.entries.each do |e| %>
|
||||||
<entry>
|
<entry>
|
||||||
<title><%= e.display_name %></title>
|
<title><%= HTML.escape(e.display_name) %></title>
|
||||||
<id>urn:mango:<%= e.id %></id>
|
<id>urn:mango:<%= e.id %></id>
|
||||||
|
|
||||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
<% hash = {
|
<% hash = {
|
||||||
"auto" => "Auto",
|
"auto" => "Auto",
|
||||||
"name" => "Name",
|
"title" => "Name",
|
||||||
"date" => "Date Modified",
|
"time_modified" => "Date Modified",
|
||||||
|
"time_added" => "Date Added",
|
||||||
"progress" => "Progress"
|
"progress" => "Progress"
|
||||||
} %>
|
} %>
|
||||||
<%= render_component "sort-form" %>
|
<%= render_component "sort-form" %>
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<% progress = title_percentage[i] %>
|
<% progress = title_percentage[i] %>
|
||||||
<%= render_component "card" %>
|
<%= render_component "card" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% title.entries.each_with_index do |item, i| %>
|
<% entries.each_with_index do |item, i| %>
|
||||||
<% progress = percentage[i] %>
|
<% progress = percentage[i] %>
|
||||||
<%= render_component "card" %>
|
<%= render_component "card" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
Reference in New Issue
Block a user