mirror of
https://github.com/hkalexling/Mango.git
synced 2026-03-20 00:00:48 -04:00
Compare commits
74 Commits
v0.20.0
...
3b19883dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b19883dde | ||
|
|
6844860065 | ||
|
|
9eb699ea3b | ||
|
|
59bcb4db3b | ||
|
|
87c479bf42 | ||
|
|
e0713ccde8 | ||
|
|
a571d21cba | ||
|
|
23541f457e | ||
|
|
cd8944ed2d | ||
|
|
7f0c256fe6 | ||
|
|
46e6e41bfe | ||
|
|
c9f55e7a8e | ||
|
|
741c3a4e20 | ||
|
|
f6da20321d | ||
|
|
2764e955b2 | ||
|
|
00c15014a1 | ||
|
|
c6fdbfd9fd | ||
|
|
e03bf32358 | ||
|
|
bbf1520c73 | ||
|
|
8950c3a1ed | ||
|
|
17837d8a29 | ||
|
|
b4a69425c8 | ||
|
|
a612500b0f | ||
|
|
9bb7144479 | ||
|
|
ee52c52f46 | ||
|
|
daec2bdac6 | ||
|
|
e9a490676b | ||
|
|
757f7c8214 | ||
|
|
eed1a9717e | ||
|
|
8829d2e237 | ||
|
|
eec6ec60bf | ||
|
|
3a82effa40 | ||
|
|
0b3e78bcb7 | ||
|
|
cb4e4437a6 | ||
|
|
6a275286ea | ||
|
|
2743868438 | ||
|
|
d3f26ecbc9 | ||
|
|
f62344806a | ||
|
|
b7b7e6f718 | ||
|
|
05b4e77fa9 | ||
|
|
8aab113aab | ||
|
|
371c8056e7 | ||
|
|
a9a2c9faa8 | ||
|
|
011768ed1f | ||
|
|
c36d2608e8 | ||
|
|
1b25a1fa47 | ||
|
|
df7e2270a4 | ||
|
|
3c3549a489 | ||
|
|
8160b0a18e | ||
|
|
a7eff772be | ||
|
|
bf3900f9a2 | ||
|
|
6fa575cf4f | ||
|
|
604c5d49a6 | ||
|
|
7449d19075 | ||
|
|
c5c9305a0b | ||
|
|
fdceab9060 | ||
|
|
c18591c5cf | ||
|
|
bb5cb9b94c | ||
|
|
fb499a5caf | ||
|
|
154d85e197 | ||
|
|
933617503e | ||
|
|
31c6893bbb | ||
|
|
171125e8ac | ||
|
|
d81334026b | ||
|
|
2b3b2eb8ba | ||
|
|
ffd5f4454b | ||
|
|
cb25d7ba00 | ||
|
|
c61eb7554e | ||
|
|
edd9a2e093 | ||
|
|
1f50785e8f | ||
|
|
70d418d1a1 | ||
|
|
45e20c94f9 | ||
|
|
4da263c594 | ||
|
|
d67a24809b |
@@ -95,6 +95,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "davidkna",
|
||||
"name": "David Knaack",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
||||
"profile": "https://github.com/davidkna",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: crystallang/crystal:0.35.1-alpine
|
||||
image: crystallang/crystal:0.36.1-alpine
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
- name: Build
|
||||
run: make static || make static
|
||||
- name: Linter
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ public/css/uikit.css
|
||||
public/img/*.svg
|
||||
public/js/*.min.js
|
||||
public/css/*.css
|
||||
public/webfonts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM crystallang/crystal:0.35.1-alpine AS builder
|
||||
FROM crystallang/crystal:0.36.1-alpine AS builder
|
||||
|
||||
WORKDIR /Mango
|
||||
|
||||
@@ -10,6 +10,6 @@ FROM library/alpine
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /Mango/mango .
|
||||
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
||||
|
||||
CMD ["./mango"]
|
||||
CMD ["/usr/local/bin/mango"]
|
||||
|
||||
@@ -2,13 +2,14 @@ FROM arm32v7/ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||
|
||||
COPY mango-arm32v7.o .
|
||||
|
||||
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
|
||||
CMD ["/usr/local/bin/mango"]
|
||||
|
||||
CMD ["./mango"]
|
||||
|
||||
@@ -2,13 +2,13 @@ FROM arm64v8/ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||
|
||||
COPY mango-arm64v8.o .
|
||||
|
||||
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
|
||||
CMD ["./mango"]
|
||||
CMD ["/usr/local/bin/mango"]
|
||||
|
||||
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango - Manga Server and Web Reader. Version 0.20.0
|
||||
Mango - Manga Server and Web Reader. Version 0.22.0
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -75,6 +75,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
||||
|
||||
```yaml
|
||||
---
|
||||
host: 0.0.0.0
|
||||
port: 9000
|
||||
base_url: /
|
||||
session_secret: mango-session-secret
|
||||
@@ -89,14 +90,16 @@ download_timeout_seconds: 30
|
||||
page_margin: 30
|
||||
disable_login: false
|
||||
default_username: ""
|
||||
auth_proxy_header_name: ""
|
||||
mangadex:
|
||||
base_url: https://mangadex.org
|
||||
api_url: https://mangadex.org/api
|
||||
api_url: https://api.mangadex.org/v2
|
||||
download_wait_seconds: 5
|
||||
download_retries: 4
|
||||
download_queue_db_path: ~/mango/queue.db
|
||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||
manga_rename_rule: '{title}'
|
||||
subscription_update_interval_hours: 24
|
||||
```
|
||||
|
||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||
@@ -172,6 +175,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">đź’»</a></td>
|
||||
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
36
gulpfile.js
36
gulpfile.js
@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
|
||||
const minifyCss = require('gulp-minify-css');
|
||||
const less = require('gulp-less');
|
||||
|
||||
// Copy libraries from node_moduels to public/js
|
||||
gulp.task('copy-js', () => {
|
||||
return gulp.src([
|
||||
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
||||
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
||||
'node_modules/uikit/dist/js/uikit.min.js',
|
||||
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
||||
])
|
||||
.pipe(gulp.dest('public/js'));
|
||||
});
|
||||
|
||||
// Copy UIKit SVG icons to public/img
|
||||
gulp.task('copy-uikit-icons', () => {
|
||||
gulp.task('copy-img', () => {
|
||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||
.pipe(gulp.dest('public/img'));
|
||||
});
|
||||
|
||||
gulp.task('copy-font', () => {
|
||||
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
|
||||
.pipe(gulp.dest('public/webfonts'));
|
||||
});
|
||||
|
||||
// Copy files from node_modules
|
||||
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
|
||||
|
||||
// Compile less
|
||||
gulp.task('less', () => {
|
||||
return gulp.src('public/css/*.less')
|
||||
return gulp.src([
|
||||
'public/css/mango.less',
|
||||
'public/css/tags.less'
|
||||
])
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest('public/css'));
|
||||
});
|
||||
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
|
||||
|
||||
// Copy static files (includeing images) to dist
|
||||
gulp.task('copy-files', () => {
|
||||
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
|
||||
return gulp.src([
|
||||
'public/*.*',
|
||||
'public/img/*',
|
||||
'public/webfonts/*',
|
||||
'public/js/*.min.js'
|
||||
], {
|
||||
base: 'public'
|
||||
})
|
||||
.pipe(gulp.dest('dist'));
|
||||
});
|
||||
|
||||
// Set up the public folder for development
|
||||
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
||||
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
|
||||
|
||||
// Set up the dist folder for deployment
|
||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||
|
||||
20
migration/md_account.11.cr
Normal file
20
migration/md_account.11.cr
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateMangaDexAccount < MG::Base
|
||||
def up : String
|
||||
<<-SQL
|
||||
CREATE TABLE md_account (
|
||||
username TEXT NOT NULL PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
expire INTEGER NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES users (username)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
SQL
|
||||
end
|
||||
|
||||
def down : String
|
||||
<<-SQL
|
||||
DROP TABLE md_account;
|
||||
SQL
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,16 @@
|
||||
// UIKit
|
||||
@import "./uikit.less";
|
||||
|
||||
// FontAwesome
|
||||
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
|
||||
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
|
||||
|
||||
@font-face {
|
||||
src: url('@{fa-font-path}/fa-solid-900.woff2');
|
||||
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
|
||||
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
|
||||
}
|
||||
|
||||
// Item cards
|
||||
.item .uk-card {
|
||||
cursor: pointer;
|
||||
@@ -21,9 +34,11 @@
|
||||
.uk-card-body {
|
||||
padding: 20px;
|
||||
.uk-card-title {
|
||||
max-height: 3em;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.uk-card-title:not(.free-height) {
|
||||
max-height: 3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,3 +43,22 @@
|
||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||
|
||||
.hook-card-default() {
|
||||
.uk-light & {
|
||||
background: @card-secondary-background;
|
||||
color: @card-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.hook-card-default-title() {
|
||||
.uk-light & {
|
||||
color: @card-secondary-title-color;
|
||||
}
|
||||
}
|
||||
|
||||
.hook-card-default-hover() {
|
||||
.uk-light & {
|
||||
background-color: @card-secondary-hover-background;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,14 +117,10 @@ const setTheme = (theme) => {
|
||||
if (theme === 'dark') {
|
||||
$('html').css('background', 'rgb(20, 20, 20)');
|
||||
$('body').addClass('uk-light');
|
||||
$('.uk-card').addClass('uk-card-secondary');
|
||||
$('.uk-card').removeClass('uk-card-default');
|
||||
$('.ui-widget-content').addClass('dark');
|
||||
} else {
|
||||
$('html').css('background', '');
|
||||
$('body').removeClass('uk-light');
|
||||
$('.uk-card').removeClass('uk-card-secondary');
|
||||
$('.uk-card').addClass('uk-card-default');
|
||||
$('.ui-widget-content').removeClass('dark');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,305 +1,285 @@
|
||||
$(() => {
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('.filter-field').each((i, ele) => {
|
||||
$(ele).change(() => {
|
||||
buildTable();
|
||||
});
|
||||
});
|
||||
});
|
||||
const selectAll = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
};
|
||||
const unselect = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
};
|
||||
const download = () => {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
$('#download-btn').attr('hidden', '');
|
||||
$('#download-spinner').removeAttr('hidden');
|
||||
const ids = selected.map((i, e) => {
|
||||
return $(e).find('td').first().text();
|
||||
}).get();
|
||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||
console.log(ids);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/mangadex/download',
|
||||
data: JSON.stringify({
|
||||
chapters: chapters
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
const downloadComponent = () => {
|
||||
return {
|
||||
chaptersLimit: 1000,
|
||||
loading: false,
|
||||
addingToDownload: false,
|
||||
searchAvailable: false,
|
||||
searchInput: '',
|
||||
data: {},
|
||||
chapters: [],
|
||||
mangaAry: undefined, // undefined: not searching; []: searched but no result
|
||||
candidateManga: {},
|
||||
langChoice: 'All',
|
||||
groupChoice: 'All',
|
||||
chapterRange: '',
|
||||
volumeRange: '',
|
||||
|
||||
get languages() {
|
||||
const set = new Set();
|
||||
if (this.data.chapters) {
|
||||
this.data.chapters.forEach(chp => {
|
||||
set.add(chp.language);
|
||||
});
|
||||
}
|
||||
const ary = [...set].sort();
|
||||
ary.unshift('All');
|
||||
return ary;
|
||||
},
|
||||
|
||||
get groups() {
|
||||
const set = new Set();
|
||||
if (this.data.chapters) {
|
||||
this.data.chapters.forEach(chp => {
|
||||
Object.keys(chp.groups).forEach(g => {
|
||||
set.add(g);
|
||||
});
|
||||
});
|
||||
}
|
||||
const ary = [...set].sort();
|
||||
ary.unshift('All');
|
||||
return ary;
|
||||
},
|
||||
|
||||
init() {
|
||||
const tableObserver = new MutationObserver(() => {
|
||||
console.log('table mutated');
|
||||
$("#selectable").selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
});
|
||||
tableObserver.observe($('table').get(0), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
|
||||
this.searchAvailable = true;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
},
|
||||
|
||||
filtersUpdated() {
|
||||
if (!this.data.chapters)
|
||||
this.chapters = [];
|
||||
const filters = {
|
||||
chapter: this.parseRange(this.chapterRange),
|
||||
volume: this.parseRange(this.volumeRange),
|
||||
lang: this.langChoice,
|
||||
group: this.groupChoice
|
||||
};
|
||||
console.log('filters:', filters);
|
||||
let _chapters = this.data.chapters.slice();
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v === 'All') return;
|
||||
if (k === 'group') {
|
||||
_chapters = _chapters.filter(c => {
|
||||
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
|
||||
return unescaped_groups.indexOf(v) >= 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
if (k === 'lang') {
|
||||
_chapters = _chapters.filter(c => c.language === v);
|
||||
return;
|
||||
}
|
||||
const lb = parseFloat(v[0]);
|
||||
const ub = parseFloat(v[1]);
|
||||
if (isNaN(lb) && isNaN(ub)) return;
|
||||
_chapters = _chapters.filter(c => {
|
||||
const val = parseFloat(c[k]);
|
||||
if (isNaN(val)) return false;
|
||||
if (isNaN(lb))
|
||||
return val <= ub;
|
||||
else if (isNaN(ub))
|
||||
return val >= lb;
|
||||
else
|
||||
return val >= lb && val <= ub;
|
||||
});
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
});
|
||||
});
|
||||
};
|
||||
const toggleSpinner = () => {
|
||||
var attr = $('#spinner').attr('hidden');
|
||||
if (attr) {
|
||||
$('#spinner').removeAttr('hidden');
|
||||
$('#search-btn').attr('hidden', '');
|
||||
} else {
|
||||
$('#search-btn').removeAttr('hidden');
|
||||
$('#spinner').attr('hidden', '');
|
||||
}
|
||||
searching = !searching;
|
||||
};
|
||||
var searching = false;
|
||||
var globalChapters;
|
||||
const search = () => {
|
||||
if (searching) {
|
||||
return;
|
||||
}
|
||||
$('#manga-details').attr('hidden', '');
|
||||
$('#filter-form').attr('hidden', '');
|
||||
$('table').attr('hidden', '');
|
||||
$('#selection-controls').attr('hidden', '');
|
||||
$('#filter-notification').attr('hidden', '');
|
||||
toggleSpinner();
|
||||
const input = $('input').val();
|
||||
console.log('filtered chapters:', _chapters);
|
||||
this.chapters = _chapters;
|
||||
},
|
||||
|
||||
if (input === "") {
|
||||
toggleSpinner();
|
||||
return;
|
||||
}
|
||||
search() {
|
||||
if (this.loading || this.searchInput === '') return;
|
||||
this.data = {};
|
||||
this.mangaAry = undefined;
|
||||
|
||||
var int_id = -1;
|
||||
|
||||
try {
|
||||
const path = new URL(input).pathname;
|
||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||
int_id = parseInt(match[1]);
|
||||
} catch (e) {
|
||||
int_id = parseInt(input);
|
||||
}
|
||||
|
||||
if (int_id <= 0 || isNaN(int_id)) {
|
||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
||||
toggleSpinner();
|
||||
return;
|
||||
}
|
||||
|
||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||
return;
|
||||
var int_id = -1;
|
||||
try {
|
||||
const path = new URL(this.searchInput).pathname;
|
||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||
int_id = parseInt(match[1]);
|
||||
} catch (e) {
|
||||
int_id = parseInt(this.searchInput);
|
||||
}
|
||||
|
||||
const cover = baseURL + data.cover_url;
|
||||
$('#cover').attr("src", cover);
|
||||
$('#title').text("Title: " + data.title);
|
||||
$('#artist').text("Artist: " + data.artist);
|
||||
$('#author').text("Author: " + data.author);
|
||||
if (!isNaN(int_id) && int_id > 0) {
|
||||
// The input is a positive integer. We treat it as an ID.
|
||||
this.loading = true;
|
||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
$('#manga-details').removeAttr('hidden');
|
||||
this.data = data;
|
||||
this.chapters = data.chapters;
|
||||
this.mangaAry = undefined;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
} else {
|
||||
if (!this.searchAvailable) {
|
||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(data.chapters);
|
||||
globalChapters = data.chapters;
|
||||
// Search as a search term
|
||||
this.loading = true;
|
||||
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
|
||||
query: this.searchInput
|
||||
})}`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let langs = new Set();
|
||||
let group_names = new Set();
|
||||
data.chapters.forEach(chp => {
|
||||
Object.entries(chp.groups).forEach(([k, v]) => {
|
||||
group_names.add(k);
|
||||
});
|
||||
langs.add(chp.language);
|
||||
this.mangaAry = data.manga;
|
||||
this.data = {};
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
parseRange(str) {
|
||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||
const matches = str.match(regex);
|
||||
var num;
|
||||
|
||||
if (!matches) {
|
||||
return [null, null];
|
||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||
// e.g., <= 30
|
||||
num = parseInt(matches[2]);
|
||||
if (isNaN(num)) {
|
||||
return [null, null];
|
||||
}
|
||||
switch (matches[1]) {
|
||||
case '<':
|
||||
return [null, num - 1];
|
||||
case '<=':
|
||||
return [null, num];
|
||||
case '>':
|
||||
return [num + 1, null];
|
||||
case '>=':
|
||||
return [num, null];
|
||||
}
|
||||
} else if (typeof matches[3] !== 'undefined') {
|
||||
// a single number
|
||||
num = parseInt(matches[3]);
|
||||
if (isNaN(num)) {
|
||||
return [null, null];
|
||||
}
|
||||
return [num, num];
|
||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||
// e.g., 10 - 23
|
||||
num = parseInt(matches[4]);
|
||||
const n2 = parseInt(matches[5]);
|
||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||
return [null, null];
|
||||
}
|
||||
return [num, n2];
|
||||
} else {
|
||||
// empty or space only
|
||||
return [null, null];
|
||||
}
|
||||
},
|
||||
|
||||
unescapeHTML(str) {
|
||||
var elt = document.createElement("span");
|
||||
elt.innerHTML = str;
|
||||
return elt.innerText;
|
||||
},
|
||||
|
||||
selectAll() {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
},
|
||||
|
||||
const comp = (a, b) => {
|
||||
var ai;
|
||||
var bi;
|
||||
try {
|
||||
ai = parseFloat(a);
|
||||
} catch (e) {}
|
||||
try {
|
||||
bi = parseFloat(b);
|
||||
} catch (e) {}
|
||||
if (typeof ai === 'undefined') return -1;
|
||||
if (typeof bi === 'undefined') return 1;
|
||||
if (ai < bi) return 1;
|
||||
if (ai > bi) return -1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
langs = [...langs].sort();
|
||||
group_names = [...group_names].sort();
|
||||
|
||||
langs.unshift('All');
|
||||
group_names.unshift('All');
|
||||
|
||||
$('select#lang-select').html(langs.map(e => `<option>${e}</option>`).join(''));
|
||||
$('select#group-select').html(group_names.map(e => `<option>${e}</option>`).join(''));
|
||||
|
||||
$('#filter-form').removeAttr('hidden');
|
||||
|
||||
buildTable();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
toggleSpinner();
|
||||
});
|
||||
};
|
||||
const parseRange = str => {
|
||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||
const matches = str.match(regex);
|
||||
var num;
|
||||
|
||||
if (!matches) {
|
||||
alert('danger', `Failed to parse filter input ${str}`);
|
||||
return [null, null];
|
||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||
// e.g., <= 30
|
||||
num = parseInt(matches[2]);
|
||||
if (isNaN(num)) {
|
||||
alert('danger', `Failed to parse filter input ${str}`);
|
||||
return [null, null];
|
||||
}
|
||||
switch (matches[1]) {
|
||||
case '<':
|
||||
return [null, num - 1];
|
||||
case '<=':
|
||||
return [null, num];
|
||||
case '>':
|
||||
return [num + 1, null];
|
||||
case '>=':
|
||||
return [num, null];
|
||||
}
|
||||
} else if (typeof matches[3] !== 'undefined') {
|
||||
// a single number
|
||||
num = parseInt(matches[3]);
|
||||
if (isNaN(num)) {
|
||||
alert('danger', `Failed to parse filter input ${str}`);
|
||||
return [null, null];
|
||||
}
|
||||
return [num, num];
|
||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||
// e.g., 10 - 23
|
||||
num = parseInt(matches[4]);
|
||||
const n2 = parseInt(matches[5]);
|
||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||
alert('danger', `Failed to parse filter input ${str}`);
|
||||
return [null, null];
|
||||
}
|
||||
return [num, n2];
|
||||
} else {
|
||||
// empty or space only
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
const getFilters = () => {
|
||||
const filters = {};
|
||||
$('.uk-select').each((i, ele) => {
|
||||
const id = $(ele).attr('id');
|
||||
const by = id.split('-')[0];
|
||||
const choice = $(ele).val();
|
||||
filters[by] = choice;
|
||||
});
|
||||
filters.volume = parseRange($('#volume-range').val());
|
||||
filters.chapter = parseRange($('#chapter-range').val());
|
||||
return filters;
|
||||
};
|
||||
const buildTable = () => {
|
||||
$('table').attr('hidden', '');
|
||||
$('#selection-controls').attr('hidden', '');
|
||||
$('#filter-notification').attr('hidden', '');
|
||||
console.log('rebuilding table');
|
||||
const filters = getFilters();
|
||||
console.log('filters:', filters);
|
||||
var chapters = globalChapters.slice();
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v === 'All') return;
|
||||
if (k === 'group') {
|
||||
chapters = chapters.filter(c => {
|
||||
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
||||
return unescaped_groups.indexOf(v) >= 0;
|
||||
clearSelection() {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
return;
|
||||
},
|
||||
|
||||
download() {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
const ids = selected.map((i, e) => {
|
||||
return parseInt($(e).find('td').first().text());
|
||||
}).get();
|
||||
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||
console.log(ids);
|
||||
this.addingToDownload = true;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `${base_url}api/admin/mangadex/download`,
|
||||
data: JSON.stringify({
|
||||
chapters: chapters
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.addingToDownload = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
chooseManga(manga) {
|
||||
this.candidateManga = manga;
|
||||
UIkit.modal($('#modal').get(0)).show();
|
||||
},
|
||||
|
||||
confirmManga(id) {
|
||||
UIkit.modal($('#modal').get(0)).hide();
|
||||
this.searchInput = id;
|
||||
this.search();
|
||||
}
|
||||
if (k === 'lang') {
|
||||
chapters = chapters.filter(c => c.language === v);
|
||||
return;
|
||||
}
|
||||
const lb = parseFloat(v[0]);
|
||||
const ub = parseFloat(v[1]);
|
||||
if (isNaN(lb) && isNaN(ub)) return;
|
||||
chapters = chapters.filter(c => {
|
||||
const val = parseFloat(c[k]);
|
||||
if (isNaN(val)) return false;
|
||||
if (isNaN(lb))
|
||||
return val <= ub;
|
||||
else if (isNaN(ub))
|
||||
return val >= lb;
|
||||
else
|
||||
return val >= lb && val <= ub;
|
||||
});
|
||||
});
|
||||
console.log('filtered chapters:', chapters);
|
||||
$('#count-text').text(`${chapters.length} chapters found`);
|
||||
|
||||
const chaptersLimit = 1000;
|
||||
if (chapters.length > chaptersLimit) {
|
||||
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
||||
$('#filter-notification').removeAttr('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const inner = chapters.map(chp => {
|
||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||
}).join(' | ');
|
||||
return `<tr class="ui-widget-content">
|
||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||
<td>${chp.title}</td>
|
||||
<td>${chp.language}</td>
|
||||
<td>${group_str}</td>
|
||||
<td>${chp.volume}</td>
|
||||
<td>${chp.chapter}</td>
|
||||
<td>${moment.unix(chp.time).fromNow()}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
||||
$('tbody').remove();
|
||||
$('table').append(tbody);
|
||||
$('table').removeAttr('hidden');
|
||||
$("#selectable").selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
$('#selection-controls').removeAttr('hidden');
|
||||
};
|
||||
|
||||
const unescapeHTML = (str) => {
|
||||
var elt = document.createElement("span");
|
||||
elt.innerHTML = str;
|
||||
return elt.innerText;
|
||||
};
|
||||
};
|
||||
|
||||
61
public/js/mangadex.js
Normal file
61
public/js/mangadex.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const component = () => {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
expires: undefined,
|
||||
loading: true,
|
||||
loggingIn: false,
|
||||
|
||||
init() {
|
||||
this.loading = true;
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `${base_url}api/admin/mangadex/expires`,
|
||||
contentType: "application/json",
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
this.expires = data.expires;
|
||||
this.loading = false;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
},
|
||||
login() {
|
||||
if (!(this.username && this.password)) return;
|
||||
this.loggingIn = true;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `${base_url}api/admin/mangadex/login`,
|
||||
contentType: "application/json",
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password
|
||||
})
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to log in. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
this.expires = data.expires;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.loggingIn = false;
|
||||
});
|
||||
},
|
||||
get expired() {
|
||||
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,141 +1,326 @@
|
||||
const loadPlugin = id => {
|
||||
localStorage.setItem('plugin', id);
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||
const newURL = `${url}?${$.param({
|
||||
plugin: id
|
||||
})}`;
|
||||
window.location.href = newURL;
|
||||
};
|
||||
const component = () => {
|
||||
return {
|
||||
plugins: [],
|
||||
info: undefined,
|
||||
pid: undefined,
|
||||
chapters: undefined, // undefined: not searched yet, []: empty
|
||||
manga: undefined, // undefined: not searched yet, []: empty
|
||||
allChapters: [],
|
||||
query: '',
|
||||
mangaTitle: '',
|
||||
searching: false,
|
||||
adding: false,
|
||||
sortOptions: [],
|
||||
showFilters: false,
|
||||
appliedFilters: [],
|
||||
chaptersLimit: 500,
|
||||
listManga: false,
|
||||
|
||||
$(() => {
|
||||
var storedID = localStorage.getItem('plugin');
|
||||
if (storedID && storedID !== pid) {
|
||||
loadPlugin(storedID);
|
||||
} else {
|
||||
$('#controls').removeAttr('hidden');
|
||||
}
|
||||
init() {
|
||||
const tableObserver = new MutationObserver(() => {
|
||||
console.log('table mutated');
|
||||
$('#selectable').selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
});
|
||||
tableObserver.observe($('table').get(0), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
fetch(`${base_url}api/admin/plugin`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success)
|
||||
throw new Error(data.error);
|
||||
this.plugins = data.plugins;
|
||||
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('#plugin-select').val(pid);
|
||||
$('#plugin-select').change(() => {
|
||||
const id = $('#plugin-select').val();
|
||||
loadPlugin(id);
|
||||
});
|
||||
});
|
||||
const pid = localStorage.getItem('plugin');
|
||||
if (pid && this.plugins.map(p => p.id).includes(pid))
|
||||
return this.loadPlugin(pid);
|
||||
|
||||
let mangaTitle = "";
|
||||
let searching = false;
|
||||
const search = () => {
|
||||
if (searching)
|
||||
return;
|
||||
if (this.plugins.length > 0)
|
||||
this.loadPlugin(this.plugins[0].id);
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to list the available plugins. Error: ${e}`);
|
||||
});
|
||||
},
|
||||
loadPlugin(pid) {
|
||||
fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
||||
plugin: pid
|
||||
})}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success)
|
||||
throw new Error(data.error);
|
||||
this.info = data.info;
|
||||
this.pid = pid;
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to get plugin metadata. Error: ${e}`);
|
||||
});
|
||||
},
|
||||
pluginChanged() {
|
||||
this.loadPlugin(this.pid);
|
||||
localStorage.setItem('plugin', this.pid);
|
||||
},
|
||||
get chapterKeys() {
|
||||
if (this.allChapters.length < 1) return [];
|
||||
return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k));
|
||||
},
|
||||
searchChapters(query) {
|
||||
this.searching = true;
|
||||
this.allChapters = [];
|
||||
this.chapters = undefined;
|
||||
this.listManga = false;
|
||||
fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({
|
||||
plugin: this.pid,
|
||||
query: query
|
||||
})}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success)
|
||||
throw new Error(data.error);
|
||||
try {
|
||||
this.mangaTitle = data.chapters[0].manga_title;
|
||||
if (!this.mangaTitle) throw new Error();
|
||||
} catch (e) {
|
||||
this.mangaTitle = data.title;
|
||||
}
|
||||
|
||||
const query = $.param({
|
||||
query: $('#search-input').val(),
|
||||
plugin: pid
|
||||
});
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Search failed. Error: ${data.error}`);
|
||||
this.allChapters = data.chapters;
|
||||
this.chapters = data.chapters;
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to list chapters. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
searchManga() {
|
||||
this.searching = true;
|
||||
this.allChapters = [];
|
||||
this.chapters = undefined;
|
||||
this.manga = undefined;
|
||||
fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
||||
plugin: this.pid,
|
||||
query: this.query
|
||||
})}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success)
|
||||
throw new Error(data.error);
|
||||
this.manga = data.manga;
|
||||
this.listManga = true;
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Search failed. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
search() {
|
||||
this.manga = undefined;
|
||||
if (this.info.version === 1) {
|
||||
this.searchChapters(this.query);
|
||||
} else {
|
||||
this.searchManga();
|
||||
}
|
||||
},
|
||||
selectAll() {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
},
|
||||
clearSelection() {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
},
|
||||
download() {
|
||||
const selected = $('tbody > tr.ui-selected').get();
|
||||
if (selected.length === 0) return;
|
||||
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
const ids = selected.map(e => e.id);
|
||||
const chapters = this.chapters.filter(c => ids.includes(c.id));
|
||||
console.log(chapters);
|
||||
this.adding = true;
|
||||
fetch(`${base_url}api/admin/plugin/download`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chapters,
|
||||
plugin: this.pid,
|
||||
title: this.mangaTitle
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success)
|
||||
throw new Error(data.error);
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.adding = false;
|
||||
});
|
||||
})
|
||||
},
|
||||
thClicked(event) {
|
||||
const idx = parseInt(event.currentTarget.id.split('-')[1]);
|
||||
if (idx === undefined || isNaN(idx)) return;
|
||||
const curOption = this.sortOptions[idx];
|
||||
let option;
|
||||
this.sortOptions = [];
|
||||
switch (curOption) {
|
||||
case 1:
|
||||
option = -1;
|
||||
break;
|
||||
case -1:
|
||||
option = 0;
|
||||
break;
|
||||
default:
|
||||
option = 1;
|
||||
}
|
||||
this.sortOptions[idx] = option;
|
||||
this.sort(this.chapterKeys[idx], option)
|
||||
},
|
||||
// Returns an array of filtered but unsorted chapters. Useful when
|
||||
// reseting the sort options.
|
||||
get filteredChapters() {
|
||||
let ary = this.allChapters.slice();
|
||||
|
||||
console.log('initial size:', ary.length);
|
||||
for (let filter of this.appliedFilters) {
|
||||
if (!filter.value) continue;
|
||||
if (filter.type === 'array' && filter.value === 'all') continue;
|
||||
|
||||
console.log('applying filter:', filter);
|
||||
|
||||
if (filter.type === 'string') {
|
||||
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
|
||||
}
|
||||
if (filter.type === 'number-min') {
|
||||
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
|
||||
}
|
||||
if (filter.type === 'number-max') {
|
||||
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
|
||||
}
|
||||
if (filter.type === 'date-min') {
|
||||
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
|
||||
}
|
||||
if (filter.type === 'date-max') {
|
||||
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
|
||||
}
|
||||
if (filter.type === 'array') {
|
||||
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
|
||||
}
|
||||
|
||||
console.log('filtered size:', ary.length);
|
||||
}
|
||||
|
||||
return ary;
|
||||
},
|
||||
// option:
|
||||
// - 1: asending
|
||||
// - -1: desending
|
||||
// - 0: unsorted
|
||||
sort(key, option) {
|
||||
if (option === 0) {
|
||||
this.chapters = this.filteredChapters;
|
||||
return;
|
||||
}
|
||||
mangaTitle = data.title;
|
||||
$('#title-text').text(data.title);
|
||||
buildTable(data.chapters);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {});
|
||||
};
|
||||
|
||||
const buildTable = (chapters) => {
|
||||
$('#table').attr('hidden', '');
|
||||
$('table').empty();
|
||||
|
||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||
$('table').append(thead);
|
||||
|
||||
const rows = chapters.map(ch => {
|
||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||
});
|
||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||
$('table').append(tbody);
|
||||
|
||||
$('#selectable').selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
|
||||
$('#table table').tablesorter();
|
||||
$('#table').removeAttr('hidden');
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const unselect = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
$('#download-btn').attr('hidden', '');
|
||||
$('#download-spinner').removeAttr('hidden');
|
||||
const chapters = selected.map((i, e) => {
|
||||
return {
|
||||
id: $(e).attr('data-id'),
|
||||
title: $(e).attr('data-title')
|
||||
}
|
||||
}).get();
|
||||
console.log(chapters);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/plugin/download',
|
||||
data: JSON.stringify({
|
||||
plugin: pid,
|
||||
chapters: chapters,
|
||||
title: mangaTitle
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
});
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||
const comp = this.compare(a[key], b[key]);
|
||||
return option < 0 ? comp * -1 : comp;
|
||||
});
|
||||
});
|
||||
},
|
||||
compare(a, b) {
|
||||
if (a === b) return 0;
|
||||
|
||||
// try numbers
|
||||
// this must come before the date checks, because any integer would
|
||||
// also be parsed as a date.
|
||||
if (!isNaN(a) && !isNaN(b))
|
||||
return Number(a) - Number(b);
|
||||
|
||||
// try dates
|
||||
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
|
||||
return this.parseDate(a) - this.parseDate(b);
|
||||
|
||||
const preprocessString = (val) => {
|
||||
if (typeof val !== 'string') return val;
|
||||
return val.toLowerCase().replace(/\s\s/g, ' ').trim();
|
||||
};
|
||||
|
||||
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||
},
|
||||
fieldType(values) {
|
||||
if (values.every(v => !isNaN(v))) return 'number'; // display input for number range
|
||||
if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range
|
||||
if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains
|
||||
return 'string'; // display input for string searching.
|
||||
// for the last two, if the number of options is small enough (say < 50), display a multi-select2
|
||||
},
|
||||
get filters() {
|
||||
if (this.allChapters.length < 1) return [];
|
||||
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k));
|
||||
return keys.map(k => {
|
||||
let values = this.allChapters.map(c => c[k]);
|
||||
const type = this.fieldType(values);
|
||||
|
||||
if (type === 'array') {
|
||||
// if the type is an array, return the list of available elements
|
||||
// example: an array of groups or authors
|
||||
values = Array.from(new Set(values.flat().map(v => {
|
||||
if (typeof v === 'string') return v.toLowerCase();
|
||||
})));
|
||||
}
|
||||
|
||||
return {
|
||||
key: k,
|
||||
type: type,
|
||||
values: values
|
||||
};
|
||||
});
|
||||
},
|
||||
applyFilters() {
|
||||
const values = $('#filter-form input, #filter-form select')
|
||||
.get()
|
||||
.map(i => ({
|
||||
key: i.getAttribute('data-filter-key'),
|
||||
value: i.value.trim(),
|
||||
type: i.getAttribute('data-filter-type')
|
||||
}));
|
||||
this.appliedFilters = values;
|
||||
this.chapters = this.filteredChapters;
|
||||
},
|
||||
clearFilters() {
|
||||
$('#filter-form input').get().forEach(i => i.value = '');
|
||||
this.appliedFilters = [];
|
||||
this.chapters = this.filteredChapters;
|
||||
},
|
||||
mangaSelected(event) {
|
||||
const mid = event.currentTarget.getAttribute('data-id');
|
||||
this.searchChapters(mid);
|
||||
},
|
||||
parseDate(str) {
|
||||
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
|
||||
// Basic sanity check to make sure it's an actual date.
|
||||
// We need this because Date.parse thinks 'Chapter 1' is a date.
|
||||
if (!regex.test(str))
|
||||
return NaN;
|
||||
return Date.parse(str);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ const readerComponent = () => {
|
||||
flipAnimation: null,
|
||||
longPages: false,
|
||||
lastSavedPage: page,
|
||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||
margin: 30,
|
||||
|
||||
/**
|
||||
* Initialize the component by fetching the page dimensions
|
||||
@@ -26,7 +28,6 @@ const readerComponent = () => {
|
||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,6 +47,11 @@ const readerComponent = () => {
|
||||
const mode = this.mode;
|
||||
this.updateMode(this.mode, page, nextTick);
|
||||
$('#mode-select').val(mode);
|
||||
|
||||
const savedMargin = localStorage.getItem('margin');
|
||||
if (savedMargin) {
|
||||
this.margin = savedMargin;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||
@@ -221,10 +227,7 @@ const readerComponent = () => {
|
||||
*/
|
||||
showControl(event) {
|
||||
const idx = event.currentTarget.id;
|
||||
const pageCount = this.items.length;
|
||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||
$('#progress-label').text(progressText);
|
||||
$('#page-select').val(idx);
|
||||
this.selectedIndex = idx;
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
},
|
||||
/**
|
||||
@@ -263,19 +266,27 @@ const readerComponent = () => {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Exits the reader, and optionally sets the reading progress tp 100%
|
||||
* Exits the reader, and sets the reading progress tp 100%
|
||||
*
|
||||
* @param {string} exitUrl - The Exit URL
|
||||
* @param {boolean} [markCompleted] - Whether we should mark the
|
||||
* reading progress to 100%
|
||||
*/
|
||||
exitReader(exitUrl, markCompleted = false) {
|
||||
if (!markCompleted) {
|
||||
return this.redirect(exitUrl);
|
||||
}
|
||||
exitReader(exitUrl) {
|
||||
this.saveProgress(this.items.length, () => {
|
||||
this.redirect(exitUrl);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles the `change` event for the entry selector
|
||||
*/
|
||||
entryChanged() {
|
||||
const id = $('#entry-select').val();
|
||||
this.redirect(`${base_url}reader/${tid}/${id}`);
|
||||
},
|
||||
|
||||
marginChanged() {
|
||||
localStorage.setItem('margin', this.margin);
|
||||
this.toPage(this.selectedIndex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
82
public/js/subscription.js
Normal file
82
public/js/subscription.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const component = () => {
|
||||
return {
|
||||
available: undefined,
|
||||
subscriptions: [],
|
||||
|
||||
init() {
|
||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
||||
|
||||
if (this.available) this.getSubscriptions();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
},
|
||||
|
||||
getSubscriptions() {
|
||||
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
this.subscriptions = data.subscriptions;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
},
|
||||
|
||||
rm(event) {
|
||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
||||
}
|
||||
this.getSubscriptions();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
},
|
||||
|
||||
check(event) {
|
||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
},
|
||||
|
||||
formatRange(min, max) {
|
||||
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
||||
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
||||
if (isNaN(min) && isNaN(max)) return 'All';
|
||||
|
||||
if (min === max) return `= ${min}`;
|
||||
return `${min} - ${max}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
14
shard.lock
14
shard.lock
@@ -2,7 +2,7 @@ version: 2.0
|
||||
shards:
|
||||
ameba:
|
||||
git: https://github.com/crystal-ameba/ameba.git
|
||||
version: 0.12.1
|
||||
version: 0.14.0
|
||||
|
||||
archive:
|
||||
git: https://github.com/hkalexling/archive.cr.git
|
||||
@@ -30,7 +30,7 @@ shards:
|
||||
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
version: 0.7.1
|
||||
version: 0.8.0
|
||||
|
||||
image_size:
|
||||
git: https://github.com/hkalexling/image_size.cr.git
|
||||
@@ -42,7 +42,7 @@ shards:
|
||||
|
||||
kemal-session:
|
||||
git: https://github.com/kemalcr/kemal-session.git
|
||||
version: 0.12.1
|
||||
version: 0.13.0
|
||||
|
||||
kilt:
|
||||
git: https://github.com/jeromegn/kilt.git
|
||||
@@ -50,11 +50,15 @@ shards:
|
||||
|
||||
koa:
|
||||
git: https://github.com/hkalexling/koa.git
|
||||
version: 0.5.0
|
||||
version: 0.7.0
|
||||
|
||||
mangadex:
|
||||
git: https://github.com/hkalexling/mangadex.git
|
||||
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
|
||||
|
||||
mg:
|
||||
git: https://github.com/hkalexling/mg.git
|
||||
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
||||
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
||||
|
||||
myhtml:
|
||||
git: https://github.com/kostya/myhtml.git
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.20.0
|
||||
version: 0.22.0
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -8,7 +8,7 @@ targets:
|
||||
mango:
|
||||
main: src/mango.cr
|
||||
|
||||
crystal: 0.35.1
|
||||
crystal: 0.36.1
|
||||
|
||||
license: MIT
|
||||
|
||||
@@ -43,3 +43,5 @@ dependencies:
|
||||
github: epoch/tallboy
|
||||
mg:
|
||||
github: hkalexling/mg
|
||||
mangadex:
|
||||
github: hkalexling/mangadex
|
||||
|
||||
@@ -8,9 +8,7 @@ describe Storage do
|
||||
end
|
||||
|
||||
it "deletes user" do
|
||||
with_storage do |storage|
|
||||
storage.delete_user "admin"
|
||||
end
|
||||
with_storage &.delete_user "admin"
|
||||
end
|
||||
|
||||
it "creates new user" do
|
||||
|
||||
@@ -21,7 +21,7 @@ describe "compare_numerically" do
|
||||
it "sorts like the stack exchange post" do
|
||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||
ary.reverse.sort { |a, b|
|
||||
ary.reverse.sort! { |a, b|
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
end
|
||||
@@ -29,7 +29,7 @@ describe "compare_numerically" do
|
||||
# https://github.com/hkalexling/Mango/issues/22
|
||||
it "handles numbers larger than Int32" do
|
||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||
ary.reverse.sort { |a, b|
|
||||
ary.reverse.sort! { |a, b|
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
end
|
||||
@@ -56,7 +56,7 @@ 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|
|
||||
ary.reverse.sort! do |a, b|
|
||||
sorter.compare a, b
|
||||
end.should eq ary
|
||||
end
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
Arabic,sa
|
||||
Bengali,bd
|
||||
Bulgarian,bg
|
||||
Burmese,mm
|
||||
Catalan,ct
|
||||
Chinese (Simp),cn
|
||||
Chinese (Trad),hk
|
||||
Czech,cz
|
||||
Danish,dk
|
||||
Dutch,nl
|
||||
English,gb
|
||||
Filipino,ph
|
||||
Finnish,fi
|
||||
French,fr
|
||||
German,de
|
||||
Greek,gr
|
||||
Hebrew,il
|
||||
Hindi,in
|
||||
Hungarian,hu
|
||||
Indonesian,id
|
||||
Italian,it
|
||||
Japanese,jp
|
||||
Korean,kr
|
||||
Lithuanian,lt
|
||||
Malay,my
|
||||
Mongolian,mn
|
||||
Other,
|
||||
Persian,ir
|
||||
Polish,pl
|
||||
Portuguese (Br),br
|
||||
Portuguese (Pt),pt
|
||||
Romanian,ro
|
||||
Russian,ru
|
||||
Serbo-Croatian,rs
|
||||
Spanish (Es),es
|
||||
Spanish (LATAM),mx
|
||||
Swedish,se
|
||||
Thai,th
|
||||
Turkish,tr
|
||||
Ukrainian,ua
|
||||
Vietnames,vn
|
||||
|
@@ -5,6 +5,7 @@ class Config
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
property path : String = ""
|
||||
property host : String = "0.0.0.0"
|
||||
property port : Int32 = 9000
|
||||
property base_url : String = "/"
|
||||
property session_secret : String = "mango-session-secret"
|
||||
@@ -19,15 +20,15 @@ class Config
|
||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||
home: true
|
||||
property download_timeout_seconds : Int32 = 30
|
||||
property page_margin : Int32 = 30
|
||||
property disable_login = false
|
||||
property default_username = ""
|
||||
property auth_proxy_header_name = ""
|
||||
property mangadex = Hash(String, String | Int32).new
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@mangadex_defaults = {
|
||||
"base_url" => "https://mangadex.org",
|
||||
"api_url" => "https://mangadex.org/api",
|
||||
"api_url" => "https://api.mangadex.org/v2",
|
||||
"download_wait_seconds" => 5,
|
||||
"download_retries" => 4,
|
||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||
@@ -51,9 +52,9 @@ class Config
|
||||
cfg_path = File.expand_path path, home: true
|
||||
if File.exists? cfg_path
|
||||
config = self.from_yaml File.read cfg_path
|
||||
config.preprocess
|
||||
config.path = path
|
||||
config.fill_defaults
|
||||
config.preprocess
|
||||
return config
|
||||
end
|
||||
puts "The config file #{cfg_path} does not exist. " \
|
||||
@@ -91,5 +92,24 @@ class Config
|
||||
raise "Login is disabled, but default username is not set. " \
|
||||
"Please set a default username"
|
||||
end
|
||||
|
||||
# `Logger.default` is not available yet
|
||||
Log.setup :debug
|
||||
unless mangadex["api_url"] =~ /\/v2/
|
||||
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
||||
"v1 in your config file. Please update it to " \
|
||||
"https://api.mangadex.org/v2 to suppress this warning." }
|
||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
||||
end
|
||||
if mangadex["api_url"] =~ /\/api\/v2/
|
||||
Log.warn { "It looks like you are using the outdated MangaDex API " \
|
||||
"url (mangadex.org/api/v2) in your config file. Please " \
|
||||
"update it to https://api.mangadex.org/v2 to suppress this " \
|
||||
"warning." }
|
||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
||||
end
|
||||
|
||||
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
||||
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,11 @@ class AuthHandler < Kemal::Handler
|
||||
env.response.status_code = 401
|
||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||
env.response.print AUTH_MESSAGE
|
||||
call_next env
|
||||
end
|
||||
|
||||
def require_auth(env)
|
||||
env.session.string "callback", env.request.path
|
||||
redirect env, "/login"
|
||||
end
|
||||
|
||||
def validate_token(env)
|
||||
@@ -49,55 +53,50 @@ class AuthHandler < Kemal::Handler
|
||||
Storage.default.verify_user username, password
|
||||
end
|
||||
|
||||
def handle_opds_auth(env)
|
||||
if validate_token(env) || validate_auth_header(env)
|
||||
call_next env
|
||||
else
|
||||
env.response.status_code = 401
|
||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||
env.response.print AUTH_MESSAGE
|
||||
end
|
||||
end
|
||||
|
||||
def handle_auth(env)
|
||||
def call(env)
|
||||
# Skip all authentication if requesting /login, /logout, or a static file
|
||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
||||
requesting_static_file env
|
||||
return call_next(env)
|
||||
end
|
||||
|
||||
unless validate_token(env) || Config.current.disable_login
|
||||
env.session.string "callback", env.request.path
|
||||
return redirect env, "/login"
|
||||
# Check user is logged in
|
||||
if validate_token env
|
||||
# Skip if the request has a valid token
|
||||
elsif Config.current.disable_login
|
||||
# Check default username if login is disabled
|
||||
unless Storage.default.username_exists Config.current.default_username
|
||||
Logger.warn "Default username #{Config.current.default_username} " \
|
||||
"does not exist"
|
||||
return require_auth env
|
||||
end
|
||||
elsif !Config.current.auth_proxy_header_name.empty?
|
||||
# Check auth proxy if present
|
||||
username = env.request.headers[Config.current.auth_proxy_header_name]?
|
||||
unless username && Storage.default.username_exists username
|
||||
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
|
||||
"or is not a valid username"
|
||||
return require_auth env
|
||||
end
|
||||
elsif request_path_startswith env, ["/opds"]
|
||||
# Check auth header if requesting an opds page
|
||||
unless validate_auth_header env
|
||||
return require_basic_auth env
|
||||
end
|
||||
else
|
||||
return require_auth env
|
||||
end
|
||||
|
||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||
# The token (if exists) takes precedence over the default user option.
|
||||
# this is why we check the default username first before checking the
|
||||
# token.
|
||||
should_reject = true
|
||||
if Config.current.disable_login &&
|
||||
Storage.default.username_is_admin Config.current.default_username
|
||||
should_reject = false
|
||||
end
|
||||
if env.session.string? "token"
|
||||
should_reject = !validate_token_admin(env)
|
||||
end
|
||||
if should_reject
|
||||
# Check admin access when requesting an admin page
|
||||
if request_path_startswith env, %w(/admin /api/admin /download)
|
||||
unless is_admin? env
|
||||
env.response.status_code = 403
|
||||
send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
return
|
||||
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
end
|
||||
end
|
||||
|
||||
# Let the request go through if it passes the above checks
|
||||
call_next env
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if request_path_startswith env, ["/opds"]
|
||||
handle_opds_auth env
|
||||
else
|
||||
handle_auth env
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,6 +46,19 @@ class Entry
|
||||
file.close
|
||||
end
|
||||
|
||||
def to_slim_json : String
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "title_id", @book.id
|
||||
json.field "title_title", @book.title
|
||||
json.field "pages" { json.number @pages }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
@@ -86,7 +99,7 @@ class Entry
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
.sort! { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
yield file, entries
|
||||
@@ -134,10 +147,11 @@ class Entry
|
||||
entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
def previous_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
|
||||
@@ -63,7 +63,22 @@ class Library
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
titles + titles.flat_map &.deep_titles
|
||||
end
|
||||
|
||||
def to_slim_json : String
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
json.raw title.to_slim_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
@@ -98,7 +113,7 @@ class Library
|
||||
.select { |path| File.directory? path }
|
||||
.map { |path| Title.new path, "" }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.sort! { |a, b| a.title <=> b.title }
|
||||
.tap { |_| @title_ids.clear }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@@ -114,14 +129,14 @@ class Library
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
.map(&.get_last_read_entry username)
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||
.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
|
||||
pe = e.previous_entry username
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
@@ -150,14 +165,14 @@ class Library
|
||||
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] }
|
||||
titles.flat_map(&.deep_entries_with_date_added)
|
||||
.select(&.[: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].book.id == last[:entry].book.id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
(e[:date_added] - last_date_added.not_nil!).abs < 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)
|
||||
@@ -188,9 +203,9 @@ class Library
|
||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||
# when the user hasn't started `Vol. 1` yet
|
||||
titles
|
||||
.select { |t| t.load_percentage(username) == 0 }
|
||||
.select(&.load_percentage(username).== 0)
|
||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||
.shuffle
|
||||
.shuffle!
|
||||
end
|
||||
|
||||
def thumbnail_generation_progress
|
||||
@@ -205,7 +220,7 @@ class Library
|
||||
end
|
||||
|
||||
Logger.info "Starting thumbnail generation"
|
||||
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
||||
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||
@entries_count = entries.size
|
||||
@thumbnails_count = 0
|
||||
|
||||
|
||||
@@ -44,19 +44,54 @@ class Title
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
mtimes += @entries.map &.mtime
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically Library.default.title_hash[a].title,
|
||||
Library.default.title_hash[b].title
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
sorter = ChapterSorter.new @entries.map &.title
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
def to_slim_json : String
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "signature" { json.number @signature }
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
json.raw title.to_slim_json
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "entries" do
|
||||
json.array do
|
||||
@entries.each do |entry|
|
||||
json.raw entry.to_slim_json
|
||||
end
|
||||
end
|
||||
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
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
@@ -92,12 +127,12 @@ class Title
|
||||
# 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
|
||||
@entries + titles.flat_map &.deep_entries
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
titles + titles.flat_map &.deep_titles
|
||||
end
|
||||
|
||||
def parents
|
||||
@@ -138,7 +173,7 @@ class Title
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
@entries.find &.id.== eid
|
||||
end
|
||||
|
||||
def display_name
|
||||
@@ -217,29 +252,23 @@ class Title
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
titles.each &.read_all username
|
||||
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
|
||||
@entries.each &.save_progress(username, 0)
|
||||
titles.each &.unread_all username
|
||||
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
|
||||
titles.flat_map(&.deep_read_page_count username).sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
entries.sum(&.pages) +
|
||||
titles.flat_map(&.deep_total_page_count).sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
@@ -311,13 +340,13 @@ class Title
|
||||
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] }
|
||||
.map &.[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 }
|
||||
sorter = ChapterSorter.new @entries.map &.title
|
||||
ary = @entries.sort do |a, b|
|
||||
sorter.compare(a.title, b.title).or \
|
||||
compare_numerically a.title, b.title
|
||||
@@ -383,13 +412,13 @@ class Title
|
||||
{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
|
||||
zip + titles.flat_map &.deep_entries_with_date_added
|
||||
end
|
||||
|
||||
def bulk_progress(action, ids : Array(String), username)
|
||||
selected_entries = ids
|
||||
.map { |id|
|
||||
@entries.find { |e| e.id == id }
|
||||
@entries.find &.id.==(id)
|
||||
}
|
||||
.select(Entry)
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
require "json"
|
||||
require "csv"
|
||||
require "../rename"
|
||||
|
||||
macro string_properties(names)
|
||||
{% for name in names %}
|
||||
property {{name.id}} = ""
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro parse_strings_from_json(names)
|
||||
{% for name in names %}
|
||||
@{{name.id}} = obj[{{name}}].as_s
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro properties_to_hash(names)
|
||||
{
|
||||
{% for name in names %}
|
||||
"{{name.id}}" => @{{name.id}}.to_s,
|
||||
{% end %}
|
||||
}
|
||||
end
|
||||
|
||||
module MangaDex
|
||||
class Chapter
|
||||
string_properties ["lang_code", "title", "volume", "chapter"]
|
||||
property manga : Manga
|
||||
property time = Time.local
|
||||
property id : String
|
||||
property full_title = ""
|
||||
property language = ""
|
||||
property pages = [] of {String, String} # filename, url
|
||||
property groups = [] of {Int32, String} # group_id, group_name
|
||||
|
||||
def initialize(@id, json_obj : JSON::Any, @manga,
|
||||
lang : Hash(String, String))
|
||||
self.parse_json json_obj, lang
|
||||
end
|
||||
|
||||
def to_info_json
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for name in ["id", "title", "volume", "chapter",
|
||||
"language", "full_title"] %}
|
||||
json.field {{name}}, @{{name.id}}
|
||||
{% end %}
|
||||
json.field "time", @time.to_unix.to_s
|
||||
json.field "manga_title", @manga.title
|
||||
json.field "manga_id", @manga.id
|
||||
json.field "groups" do
|
||||
json.object do
|
||||
@groups.each do |gid, gname|
|
||||
json.field gname, gid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_json(obj, lang)
|
||||
parse_strings_from_json ["lang_code", "title", "volume",
|
||||
"chapter"]
|
||||
language = lang[@lang_code]?
|
||||
@language = language if language
|
||||
@time = Time.unix obj["timestamp"].as_i
|
||||
suffixes = ["", "_2", "_3"]
|
||||
suffixes.each do |s|
|
||||
gid = obj["group_id#{s}"].as_i
|
||||
next if gid == 0
|
||||
gname = obj["group_name#{s}"].as_s
|
||||
@groups << {gid, gname}
|
||||
end
|
||||
|
||||
rename_rule = Rename::Rule.new \
|
||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
||||
@full_title = rename rename_rule
|
||||
rescue e
|
||||
raise "failed to parse json: #{e}"
|
||||
end
|
||||
|
||||
def rename(rule : Rename::Rule)
|
||||
hash = properties_to_hash ["id", "title", "volume", "chapter",
|
||||
"lang_code", "language", "pages"]
|
||||
hash["groups"] = @groups.map { |g| g[1] }.join ","
|
||||
rule.render hash
|
||||
end
|
||||
end
|
||||
|
||||
class Manga
|
||||
string_properties ["cover_url", "description", "title", "author", "artist"]
|
||||
property chapters = [] of Chapter
|
||||
property id : String
|
||||
|
||||
def initialize(@id, json_obj : JSON::Any)
|
||||
self.parse_json json_obj
|
||||
end
|
||||
|
||||
def to_info_json(with_chapters = true)
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for name in ["id", "title", "description", "author", "artist",
|
||||
"cover_url"] %}
|
||||
json.field {{name}}, @{{name.id}}
|
||||
{% end %}
|
||||
if with_chapters
|
||||
json.field "chapters" do
|
||||
json.array do
|
||||
@chapters.each do |c|
|
||||
json.raw c.to_info_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def parse_json(obj)
|
||||
parse_strings_from_json ["cover_url", "description", "title", "author",
|
||||
"artist"]
|
||||
rescue e
|
||||
raise "failed to parse json: #{e}"
|
||||
end
|
||||
|
||||
def rename(rule : Rename::Rule)
|
||||
rule.render properties_to_hash ["id", "title", "author", "artist"]
|
||||
end
|
||||
end
|
||||
|
||||
class API
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
||||
"https://mangadex.org/api/"
|
||||
@lang = {} of String => String
|
||||
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
||||
@lang[row[1]] = row[0]
|
||||
end
|
||||
end
|
||||
|
||||
def get(url)
|
||||
headers = HTTP::Headers{
|
||||
"User-agent" => "Mangadex.cr",
|
||||
}
|
||||
res = HTTP::Client.get url, headers
|
||||
raise "Failed to get #{url}. [#{res.status_code}] " \
|
||||
"#{res.status_message}" if !res.success?
|
||||
JSON.parse res.body
|
||||
end
|
||||
|
||||
def get_manga(id)
|
||||
obj = self.get File.join @base_url, "manga/#{id}"
|
||||
if obj["status"]? != "OK"
|
||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||
end
|
||||
begin
|
||||
manga = Manga.new id, obj["manga"]
|
||||
obj["chapter"].as_h.map do |k, v|
|
||||
chapter = Chapter.new k, v, manga, @lang
|
||||
manga.chapters << chapter
|
||||
end
|
||||
manga
|
||||
rescue
|
||||
raise "Failed to parse JSON"
|
||||
end
|
||||
end
|
||||
|
||||
def get_chapter(chapter : Chapter)
|
||||
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
||||
if obj["status"]? == "external"
|
||||
raise "This chapter is hosted on an external site " \
|
||||
"#{obj["external"]?}, and Mango does not support " \
|
||||
"external chapters."
|
||||
end
|
||||
if obj["status"]? != "OK"
|
||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||
end
|
||||
begin
|
||||
server = obj["server"].as_s
|
||||
hash = obj["hash"].as_s
|
||||
chapter.pages = obj["page_array"].as_a.map do |fn|
|
||||
{
|
||||
fn.as_s,
|
||||
"#{server}#{hash}/#{fn.as_s}",
|
||||
}
|
||||
end
|
||||
rescue
|
||||
raise "Failed to parse JSON"
|
||||
end
|
||||
end
|
||||
|
||||
def get_chapter(id : String)
|
||||
obj = self.get File.join @base_url, "chapter/#{id}"
|
||||
if obj["status"]? == "external"
|
||||
raise "This chapter is hosted on an external site " \
|
||||
"#{obj["external"]?}, and Mango does not support " \
|
||||
"external chapters."
|
||||
end
|
||||
if obj["status"]? != "OK"
|
||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||
end
|
||||
manga_id = ""
|
||||
begin
|
||||
manga_id = obj["manga_id"].as_i.to_s
|
||||
rescue
|
||||
raise "Failed to parse JSON"
|
||||
end
|
||||
manga = self.get_manga manga_id
|
||||
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
||||
self.get_chapter chapter
|
||||
chapter
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
require "./api"
|
||||
require "mangadex"
|
||||
require "compress/zip"
|
||||
require "../rename"
|
||||
require "./ext"
|
||||
|
||||
module MangaDex
|
||||
class PageJob
|
||||
@@ -21,7 +23,7 @@ module MangaDex
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
@api = API.default
|
||||
@client = Client.from_config
|
||||
super
|
||||
end
|
||||
|
||||
@@ -46,7 +48,10 @@ module MangaDex
|
||||
@downloading = true
|
||||
@queue.set_status Queue::JobStatus::Downloading, job
|
||||
begin
|
||||
chapter = @api.get_chapter(job.id)
|
||||
chapter = @client.chapter job.id
|
||||
# We must put the `.pages` call in a rescue block to handle external
|
||||
# chapters.
|
||||
pages = chapter.pages
|
||||
rescue e
|
||||
Logger.error e
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
@@ -56,7 +61,7 @@ module MangaDex
|
||||
@downloading = false
|
||||
return
|
||||
end
|
||||
@queue.set_pages chapter.pages.size, job
|
||||
@queue.set_pages pages.size, job
|
||||
lib_dir = @library_path
|
||||
rename_rule = Rename::Rule.new \
|
||||
Config.current.mangadex["manga_rename_rule"].to_s
|
||||
@@ -67,14 +72,14 @@ module MangaDex
|
||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
||||
|
||||
# Find the number of digits needed to store the number of pages
|
||||
len = Math.log10(chapter.pages.size).to_i + 1
|
||||
len = Math.log10(pages.size).to_i + 1
|
||||
|
||||
writer = Compress::Zip::Writer.new zip_path
|
||||
# Create a buffered channel. It works as an FIFO queue
|
||||
channel = Channel(PageJob).new chapter.pages.size
|
||||
channel = Channel(PageJob).new pages.size
|
||||
spawn do
|
||||
chapter.pages.each_with_index do |tuple, i|
|
||||
fn, url = tuple
|
||||
pages.each_with_index do |url, i|
|
||||
fn = Path.new(URI.parse(url).path).basename
|
||||
ext = File.extname fn
|
||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||
page_job = PageJob.new url, fn, writer, @retries
|
||||
@@ -97,7 +102,7 @@ module MangaDex
|
||||
|
||||
spawn do
|
||||
page_jobs = [] of PageJob
|
||||
chapter.pages.size.times do
|
||||
pages.size.times do
|
||||
page_job = channel.receive
|
||||
|
||||
break unless @queue.exists? job
|
||||
|
||||
60
src/mangadex/ext.cr
Normal file
60
src/mangadex/ext.cr
Normal file
@@ -0,0 +1,60 @@
|
||||
private macro properties_to_hash(names)
|
||||
{
|
||||
{% for name in names %}
|
||||
"{{name.id}}" => {{name.id}}.to_s,
|
||||
{% end %}
|
||||
}
|
||||
end
|
||||
|
||||
# Monkey-patch the structures in the `mangadex` shard to suit our needs
|
||||
module MangaDex
|
||||
struct Client
|
||||
@@group_cache = {} of String => Group
|
||||
|
||||
def self.from_config : Client
|
||||
self.new base_url: Config.current.mangadex["base_url"].to_s,
|
||||
api_url: Config.current.mangadex["api_url"].to_s
|
||||
end
|
||||
end
|
||||
|
||||
struct Manga
|
||||
def rename(rule : Rename::Rule)
|
||||
rule.render properties_to_hash %w(id title author artist)
|
||||
end
|
||||
|
||||
def to_info_json
|
||||
hash = JSON.parse(to_json).as_h
|
||||
_chapters = chapters.map do |c|
|
||||
JSON.parse c.to_info_json
|
||||
end
|
||||
hash["chapters"] = JSON::Any.new _chapters
|
||||
hash.to_json
|
||||
end
|
||||
end
|
||||
|
||||
struct Chapter
|
||||
def rename(rule : Rename::Rule)
|
||||
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
||||
hash["groups"] = groups.join(",", &.name)
|
||||
rule.render hash
|
||||
end
|
||||
|
||||
def full_title
|
||||
rule = Rename::Rule.new \
|
||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
||||
rename rule
|
||||
end
|
||||
|
||||
def to_info_json
|
||||
hash = JSON.parse(to_json).as_h
|
||||
hash["language"] = JSON::Any.new language
|
||||
_groups = {} of String => JSON::Any
|
||||
groups.each do |g|
|
||||
_groups[g.name] = JSON::Any.new g.id
|
||||
end
|
||||
hash["groups"] = JSON::Any.new _groups
|
||||
hash["full_title"] = JSON::Any.new full_title
|
||||
hash.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ require "option_parser"
|
||||
require "clim"
|
||||
require "tallboy"
|
||||
|
||||
MANGO_VERSION = "0.20.0"
|
||||
MANGO_VERSION = "0.22.0"
|
||||
|
||||
# From http://www.network-science.de/ascii/
|
||||
BANNER = %{
|
||||
|
||||
@@ -2,6 +2,8 @@ require "duktape/runtime"
|
||||
require "myhtml"
|
||||
require "xml"
|
||||
|
||||
require "./subscriptions"
|
||||
|
||||
class Plugin
|
||||
class Error < ::Exception
|
||||
end
|
||||
@@ -16,12 +18,19 @@ class Plugin
|
||||
end
|
||||
|
||||
struct Info
|
||||
include JSON::Serializable
|
||||
|
||||
{% for name in ["id", "title", "placeholder"] %}
|
||||
getter {{name.id}} = ""
|
||||
{% end %}
|
||||
getter wait_seconds : UInt64 = 0
|
||||
getter wait_seconds = 0u64
|
||||
getter version = 0u64
|
||||
getter settings = {} of String => String?
|
||||
getter dir : String
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
@json : JSON::Any
|
||||
|
||||
def initialize(@dir)
|
||||
info_path = File.join @dir, "info.json"
|
||||
|
||||
@@ -37,6 +46,16 @@ class Plugin
|
||||
@{{name.id}} = @json[{{name}}].as_s
|
||||
{% end %}
|
||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
||||
|
||||
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
||||
settings_hash.each do |k, v|
|
||||
unless str_value = v.as_s?
|
||||
raise "The settings object can only contain strings or null"
|
||||
end
|
||||
@settings[k] = str_value
|
||||
end
|
||||
end
|
||||
|
||||
unless @id.alphanumeric_underscore?
|
||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||
@@ -114,10 +133,26 @@ class Plugin
|
||||
@info.not_nil!
|
||||
end
|
||||
|
||||
def subscribe(subscription : Subscription)
|
||||
list = SubscriptionList.new info.dir
|
||||
list << subscription
|
||||
list.save
|
||||
end
|
||||
|
||||
def list_subscriptions
|
||||
SubscriptionList.new(info.dir).ary
|
||||
end
|
||||
|
||||
def unsubscribe(id : String)
|
||||
list = SubscriptionList.new info.dir
|
||||
list.reject &.id.== id
|
||||
list.save
|
||||
end
|
||||
|
||||
def initialize(id : String)
|
||||
Plugin.build_info_ary
|
||||
|
||||
@info = @@info_ary.find { |i| i.id == id }
|
||||
@info = @@info_ary.find &.id.== id
|
||||
if @info.nil?
|
||||
raise Error.new "Plugin with ID #{id} not found"
|
||||
end
|
||||
@@ -138,6 +173,12 @@ class Plugin
|
||||
sbx.push_string path
|
||||
sbx.put_prop_string -2, "storage_path"
|
||||
|
||||
sbx.push_pointer info.dir.as(Void*)
|
||||
path = sbx.require_pointer(-1).as String
|
||||
sbx.pop
|
||||
sbx.push_string path
|
||||
sbx.put_prop_string -2, "info_dir"
|
||||
|
||||
def_helper_functions sbx
|
||||
end
|
||||
|
||||
@@ -152,23 +193,67 @@ class Plugin
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def assert_manga_type(obj : JSON::Any)
|
||||
obj["id"].as_s && obj["title"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Manga type"
|
||||
end
|
||||
|
||||
def assert_chapter_type(obj : JSON::Any)
|
||||
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
||||
obj["manga_title"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Chapter type"
|
||||
end
|
||||
|
||||
def assert_page_type(obj : JSON::Any)
|
||||
obj["url"].as_s && obj["filename"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Page type"
|
||||
end
|
||||
|
||||
def search_manga(query : String)
|
||||
if info.version == 1
|
||||
raise Error.new "Manga searching is only available for plugins targeting API " \
|
||||
"v2 or above"
|
||||
end
|
||||
json = eval_json "searchManga('#{query}')"
|
||||
begin
|
||||
json.as_a.each do |obj|
|
||||
assert_manga_type obj
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def list_chapters(query : String)
|
||||
json = eval_json "listChapters('#{query}')"
|
||||
begin
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
ary = json["chapters"].as_a
|
||||
ary.each do |obj|
|
||||
id = obj["id"]?
|
||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||
|
||||
unless id.to_s.alphanumeric_underscore?
|
||||
raise "The `id` field can only contain alphanumeric characters " \
|
||||
"and underscores"
|
||||
if info.version > 1
|
||||
# Since v2, listChapters returns an array
|
||||
json.as_a.each do |obj|
|
||||
assert_chapter_type obj
|
||||
end
|
||||
else
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
title = obj["title"]?
|
||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||
ary = json["chapters"].as_a
|
||||
ary.each do |obj|
|
||||
id = obj["id"]?
|
||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||
|
||||
unless id.to_s.alphanumeric_underscore?
|
||||
raise "The `id` field can only contain alphanumeric characters " \
|
||||
"and underscores"
|
||||
end
|
||||
|
||||
title = obj["title"]?
|
||||
if title.nil?
|
||||
raise "Field `title` missing from `listChapters` outputs"
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
@@ -179,10 +264,14 @@ class Plugin
|
||||
def select_chapter(id : String)
|
||||
json = eval_json "selectChapter('#{id}')"
|
||||
begin
|
||||
check_fields ["title", "pages"]
|
||||
if info.version > 1
|
||||
assert_chapter_type json
|
||||
else
|
||||
check_fields ["title", "pages"]
|
||||
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
@@ -194,7 +283,19 @@ class Plugin
|
||||
json = eval_json "nextPage()"
|
||||
return if json.size == 0
|
||||
begin
|
||||
check_fields ["filename", "url"]
|
||||
assert_page_type json
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def new_chapters(manga_id : String, after : Int64)
|
||||
json = eval_json "newChapters('#{manga_id}', #{after})"
|
||||
begin
|
||||
json.as_a.each do |obj|
|
||||
assert_chapter_type obj
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
@@ -379,6 +480,27 @@ class Plugin
|
||||
end
|
||||
sbx.put_prop_string -2, "storage"
|
||||
|
||||
if info.version > 1
|
||||
sbx.push_proc 1 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
key = env.require_string 0
|
||||
|
||||
env.get_global_string "info_dir"
|
||||
info_dir = env.require_string -1
|
||||
env.pop
|
||||
info = Info.new info_dir
|
||||
|
||||
if value = info.settings[key]?
|
||||
env.push_string value
|
||||
else
|
||||
env.push_undefined
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "settings"
|
||||
end
|
||||
|
||||
sbx.put_prop_string -2, "mango"
|
||||
end
|
||||
end
|
||||
|
||||
87
src/plugin/subscriptions.cr
Normal file
87
src/plugin/subscriptions.cr
Normal file
@@ -0,0 +1,87 @@
|
||||
require "uuid"
|
||||
|
||||
enum FilterType
|
||||
String
|
||||
NumMin
|
||||
NumMax
|
||||
DateMin
|
||||
DateMax
|
||||
Array
|
||||
|
||||
def self.from_string(str)
|
||||
case str
|
||||
when "string"
|
||||
String
|
||||
when "number-min"
|
||||
NumMin
|
||||
when "number-max"
|
||||
NumMax
|
||||
when "date-min"
|
||||
DateMin
|
||||
when "date-max"
|
||||
DateMax
|
||||
when "array"
|
||||
Array
|
||||
else
|
||||
raise "Unknown filter type with string #{str}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct Filter
|
||||
include JSON::Serializable
|
||||
|
||||
property key : String
|
||||
property value : String | Int32 | Int64 | Float32 | Nil
|
||||
property type : FilterType
|
||||
|
||||
def initialize(@key, @value, @type)
|
||||
end
|
||||
|
||||
def self.from_json(str) : Filter
|
||||
json = JSON.parse str
|
||||
key = json["key"].as_s
|
||||
type = FilterType.from_string json["type"].as_s
|
||||
_value = json["value"]
|
||||
value = _value.as_s? || _value.as_i32? || _value.as_i64? ||
|
||||
_value.as_f32? || nil
|
||||
self.new key, value, type
|
||||
end
|
||||
end
|
||||
|
||||
struct Subscription
|
||||
include JSON::Serializable
|
||||
|
||||
property id : String
|
||||
property plugin_id : String
|
||||
property name : String
|
||||
property created_at : Int64
|
||||
property last_checked : Int64
|
||||
property filters = [] of Filter
|
||||
|
||||
def initialize(@plugin_id, @name)
|
||||
@id = UUID.random.to_s
|
||||
@created_at = Time.utc.to_unix
|
||||
@last_checked = Time.utc.to_unix
|
||||
end
|
||||
end
|
||||
|
||||
struct SubscriptionList
|
||||
@dir : String
|
||||
@path : String
|
||||
|
||||
getter ary = [] of Subscription
|
||||
|
||||
forward_missing_to @ary
|
||||
|
||||
def initialize(@dir)
|
||||
@path = Path[@dir, "subscriptions.json"]
|
||||
if File.exists? @path
|
||||
@ary = Array(Subscription).from_json File.read @path
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
File.write @path, @ary.to_json
|
||||
end
|
||||
end
|
||||
@@ -303,12 +303,12 @@ class Queue
|
||||
end
|
||||
|
||||
def pause
|
||||
@downloaders.each { |d| d.stopped = true }
|
||||
@downloaders.each &.stopped=(true)
|
||||
@paused = true
|
||||
end
|
||||
|
||||
def resume
|
||||
@downloaders.each { |d| d.stopped = false }
|
||||
@downloaders.each &.stopped=(false)
|
||||
@paused = false
|
||||
end
|
||||
|
||||
|
||||
@@ -35,15 +35,15 @@ module Rename
|
||||
|
||||
class Group < Base(Pattern | String)
|
||||
def render(hash : VHash)
|
||||
return "" if @ary.select(&.is_a? Pattern)
|
||||
return "" if @ary.select(Pattern)
|
||||
.any? &.as(Pattern).render(hash).empty?
|
||||
@ary.map do |e|
|
||||
@ary.join do |e|
|
||||
if e.is_a? Pattern
|
||||
e.render hash
|
||||
else
|
||||
e
|
||||
end
|
||||
end.join
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -129,13 +129,13 @@ module Rename
|
||||
end
|
||||
|
||||
def render(hash : VHash)
|
||||
str = @ary.map do |e|
|
||||
str = @ary.join do |e|
|
||||
if e.is_a? String
|
||||
e
|
||||
else
|
||||
e.render hash
|
||||
end
|
||||
end.join.strip
|
||||
end.strip
|
||||
post_process str
|
||||
end
|
||||
|
||||
|
||||
@@ -73,5 +73,9 @@ struct AdminRouter
|
||||
get "/admin/missing" do |env|
|
||||
layout "missing-items"
|
||||
end
|
||||
|
||||
get "/admin/mangadex" do |env|
|
||||
layout "mangadex"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ struct APIRouter
|
||||
macro s(fields)
|
||||
{
|
||||
{% for field in fields %}
|
||||
{{field}} => "string",
|
||||
{{field}} => String,
|
||||
{% end %}
|
||||
}
|
||||
end
|
||||
@@ -33,160 +33,49 @@ struct APIRouter
|
||||
MD
|
||||
|
||||
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
|
||||
Koa.global_tag "admin", desc: <<-MD
|
||||
Koa.define_tag "admin", desc: <<-MD
|
||||
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
|
||||
MD
|
||||
|
||||
Koa.binary "binary", desc: "A binary file"
|
||||
Koa.array "entryAry", "$entry", desc: "An array of entries"
|
||||
Koa.array "titleAry", "$title", desc: "An array of titles"
|
||||
Koa.array "strAry", "string", desc: "An array of strings"
|
||||
Koa.schema "entry", {
|
||||
"pages" => Int32,
|
||||
"mtime" => Int64,
|
||||
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
||||
desc: "An entry in a book"
|
||||
|
||||
entry_schema = {
|
||||
"pages" => "integer",
|
||||
"mtime" => "integer",
|
||||
}.merge s %w(zip_path title size id title_id display_name cover_url)
|
||||
Koa.object "entry", entry_schema, desc: "An entry in a book"
|
||||
|
||||
title_schema = {
|
||||
"mtime" => "integer",
|
||||
"entries" => "$entryAry",
|
||||
"titles" => "$titleAry",
|
||||
"parents" => "$strAry",
|
||||
}.merge s %w(dir title id display_name cover_url)
|
||||
Koa.object "title", title_schema,
|
||||
Koa.schema "title", {
|
||||
"mtime" => Int64,
|
||||
"entries" => ["entry"],
|
||||
"titles" => ["title"],
|
||||
"parents" => [String],
|
||||
}.merge(s %w(dir title id display_name cover_url)),
|
||||
desc: "A manga title (a collection of entries and sub-titles)"
|
||||
|
||||
Koa.object "library", {
|
||||
"dir" => "string",
|
||||
"titles" => "$titleAry",
|
||||
}, desc: "A library containing a list of top-level titles"
|
||||
|
||||
Koa.object "scanResult", {
|
||||
"milliseconds" => "integer",
|
||||
"titles" => "integer",
|
||||
Koa.schema "result", {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
}
|
||||
|
||||
Koa.object "progressResult", {
|
||||
"progress" => "number",
|
||||
}
|
||||
Koa.schema("mdChapter", {
|
||||
"id" => Int64,
|
||||
"group" => {} of String => String,
|
||||
}.merge(s %w(title volume chapter language full_title time
|
||||
manga_title manga_id)),
|
||||
desc: "A MangaDex chapter")
|
||||
|
||||
Koa.object "result", {
|
||||
"success" => "boolean",
|
||||
"error" => "string?",
|
||||
}
|
||||
|
||||
mc_schema = {
|
||||
"groups" => "object",
|
||||
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
|
||||
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
|
||||
|
||||
Koa.array "chapterAry", "$mangadexChapter"
|
||||
|
||||
mm_schema = {
|
||||
"chapers" => "$chapterAry",
|
||||
}.merge s %w(id title description author artist cover_url)
|
||||
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
|
||||
|
||||
Koa.object "chaptersObj", {
|
||||
"chapters" => "$chapterAry",
|
||||
}
|
||||
|
||||
Koa.object "successFailCount", {
|
||||
"success" => "integer",
|
||||
"fail" => "integer",
|
||||
}
|
||||
|
||||
job_schema = {
|
||||
"pages" => "integer",
|
||||
"success_count" => "integer",
|
||||
"fail_count" => "integer",
|
||||
"time" => "integer",
|
||||
}.merge s %w(id manga_id title manga_title status_message status)
|
||||
Koa.object "job", job_schema, desc: "A download job in the queue"
|
||||
|
||||
Koa.array "jobAry", "$job"
|
||||
|
||||
Koa.object "jobs", {
|
||||
"success" => "boolean",
|
||||
"paused" => "boolean",
|
||||
"jobs" => "$jobAry",
|
||||
}
|
||||
|
||||
Koa.object "binaryUpload", {
|
||||
"file" => "$binary",
|
||||
}
|
||||
|
||||
Koa.object "pluginListBody", {
|
||||
"plugin" => "string",
|
||||
"query" => "string",
|
||||
}
|
||||
|
||||
Koa.object "pluginChapter", {
|
||||
"id" => "string",
|
||||
"title" => "string",
|
||||
}
|
||||
|
||||
Koa.array "pluginChapterAry", "$pluginChapter"
|
||||
|
||||
Koa.object "pluginList", {
|
||||
"success" => "boolean",
|
||||
"chapters" => "$pluginChapterAry?",
|
||||
"title" => "string?",
|
||||
"error" => "string?",
|
||||
}
|
||||
|
||||
Koa.object "pluginDownload", {
|
||||
"plugin" => "string",
|
||||
"title" => "string",
|
||||
"chapters" => "$pluginChapterAry",
|
||||
}
|
||||
|
||||
Koa.object "dimension", {
|
||||
"width" => "integer",
|
||||
"height" => "integer",
|
||||
}
|
||||
|
||||
Koa.array "dimensionAry", "$dimension"
|
||||
|
||||
Koa.object "dimensionResult", {
|
||||
"success" => "boolean",
|
||||
"dimensions" => "$dimensionAry?",
|
||||
"margin" => "number",
|
||||
"error" => "string?",
|
||||
}
|
||||
|
||||
Koa.object "ids", {
|
||||
"ids" => "$strAry",
|
||||
}
|
||||
|
||||
Koa.object "tagsResult", {
|
||||
"success" => "boolean",
|
||||
"tags" => "$strAry?",
|
||||
"error" => "string?",
|
||||
}
|
||||
|
||||
Koa.object "missing", {
|
||||
"path" => "string",
|
||||
"id" => "string",
|
||||
"signature" => "string",
|
||||
}
|
||||
|
||||
Koa.array "missingAry", "$missing"
|
||||
|
||||
Koa.object "missingResult", {
|
||||
"success" => "boolean",
|
||||
"error" => "string?",
|
||||
"entries" => "$missingAry?",
|
||||
"titles" => "$missingAry?",
|
||||
}
|
||||
Koa.schema "mdManga", {
|
||||
"id" => Int64,
|
||||
"chapters" => ["mdChapter"],
|
||||
}.merge(s %w(title description author artist cover_url)),
|
||||
desc: "A MangaDex manga"
|
||||
|
||||
Koa.describe "Returns a page in a manga entry"
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.path "eid", desc: "Entry ID"
|
||||
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
|
||||
Koa.response 200, ref: "$binary", media_type: "image/*"
|
||||
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
|
||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||
Koa.response 500, "Page not found or not readable"
|
||||
Koa.tag "reader"
|
||||
get "/api/page/:tid/:eid/:page" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -212,8 +101,9 @@ struct APIRouter
|
||||
Koa.describe "Returns the cover image of a manga entry"
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.path "eid", desc: "Entry ID"
|
||||
Koa.response 200, ref: "$binary", media_type: "image/*"
|
||||
Koa.response 200, schema: Bytes, media_type: "image/*"
|
||||
Koa.response 500, "Page not found or not readable"
|
||||
Koa.tag "library"
|
||||
get "/api/cover/:tid/:eid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -236,17 +126,25 @@ struct APIRouter
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the book with title `tid`"
|
||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||
MD
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.response 200, ref: "$title"
|
||||
Koa.query "slim"
|
||||
Koa.response 200, schema: "title"
|
||||
Koa.response 404, "Title not found"
|
||||
Koa.tag "library"
|
||||
get "/api/book/:tid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
title = Library.default.get_title tid
|
||||
raise "Title ID `#{tid}` not found" if title.nil?
|
||||
|
||||
send_json env, title.to_json
|
||||
if env.params.query["slim"]?
|
||||
send_json env, title.to_slim_json
|
||||
else
|
||||
send_json env, title.to_json
|
||||
end
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 404
|
||||
@@ -254,15 +152,29 @@ struct APIRouter
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the entire library with all titles and entries"
|
||||
Koa.response 200, ref: "$library"
|
||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||
MD
|
||||
Koa.query "slim"
|
||||
Koa.response 200, schema: {
|
||||
"dir" => String,
|
||||
"titles" => ["title"],
|
||||
}
|
||||
Koa.tag "library"
|
||||
get "/api/library" do |env|
|
||||
send_json env, Library.default.to_json
|
||||
if env.params.query["slim"]?
|
||||
send_json env, Library.default.to_slim_json
|
||||
else
|
||||
send_json env, Library.default.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Triggers a library scan"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$scanResult"
|
||||
Koa.tags ["admin", "library"]
|
||||
Koa.response 200, schema: {
|
||||
"milliseconds" => Float64,
|
||||
"titles" => Int32,
|
||||
}
|
||||
post "/api/admin/scan" do |env|
|
||||
start = Time.utc
|
||||
Library.default.scan
|
||||
@@ -274,8 +186,10 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$progressResult"
|
||||
Koa.tags ["admin", "library"]
|
||||
Koa.response 200, schema: {
|
||||
"progress" => Float64,
|
||||
}
|
||||
get "/api/admin/thumbnail_progress" do |env|
|
||||
send_json env, {
|
||||
"progress" => Library.default.thumbnail_generation_progress,
|
||||
@@ -283,7 +197,7 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Triggers a thumbnail generation"
|
||||
Koa.tag "admin"
|
||||
Koa.tags ["admin", "library"]
|
||||
post "/api/admin/generate_thumbnails" do |env|
|
||||
spawn do
|
||||
Library.default.generate_thumbnails
|
||||
@@ -291,8 +205,8 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Deletes a user with `username`"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tags ["admin", "users"]
|
||||
Koa.response 200, schema: "result"
|
||||
delete "/api/admin/user/delete/:username" do |env|
|
||||
begin
|
||||
username = env.params.url["username"]
|
||||
@@ -319,7 +233,8 @@ struct APIRouter
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.query "eid", desc: "Entry ID", required: false
|
||||
Koa.path "page", desc: "The new page number indicating the progress"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tag "progress"
|
||||
put "/api/progress/:tid/:page" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
@@ -350,8 +265,11 @@ struct APIRouter
|
||||
Koa.describe "Updates the reading progress of multiple entries in a title"
|
||||
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.body ref: "$ids", desc: "An array of entry IDs"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.body schema: {
|
||||
"ids" => [String],
|
||||
}, desc: "An array of entry IDs"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tag "progress"
|
||||
put "/api/bulk_progress/:action/:tid" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
@@ -377,11 +295,11 @@ struct APIRouter
|
||||
Koa.describe "Sets the display name of a title or an entry", <<-MD
|
||||
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.tags ["admin", "library"]
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.query "eid", desc: "Entry ID", required: false
|
||||
Koa.path "name", desc: "The new display name"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.response 200, schema: "result"
|
||||
put "/api/admin/display_name/:tid/:name" do |env|
|
||||
begin
|
||||
title = (Library.default.get_title env.params.url["tid"])
|
||||
@@ -408,14 +326,13 @@ struct APIRouter
|
||||
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
||||
On error, returns a JSON that contains the error message in the `error` field.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.tags ["admin", "mangadex"]
|
||||
Koa.path "id", desc: "A MangaDex manga ID"
|
||||
Koa.response 200, ref: "$mangadexManga"
|
||||
Koa.response 200, schema: "mdManga"
|
||||
get "/api/admin/mangadex/manga/:id" do |env|
|
||||
begin
|
||||
id = env.params.url["id"]
|
||||
api = MangaDex::API.default
|
||||
manga = api.get_manga id
|
||||
manga = MangaDex::Client.from_config.manga id
|
||||
send_json env, manga.to_info_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
@@ -426,20 +343,25 @@ struct APIRouter
|
||||
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
|
||||
On error, returns a JSON that contains the error message in the `error` field.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.body ref: "$chaptersObj"
|
||||
Koa.response 200, ref: "$successFailCount"
|
||||
Koa.tags ["admin", "mangadex", "downloader"]
|
||||
Koa.body schema: {
|
||||
"chapters" => ["mdChapter"],
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Int32,
|
||||
"fail" => Int32,
|
||||
}
|
||||
post "/api/admin/mangadex/download" do |env|
|
||||
begin
|
||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||
chapters = env.params.json["chapters"].as(Array).map &.as_h
|
||||
jobs = chapters.map { |chapter|
|
||||
Queue::Job.new(
|
||||
chapter["id"].as_s,
|
||||
chapter["manga_id"].as_s,
|
||||
chapter["id"].as_i64.to_s,
|
||||
chapter["mangaId"].as_i64.to_s,
|
||||
chapter["full_title"].as_s,
|
||||
chapter["manga_title"].as_s,
|
||||
chapter["mangaTitle"].as_s,
|
||||
Queue::JobStatus::Pending,
|
||||
Time.unix chapter["time"].as_s.to_i
|
||||
Time.unix chapter["timestamp"].as_i64
|
||||
)
|
||||
}
|
||||
inserted_count = Queue.default.push jobs
|
||||
@@ -458,7 +380,7 @@ struct APIRouter
|
||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||
loop do
|
||||
socket.send({
|
||||
"jobs" => Queue.default.get_all,
|
||||
"jobs" => Queue.default.get_all.reverse,
|
||||
"paused" => Queue.default.paused?,
|
||||
}.to_json)
|
||||
sleep interval.seconds
|
||||
@@ -468,17 +390,27 @@ struct APIRouter
|
||||
Koa.describe "Returns the current download queue", <<-MD
|
||||
On error, returns a JSON that contains the error message in the `error` field.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$jobs"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"paused" => Bool?,
|
||||
"jobs?" => [{
|
||||
"pages" => Int32,
|
||||
"success_count" => Int32,
|
||||
"fail_count" => Int32,
|
||||
"time" => Int64,
|
||||
}.merge(s %w(id manga_id title manga_title status_message status))],
|
||||
}
|
||||
get "/api/admin/mangadex/queue" do |env|
|
||||
begin
|
||||
jobs = Queue.default.get_all
|
||||
send_json env, {
|
||||
"jobs" => jobs,
|
||||
"jobs" => Queue.default.get_all.reverse,
|
||||
"paused" => Queue.default.paused?,
|
||||
"success" => true,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -495,10 +427,10 @@ struct APIRouter
|
||||
|
||||
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
|
||||
Koa.query "id", required: false, desc: "A job ID"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.response 200, schema: "result"
|
||||
post "/api/admin/mangadex/queue/:action" do |env|
|
||||
begin
|
||||
action = env.params.url["action"]
|
||||
@@ -526,6 +458,7 @@ struct APIRouter
|
||||
|
||||
send_json env, {"success" => true}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -547,8 +480,10 @@ struct APIRouter
|
||||
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
|
||||
MD
|
||||
Koa.tag "admin"
|
||||
Koa.body type: "multipart/form-data", ref: "$binaryUpload"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.body media_type: "multipart/form-data", schema: {
|
||||
"file" => Bytes,
|
||||
}
|
||||
Koa.response 200, schema: "result"
|
||||
post "/api/admin/upload/:target" do |env|
|
||||
begin
|
||||
target = env.params.url["target"]
|
||||
@@ -596,6 +531,98 @@ struct APIRouter
|
||||
|
||||
raise "No part with name `file` found"
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns a list of available plugins"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.query "plugin", schema: String
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"plugins" => [{
|
||||
"id" => String,
|
||||
"title" => String,
|
||||
}],
|
||||
}
|
||||
get "/api/admin/plugin" do |env|
|
||||
begin
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"plugins" => Plugin.list,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the metadata of a plugin"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.query "plugin", schema: String
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"info" => {
|
||||
"dir" => String,
|
||||
"id" => String,
|
||||
"title" => String,
|
||||
"placeholder" => String,
|
||||
"wait_seconds" => Int32,
|
||||
"version" => Int32,
|
||||
"settings" => {} of String => String,
|
||||
},
|
||||
}
|
||||
get "/api/admin/plugin/info" do |env|
|
||||
begin
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"info" => plugin.info,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
|
||||
Only available for plugins targeting API v2 or above.
|
||||
MD
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.query "plugin", schema: String
|
||||
Koa.query "query", schema: String
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"manga" => [{
|
||||
"id" => String,
|
||||
"title" => String,
|
||||
}],
|
||||
}
|
||||
get "/api/admin/plugin/search" do |env|
|
||||
begin
|
||||
query = env.params.query["query"].as String
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
|
||||
manga_ary = plugin.search_manga(query).as_a
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"manga" => manga_ary,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -604,17 +631,32 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Lists the chapters in a title from a plugin"
|
||||
Koa.tag "admin"
|
||||
Koa.body ref: "$pluginListBody"
|
||||
Koa.response 200, ref: "$pluginList"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.query "plugin", schema: String
|
||||
Koa.query "query", schema: String
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"chapters?" => [{
|
||||
"id" => String,
|
||||
"title?" => String,
|
||||
}],
|
||||
"title" => String?,
|
||||
}
|
||||
get "/api/admin/plugin/list" do |env|
|
||||
begin
|
||||
query = env.params.query["query"].as String
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
|
||||
json = plugin.list_chapters query
|
||||
chapters = json["chapters"]
|
||||
title = json["title"]
|
||||
|
||||
if plugin.info.version == 1
|
||||
chapters = json["chapters"]
|
||||
title = json["title"]
|
||||
else
|
||||
chapters = json
|
||||
title = nil
|
||||
end
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
@@ -622,6 +664,7 @@ struct APIRouter
|
||||
"title" => title,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -630,9 +673,19 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Adds a list of chapters from a plugin to the download queue"
|
||||
Koa.tag "admin"
|
||||
Koa.body ref: "$pluginDownload"
|
||||
Koa.response 200, ref: "$successFailCount"
|
||||
Koa.tags ["admin", "downloader"]
|
||||
Koa.body schema: {
|
||||
"plugin" => String,
|
||||
"title" => String,
|
||||
"chapters" => [{
|
||||
"id" => String,
|
||||
"title" => String,
|
||||
}],
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Int32,
|
||||
"fail" => Int32,
|
||||
}
|
||||
post "/api/admin/plugin/download" do |env|
|
||||
begin
|
||||
plugin = Plugin.new env.params.json["plugin"].as String
|
||||
@@ -655,6 +708,7 @@ struct APIRouter
|
||||
"fail": jobs.size - inserted_count,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -665,7 +719,15 @@ struct APIRouter
|
||||
Koa.describe "Returns the image dimensions of all pages in an entry"
|
||||
Koa.path "tid", desc: "A title ID"
|
||||
Koa.path "eid", desc: "An entry ID"
|
||||
Koa.response 200, ref: "$dimensionResult"
|
||||
Koa.tag "reader"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"dimensions?" => [{
|
||||
"width" => Int32,
|
||||
"height" => Int32,
|
||||
}],
|
||||
}
|
||||
get "/api/dimensions/:tid/:eid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -680,9 +742,9 @@ struct APIRouter
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"dimensions" => sizes,
|
||||
"margin" => Config.current.page_margin,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -693,8 +755,9 @@ struct APIRouter
|
||||
Koa.describe "Downloads an entry"
|
||||
Koa.path "tid", desc: "A title ID"
|
||||
Koa.path "eid", desc: "An entry ID"
|
||||
Koa.response 200, ref: "$binary"
|
||||
Koa.response 200, schema: Bytes
|
||||
Koa.response 404, "Entry not found"
|
||||
Koa.tags ["library", "reader"]
|
||||
get "/api/download/:tid/:eid" do |env|
|
||||
begin
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
@@ -709,7 +772,12 @@ struct APIRouter
|
||||
|
||||
Koa.describe "Gets the tags of a title"
|
||||
Koa.path "tid", desc: "A title ID"
|
||||
Koa.response 200, ref: "$tagsResult"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"tags" => [String?],
|
||||
}
|
||||
Koa.tags ["library", "tags"]
|
||||
get "/api/tags/:tid" do |env|
|
||||
begin
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
@@ -729,7 +797,12 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Returns all tags"
|
||||
Koa.response 200, ref: "$tagsResult"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"tags" => [String?],
|
||||
}
|
||||
Koa.tags ["library", "tags"]
|
||||
get "/api/tags" do |env|
|
||||
begin
|
||||
tags = Storage.default.list_tags
|
||||
@@ -748,8 +821,8 @@ struct APIRouter
|
||||
|
||||
Koa.describe "Adds a new tag to a title"
|
||||
Koa.path "tid", desc: "A title ID"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library", "tags"]
|
||||
put "/api/admin/tags/:tid/:tag" do |env|
|
||||
begin
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
@@ -771,8 +844,8 @@ struct APIRouter
|
||||
|
||||
Koa.describe "Deletes a tag from a title"
|
||||
Koa.path "tid", desc: "A title ID"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library", "tags"]
|
||||
delete "/api/admin/tags/:tid/:tag" do |env|
|
||||
begin
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
@@ -793,8 +866,16 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Lists all missing titles"
|
||||
Koa.response 200, ref: "$missingResult"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"titles?" => [{
|
||||
"path" => String,
|
||||
"id" => String,
|
||||
"signature" => String,
|
||||
}],
|
||||
}
|
||||
Koa.tags ["admin", "library"]
|
||||
get "/api/admin/titles/missing" do |env|
|
||||
begin
|
||||
send_json env, {
|
||||
@@ -803,6 +884,7 @@ struct APIRouter
|
||||
"titles" => Storage.default.missing_titles,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -811,8 +893,16 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Lists all missing entries"
|
||||
Koa.response 200, ref: "$missingResult"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"entries?" => [{
|
||||
"path" => String,
|
||||
"id" => String,
|
||||
"signature" => String,
|
||||
}],
|
||||
}
|
||||
Koa.tags ["admin", "library"]
|
||||
get "/api/admin/entries/missing" do |env|
|
||||
begin
|
||||
send_json env, {
|
||||
@@ -821,6 +911,7 @@ struct APIRouter
|
||||
"entries" => Storage.default.missing_entries,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -829,8 +920,8 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Deletes all missing titles"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library"]
|
||||
delete "/api/admin/titles/missing" do |env|
|
||||
begin
|
||||
Storage.default.delete_missing_title
|
||||
@@ -839,6 +930,7 @@ struct APIRouter
|
||||
"error" => nil,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -847,8 +939,8 @@ struct APIRouter
|
||||
end
|
||||
|
||||
Koa.describe "Deletes all missing entries"
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library"]
|
||||
delete "/api/admin/entries/missing" do |env|
|
||||
begin
|
||||
Storage.default.delete_missing_entry
|
||||
@@ -857,6 +949,7 @@ struct APIRouter
|
||||
"error" => nil,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -867,8 +960,8 @@ struct APIRouter
|
||||
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
|
||||
Does nothing if the given `tid` is not found or if the title is not missing.
|
||||
MD
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library"]
|
||||
delete "/api/admin/titles/missing/:tid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -878,6 +971,7 @@ struct APIRouter
|
||||
"error" => nil,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
@@ -888,8 +982,8 @@ struct APIRouter
|
||||
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
|
||||
Does nothing if the given `eid` is not found or if the entry is not missing.
|
||||
MD
|
||||
Koa.response 200, ref: "$result"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, schema: "result"
|
||||
Koa.tags ["admin", "library"]
|
||||
delete "/api/admin/entries/missing/:eid" do |env|
|
||||
begin
|
||||
eid = env.params.url["eid"]
|
||||
@@ -899,6 +993,116 @@ struct APIRouter
|
||||
"error" => nil,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Logs the current user into their MangaDex account", <<-MD
|
||||
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
|
||||
MD
|
||||
Koa.body schema: {
|
||||
"username" => String,
|
||||
"password" => String,
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"expires" => Int64?,
|
||||
}
|
||||
Koa.tags ["admin", "mangadex", "users"]
|
||||
post "/api/admin/mangadex/login" do |env|
|
||||
begin
|
||||
username = env.params.json["username"].as String
|
||||
password = env.params.json["password"].as String
|
||||
mango_username = get_username env
|
||||
|
||||
client = MangaDex::Client.from_config
|
||||
client.auth username, password
|
||||
|
||||
Storage.default.save_md_token mango_username, client.token.not_nil!,
|
||||
client.token_expires
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"error" => nil,
|
||||
"expires" => client.token_expires.to_unix,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"expires" => Int64?,
|
||||
}
|
||||
Koa.tags ["admin", "mangadex", "users"]
|
||||
get "/api/admin/mangadex/expires" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
_, expires = Storage.default.get_md_token username
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"error" => nil,
|
||||
"expires" => expires.try &.to_unix,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
|
||||
Returns an empty list if the current user hasn't logged in to MangaDex.
|
||||
MD
|
||||
Koa.query "query"
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"manga?" => [{
|
||||
"id" => Int64,
|
||||
"title" => String,
|
||||
"description" => String,
|
||||
"mainCover" => String,
|
||||
}],
|
||||
}
|
||||
Koa.tags ["admin", "mangadex"]
|
||||
get "/api/admin/mangadex/search" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
token, expires = Storage.default.get_md_token username
|
||||
|
||||
unless expires && token
|
||||
raise "No token found for user #{username}"
|
||||
end
|
||||
|
||||
client = MangaDex::Client.from_config
|
||||
client.token = token
|
||||
client.token_expires = expires
|
||||
|
||||
query = env.params.query["query"]
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"error" => nil,
|
||||
"manga" => client.partial_search query,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
|
||||
@@ -30,7 +30,8 @@ struct MainRouter
|
||||
else
|
||||
redirect env, "/"
|
||||
end
|
||||
rescue
|
||||
rescue e
|
||||
Logger.error e
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
@@ -78,16 +79,6 @@ struct MainRouter
|
||||
|
||||
get "/download/plugins" do |env|
|
||||
begin
|
||||
id = env.params.query["plugin"]?
|
||||
plugins = Plugin.list
|
||||
plugin = nil
|
||||
|
||||
if id
|
||||
plugin = Plugin.new id
|
||||
elsif !plugins.empty?
|
||||
plugin = Plugin.new plugins[0][:id]
|
||||
end
|
||||
|
||||
layout "plugin-download"
|
||||
rescue e
|
||||
Logger.error e
|
||||
@@ -103,7 +94,7 @@ struct MainRouter
|
||||
recently_added = Library.default.get_recently_added_entries username
|
||||
start_reading = Library.default.get_start_reading_titles username
|
||||
titles = Library.default.titles
|
||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||
new_user = !titles.any? &.load_percentage(username).> 0
|
||||
empty_library = titles.size == 0
|
||||
layout "home"
|
||||
rescue e
|
||||
|
||||
@@ -30,6 +30,11 @@ struct ReaderRouter
|
||||
|
||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
get_sort_opt
|
||||
entries = title.sorted_entries username, sort_opt
|
||||
|
||||
page_idx = env.params.url["page"].to_i
|
||||
if page_idx > entry.pages || page_idx <= 0
|
||||
raise "Page #{page_idx} not found."
|
||||
@@ -37,10 +42,12 @@ struct ReaderRouter
|
||||
|
||||
exit_url = "#{base_url}book/#{title.id}"
|
||||
|
||||
next_entry_url = nil
|
||||
next_entry = entry.next_entry username
|
||||
unless next_entry.nil?
|
||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
||||
next_entry_url = entry.next_entry(username).try do |e|
|
||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||
end
|
||||
|
||||
previous_entry_url = entry.previous_entry(username).try do |e|
|
||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||
end
|
||||
|
||||
render "src/views/reader.html.ecr"
|
||||
|
||||
@@ -49,6 +49,7 @@ class Server
|
||||
{% if flag?(:release) %}
|
||||
Kemal.config.env = "production"
|
||||
{% end %}
|
||||
Kemal.config.host_binding = Config.current.host
|
||||
Kemal.config.port = Config.current.port
|
||||
Kemal.run
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ class Storage
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
Logger.info "The DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
MainFiber.run do
|
||||
@@ -48,14 +48,6 @@ class Storage
|
||||
|
||||
user_count = db.query_one "select count(*) from users", as: Int32
|
||||
init_admin if init_user && user_count == 0
|
||||
|
||||
# Verifies that the default username in config is valid
|
||||
if Config.current.disable_login
|
||||
username = Config.current.default_username
|
||||
unless username_exists username
|
||||
raise "Default username #{username} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
unless @auto_close
|
||||
@db = DB.open "sqlite3://#{@path}"
|
||||
@@ -453,7 +445,7 @@ class Storage
|
||||
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||
end
|
||||
db.exec "update ids set unavailable = 1 where id in " \
|
||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
||||
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
||||
|
||||
# Detect dangling title IDs
|
||||
trash_titles = [] of String
|
||||
@@ -469,7 +461,7 @@ class Storage
|
||||
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||
end
|
||||
db.exec "update titles set unavailable = 1 where id in " \
|
||||
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
||||
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -522,6 +514,37 @@ class Storage
|
||||
delete_missing "titles", id
|
||||
end
|
||||
|
||||
def save_md_token(username : String, token : String, expire : Time)
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
count = db.query_one "select count(*) from md_account where " \
|
||||
"username = (?)", username, as: Int64
|
||||
if count == 0
|
||||
db.exec "insert into md_account values (?, ?, ?)", username, token,
|
||||
expire.to_unix
|
||||
else
|
||||
db.exec "update md_account set token = (?), expire = (?) " \
|
||||
"where username = (?)", token, expire.to_unix, username
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_md_token(username) : Tuple(String?, Time?)
|
||||
token = nil
|
||||
expires = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query_one? "select token, expire from md_account where " \
|
||||
"username = (?)", username do |res|
|
||||
token = res.read String
|
||||
expires = Time.unix res.read Int64
|
||||
end
|
||||
end
|
||||
end
|
||||
{token, expires}
|
||||
end
|
||||
|
||||
def close
|
||||
MainFiber.run do
|
||||
unless @db.nil?
|
||||
|
||||
83
src/subscription.cr
Normal file
83
src/subscription.cr
Normal file
@@ -0,0 +1,83 @@
|
||||
require "db"
|
||||
require "json"
|
||||
|
||||
struct Subscription
|
||||
include DB::Serializable
|
||||
include JSON::Serializable
|
||||
|
||||
getter id : Int64 = 0
|
||||
getter username : String
|
||||
getter manga_id : Int64
|
||||
property language : String?
|
||||
property group_id : Int64?
|
||||
property min_volume : Int64?
|
||||
property max_volume : Int64?
|
||||
property min_chapter : Int64?
|
||||
property max_chapter : Int64?
|
||||
@[DB::Field(key: "last_checked")]
|
||||
@[JSON::Field(key: "last_checked")]
|
||||
@raw_last_checked : Int64
|
||||
@[DB::Field(key: "created_at")]
|
||||
@[JSON::Field(key: "created_at")]
|
||||
@raw_created_at : Int64
|
||||
|
||||
def last_checked : Time
|
||||
Time.unix @raw_last_checked
|
||||
end
|
||||
|
||||
def created_at : Time
|
||||
Time.unix @raw_created_at
|
||||
end
|
||||
|
||||
def initialize(@manga_id, @username)
|
||||
@raw_created_at = Time.utc.to_unix
|
||||
@raw_last_checked = Time.utc.to_unix
|
||||
end
|
||||
|
||||
private def in_range?(value : String, lowerbound : Int64?,
|
||||
upperbound : Int64?) : Bool
|
||||
lb = lowerbound.try &.to_f64
|
||||
ub = upperbound.try &.to_f64
|
||||
|
||||
return true if lb.nil? && ub.nil?
|
||||
|
||||
v = value.to_f64?
|
||||
return false unless v
|
||||
|
||||
if lb.nil?
|
||||
v <= ub.not_nil!
|
||||
elsif ub.nil?
|
||||
v >= lb.not_nil!
|
||||
else
|
||||
v >= lb.not_nil! && v <= ub.not_nil!
|
||||
end
|
||||
end
|
||||
|
||||
def match?(chapter : MangaDex::Chapter) : Bool
|
||||
if chapter.manga_id != manga_id ||
|
||||
(language && chapter.language != language) ||
|
||||
(group_id && !chapter.groups.map(&.id).includes? group_id)
|
||||
return false
|
||||
end
|
||||
|
||||
in_range?(chapter.volume, min_volume, max_volume) &&
|
||||
in_range?(chapter.chapter, min_chapter, max_chapter)
|
||||
end
|
||||
|
||||
def check_for_updates : Int32
|
||||
Logger.debug "Checking updates for subscription with ID #{id}"
|
||||
jobs = [] of Queue::Job
|
||||
get_client(username).user.updates_after last_checked do |chapter|
|
||||
next unless match? chapter
|
||||
jobs << chapter.to_job
|
||||
end
|
||||
Storage.default.update_subscription_last_checked id
|
||||
count = Queue.default.push jobs
|
||||
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
|
||||
count
|
||||
rescue e
|
||||
Logger.error "Error occurred when checking updates for " \
|
||||
"subscription with ID #{id}. #{e}"
|
||||
0
|
||||
end
|
||||
end
|
||||
@@ -73,7 +73,7 @@ class ChapterSorter
|
||||
.select do |key|
|
||||
keys[key].count >= str_ary.size / 2
|
||||
end
|
||||
.sort do |a_key, b_key|
|
||||
.sort! do |a_key, b_key|
|
||||
a = keys[a_key]
|
||||
b = keys[b_key]
|
||||
# Sort keys by the number of times they appear
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 != "" }
|
||||
arr += match.captures.select &.!= ""
|
||||
end
|
||||
arr
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
IMGS_PER_PAGE = 5
|
||||
ENTRIES_IN_HOME_SECTIONS = 8
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||
|
||||
def random_str
|
||||
@@ -23,10 +23,18 @@ end
|
||||
|
||||
def register_mime_types
|
||||
{
|
||||
# Comic Archives
|
||||
".zip" => "application/zip",
|
||||
".rar" => "application/x-rar-compressed",
|
||||
".cbz" => "application/vnd.comicbook+zip",
|
||||
".cbr" => "application/vnd.comicbook-rar",
|
||||
|
||||
# Favicon
|
||||
".ico" => "image/x-icon",
|
||||
|
||||
# FontAwesome fonts
|
||||
".woff" => "font/woff",
|
||||
".woff2" => "font/woff2",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
@@ -106,7 +114,7 @@ class String
|
||||
def components_similarity(other : String) : Float64
|
||||
s, l = [self, other]
|
||||
.map { |str| Path.new(str).parts }
|
||||
.sort_by &.size
|
||||
.sort_by! &.size
|
||||
|
||||
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
||||
match / s.size
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# Web related helper functions/macros
|
||||
|
||||
# This macro defines `is_admin` when used
|
||||
macro check_admin_access
|
||||
def is_admin?(env) : Bool
|
||||
is_admin = false
|
||||
# The token (if exists) takes precedence over the default user option.
|
||||
# this is why we check the default username first before checking the
|
||||
# token.
|
||||
if Config.current.disable_login
|
||||
is_admin = Storage.default.
|
||||
username_is_admin Config.current.default_username
|
||||
if !Config.current.auth_proxy_header_name.empty? ||
|
||||
Config.current.disable_login
|
||||
is_admin = Storage.default.username_is_admin get_username env
|
||||
end
|
||||
|
||||
# The token (if exists) takes precedence over other authentication methods.
|
||||
if token = env.session.string? "token"
|
||||
is_admin = Storage.default.verify_admin token
|
||||
end
|
||||
|
||||
is_admin
|
||||
end
|
||||
|
||||
macro layout(name)
|
||||
base_url = Config.current.base_url
|
||||
check_admin_access
|
||||
is_admin = is_admin? env
|
||||
begin
|
||||
page = {{name}}
|
||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||
@@ -32,7 +32,7 @@ end
|
||||
macro send_error_page(msg)
|
||||
message = {{msg}}
|
||||
base_url = Config.current.base_url
|
||||
check_admin_access
|
||||
is_admin = is_admin? env
|
||||
page = "Error"
|
||||
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||
send_file env, html.to_slice, "text/html"
|
||||
@@ -49,6 +49,8 @@ macro get_username(env)
|
||||
rescue e
|
||||
if Config.current.disable_login
|
||||
Config.current.default_username
|
||||
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
|
||||
env.request.headers[header]
|
||||
else
|
||||
raise e
|
||||
end
|
||||
@@ -70,7 +72,7 @@ def redirect(env, path)
|
||||
end
|
||||
|
||||
def hash_to_query(hash)
|
||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||
hash.join "&" { |k, v| "#{k}=#{v}" }
|
||||
end
|
||||
|
||||
def request_path_startswith(env, ary)
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<option>System</option>
|
||||
</select>
|
||||
</li>
|
||||
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
|
||||
</ul>
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
@@ -4,13 +4,10 @@
|
||||
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
|
||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
|
||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||
|
||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
|
||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
|
||||
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
|
||||
|
||||
1
src/views/components/jquery-ui.html.ecr
Normal file
1
src/views/components/jquery-ui.html.ecr
Normal file
@@ -0,0 +1 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
1
src/views/components/moment.html.ecr
Normal file
1
src/views/components/moment.html.ecr
Normal file
@@ -0,0 +1 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
2
src/views/components/uikit.html.ecr
Normal file
2
src/views/components/uikit.html.ecr
Normal file
@@ -0,0 +1,2 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit-icons.min.js"></script>
|
||||
@@ -5,65 +5,67 @@
|
||||
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
||||
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
||||
</div>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Manga</th>
|
||||
<th>Progress</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Plugin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="job in jobs" :key="job">
|
||||
<tr :id="`chapter-${job.id}`">
|
||||
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
|
||||
</template>
|
||||
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.manga_title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||
</template>
|
||||
|
||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||
|
||||
<td>
|
||||
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<div class="uk-inline">
|
||||
<span uk-icon="info"></span>
|
||||
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||
|
||||
<td>
|
||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||
</template>
|
||||
</td>
|
||||
<div class="uk-overflow-auto">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Manga</th>
|
||||
<th>Progress</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Plugin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="job in jobs" :key="job">
|
||||
<tr :id="`chapter-${job.id}`">
|
||||
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
||||
</template>
|
||||
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.manga_title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||
</template>
|
||||
|
||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||
|
||||
<td>
|
||||
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<div class="uk-inline">
|
||||
<span uk-icon="info"></span>
|
||||
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||
|
||||
<td>
|
||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -1,83 +1,162 @@
|
||||
<h2 class=uk-title>Download from MangaDex</h2>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-3-4">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||
<div x-data="downloadComponent()" x-init="init()">
|
||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||
<div class="uk-width-expand">
|
||||
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
||||
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-1-4">
|
||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
||||
<div class="uk-width-1-4@s">
|
||||
<img id="cover">
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<p id="title"></p>
|
||||
<p id="artist"></p>
|
||||
<p id="author"></p>
|
||||
</div>
|
||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="lang-select">Language</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="lang-select">
|
||||
</select>
|
||||
|
||||
<template x-if="mangaAry">
|
||||
<div>
|
||||
<p x-show="mangaAry.length === 0">No matching manga found.</p>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<template x-for="manga in mangaAry" :key="manga.id">
|
||||
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top uk-inline">
|
||||
<img uk-img :data-src="manga.mainCover">
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
|
||||
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="group-select">Group</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="group-select">
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<div x-show="data && data.chapters" x-cloak>
|
||||
<div class"uk-grid-small" uk-grid>
|
||||
<div class="uk-width-1-4@s">
|
||||
<img :src="data.mainCover">
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
|
||||
<p x-text="`Artist: ${data.artist}`"></p>
|
||||
<p x-text="`Author: ${data.author}`"></p>
|
||||
</div>
|
||||
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Language</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
||||
<template x-for="lang in languages" :key="lang">
|
||||
<option x-text="lang"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Group</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
|
||||
<template x-for="group in groups" :key="group">
|
||||
<option x-text="group"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Volume</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Chapter</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
|
||||
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Language</th>
|
||||
<th>Group</th>
|
||||
<th>Volume</th>
|
||||
<th>Chapter</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<template x-if="chapters.length <= chaptersLimit">
|
||||
<tbody id="selectable">
|
||||
<template x-for="chp in chapters" :key="chp">
|
||||
<tr class="ui-widget-content">
|
||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
|
||||
<td x-text="chp.title"></td>
|
||||
<td x-text="chp.language"></td>
|
||||
<td>
|
||||
<template x-for="grp in Object.entries(chp.groups)">
|
||||
<div>
|
||||
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td x-text="chp.volume"></td>
|
||||
<td x-text="chp.chapter"></td>
|
||||
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="uk-flex-top" uk-modal="container: false">
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-grid">
|
||||
<div class="uk-width-1-3@s">
|
||||
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
|
||||
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
|
||||
</div>
|
||||
<div class="uk-width-2-3@s" uk-overflow-auto>
|
||||
<p x-text="candidateManga.description"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer">
|
||||
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="selection-controls" class="uk-margin" hidden>
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
</div>
|
||||
<p id="filter-notification" hidden></p>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Language</th>
|
||||
<th>Group</th>
|
||||
<th>Volume</th>
|
||||
<th>Chapter</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script>
|
||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<%= render_component "moment" %>
|
||||
<%= render_component "jquery-ui" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<%- end -%>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/title.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -1,91 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<%= render_component "head" %>
|
||||
<%= render_component "head" %>
|
||||
|
||||
<body>
|
||||
<div class="uk-offcanvas-content">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li class="uk-parent">
|
||||
<a href="#">Download</a>
|
||||
<ul class="uk-nav-sub">
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
<hr uk-divider>
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-position-top">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div class="uk-navbar-left uk-hidden@s">
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@s">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li>
|
||||
<a href="#">Download</a>
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li class="uk-nav-header">Source</li>
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li class="uk-nav-divider"></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
<body>
|
||||
<div class="uk-offcanvas-content">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li class="uk-parent">
|
||||
<a href="#">Download</a>
|
||||
<ul class="uk-nav-sub">
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
<hr uk-divider>
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-navbar-right uk-visible@s">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
<div class="uk-position-top">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div class="uk-navbar-left uk-hidden@s">
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@s">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li>
|
||||
<a href="#">Download</a>
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li class="uk-nav-header">Source</li>
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li class="uk-nav-divider"></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="uk-navbar-right uk-visible@s">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
</div>
|
||||
<div class="uk-section uk-section-small" style="position:relative;">
|
||||
<div class="uk-container uk-container-small">
|
||||
<div id="alert"></div>
|
||||
<%= content %>
|
||||
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
||||
<a href="#" uk-totop uk-scroll></a>
|
||||
<div class="uk-section uk-section-small">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme();
|
||||
const base_url = "<%= base_url %>";
|
||||
</script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
<div class="uk-section uk-section-small" style="position:relative;">
|
||||
<div class="uk-container uk-container-small">
|
||||
<div id="alert"></div>
|
||||
<%= content %>
|
||||
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
||||
<a href="#" uk-totop uk-scroll></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme();
|
||||
const base_url = "<%= base_url %>";
|
||||
</script>
|
||||
<%= render_component "uikit" %>
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
<script>
|
||||
setTheme();
|
||||
</script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
<%= render_component "uikit" %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
39
src/views/mangadex.html.ecr
Normal file
39
src/views/mangadex.html.ecr
Normal file
@@ -0,0 +1,39 @@
|
||||
<div x-data="component()" x-init="init()">
|
||||
<h2 class="uk-title">Connect to MangaDex</h2>
|
||||
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
|
||||
<div class="uk-width-1-2@s" x-show="!expires">
|
||||
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
|
||||
<ul>
|
||||
<li>Search MangaDex by search terms in addition to manga IDs</li>
|
||||
<li>Automatically download new chapters when they are available (coming soon)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="uk-width-1-2@s" x-show="expires">
|
||||
<p>
|
||||
<span x-show="!expired">You have logged in to MangaDex!</span>
|
||||
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
|
||||
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
|
||||
<span x-show="!expired">If the integration is not working, you</span>
|
||||
<span x-show="expired">You</span>
|
||||
can log in again and the token will be updated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="uk-width-1-2@s">
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
|
||||
</div>
|
||||
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/mangadex.js"></script>
|
||||
<% end %>
|
||||
@@ -3,34 +3,36 @@
|
||||
<div x-show="!empty">
|
||||
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
||||
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Relative Path</th>
|
||||
<th>ID</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="title in titles" :key="title">
|
||||
<tr :id="`title-${title.id}`">
|
||||
<td>Title</td>
|
||||
<td x-text="title.path"></td>
|
||||
<td x-text="title.id"></td>
|
||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||
<div class="uk-overflow-auto">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Relative Path</th>
|
||||
<th>ID</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in entries" :key="entry">
|
||||
<tr :id="`entry-${entry.id}`">
|
||||
<td>Entry</td>
|
||||
<td x-text="entry.path"></td>
|
||||
<td x-text="entry.id"></td>
|
||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="title in titles" :key="title">
|
||||
<tr :id="`title-${title.id}`">
|
||||
<td>Title</td>
|
||||
<td x-text="title.path"></td>
|
||||
<td x-text="title.id"></td>
|
||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in entries" :key="entry">
|
||||
<tr :id="`entry-${entry.id}`">
|
||||
<td>Entry</td>
|
||||
<td x-text="entry.path"></td>
|
||||
<td x-text="entry.id"></td>
|
||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,75 +1,180 @@
|
||||
<% if plugins.empty? %>
|
||||
<div class="uk-container uk-text-center">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<h2 class=uk-title>Download with Plugins</h2>
|
||||
|
||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="component()" x-init="init()" x-cloak>
|
||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
<div class="uk-width-expand">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="plugin-select" class="uk-select">
|
||||
<% plugins.each do |p| %>
|
||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<div x-show="plugins.length > 0" style="width:100%">
|
||||
<h2 class=uk-title>Download with Plugins
|
||||
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||
</h2>
|
||||
|
||||
<template x-if="info !== undefined">
|
||||
<div>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||
<div class="uk-margin">
|
||||
<div class="uk-form-controls">
|
||||
<label class="uk-form-label"> </label>
|
||||
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-expand">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||
<template x-for="p in plugins" :key="p">
|
||||
<option :value="p.id" x-text="p.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label"> </label>
|
||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
||||
<dl class="uk-description-list" id="toggle" hidden>
|
||||
<dt x-text="entry[0]"></dt>
|
||||
<dd x-text="entry[1]"></dd>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </label>
|
||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||
</template>
|
||||
|
||||
<template x-if="manga">
|
||||
<div class="uk-margin">
|
||||
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||
<p x-show="manga.length > 0">
|
||||
<span x-text="`${manga.length} manga found`"></span>
|
||||
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
|
||||
</p>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
|
||||
<template x-for="m in manga" :key="m.id">
|
||||
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top uk-inline">
|
||||
<img uk-img :data-src="m.cover_url">
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
|
||||
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="uk-margin-large-top" x-show="chapters !== undefined">
|
||||
<h3 x-text="mangaTitle"></h3>
|
||||
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
|
||||
|
||||
<div class="uk-margin">
|
||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
|
||||
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
|
||||
</div>
|
||||
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
|
||||
</div>
|
||||
|
||||
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
|
||||
<template x-for="field in filters">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">
|
||||
<span x-text="field.key"></span>
|
||||
<template x-if="field.type === 'number'">
|
||||
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
|
||||
</template>
|
||||
</label>
|
||||
|
||||
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
|
||||
|
||||
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
|
||||
<option value="all">All</option>
|
||||
<template x-for="v in field.values" :key="v">
|
||||
<option x-text="v" :value="v"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
|
||||
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
|
||||
</form>
|
||||
|
||||
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
|
||||
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
|
||||
|
||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
<div class="uk-overflow-auto">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<template x-for="(k, idx) in chapterKeys" :key="k">
|
||||
<th :id="`th-${idx}`" @click="thClicked($event)">
|
||||
<span x-text="k"></span>
|
||||
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
|
||||
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
|
||||
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="selectable">
|
||||
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
|
||||
<template x-for="ch in chapters" :key="ch">
|
||||
<tr class="ui-widget-content" :id="ch.id">
|
||||
<template x-for="k in chapterKeys" :key="k">
|
||||
<td x-text="ch[k]"></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="uk-description-list" id="toggle" hidden>
|
||||
<% plugin.not_nil!.info.each do |k, v| %>
|
||||
<dt><%= k %></dt>
|
||||
<dd><%= v.to_s %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
|
||||
<div id="table" class="uk-margin-large-top" hidden>
|
||||
<h3 id="title-text"></h3>
|
||||
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<% if plugin %>
|
||||
<script>
|
||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
||||
</script>
|
||||
<% end %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
||||
<%= render_component "jquery-ui" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -24,19 +24,19 @@
|
||||
<template x-for="item in items">
|
||||
<img
|
||||
uk-img
|
||||
class="uk-align-center"
|
||||
:style="item.style"
|
||||
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||
:data-src="item.url"
|
||||
:width="item.width"
|
||||
:height="item.height"
|
||||
:id="item.id"
|
||||
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||
@click="showControl($event)"
|
||||
/>
|
||||
</template>
|
||||
<%- if next_entry_url -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
||||
<%- else -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
@@ -68,18 +68,19 @@
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
<p id="progress-label"></p>
|
||||
<p x-text="`Progress: ${selectedIndex}/${items.length} (${(selectedIndex/items.length * 100).toFixed(1)}%)`"></p>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="page-select">Jump to page</label>
|
||||
<label class="uk-form-label" for="page-select">Jump to Page</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="page-select" class="uk-select" @change="pageChanged()">
|
||||
<select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
|
||||
<%- (1..entry.pages).each do |p| -%>
|
||||
<option value="<%= p %>"><%= p %></option>
|
||||
<%- end -%>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||
<div class="uk-form-controls">
|
||||
@@ -89,9 +90,40 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin" x-show="mode === 'continuous'">
|
||||
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="entry-select">Jump to Entry</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="entry-select" class="uk-select" @change="entryChanged()">
|
||||
<% entries.each do |e| %>
|
||||
<option value="<%= e.id %>"
|
||||
<% if e.id == entry.id %>
|
||||
selected
|
||||
<% end %>>
|
||||
<%= e.title %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||
<% if previous_entry_url %>
|
||||
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
||||
<% end %>
|
||||
<% if next_entry_url %>
|
||||
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
||||
<% end %>
|
||||
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,15 +135,14 @@
|
||||
const eid = "<%= entry.id %>";
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||
<%= render_component "uikit" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
<script src="<%= base_url %>js/reader.js"></script>
|
||||
</body>
|
||||
|
||||
<style>
|
||||
img[data-src][src*='data:image'] { background: white; }
|
||||
img { width: 100%; }
|
||||
img:not(.spine) { width: 100%; }
|
||||
.reader-bg { background: black; }
|
||||
</style>
|
||||
|
||||
|
||||
54
src/views/subscription.html.ecr
Normal file
54
src/views/subscription.html.ecr
Normal file
@@ -0,0 +1,54 @@
|
||||
<h2 class="uk-title">MangaDex Subscription Manager</h2>
|
||||
|
||||
<div x-data="component()" x-init="init()">
|
||||
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
|
||||
|
||||
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
|
||||
|
||||
<template x-if="subscriptions.length > 0">
|
||||
<div class="uk-overflow-auto">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Manga ID</th>
|
||||
<th>Language</th>
|
||||
<th>Group ID</th>
|
||||
<th>Volume Range</th>
|
||||
<th>Chapter Range</th>
|
||||
<th>Creator</th>
|
||||
<th>Last Checked</th>
|
||||
<th>Created At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="sub in subscriptions" :key="sub">
|
||||
<tr>
|
||||
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
|
||||
<td x-text="sub.language || 'All'"></td>
|
||||
<td>
|
||||
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
|
||||
<span x-show="!sub.group_id">All</span>
|
||||
</td>
|
||||
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
|
||||
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
|
||||
<td x-text="sub.username"></td>
|
||||
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
|
||||
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
|
||||
<td :data-id="sub.id">
|
||||
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
|
||||
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/subscription.js"></script>
|
||||
<% end %>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="<%= base_url %>css/tags.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user