mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c0199039 | ||
|
|
7a7cb78f82 | ||
|
|
8931ba8c43 | ||
|
|
d50981c151 | ||
|
|
df4deb1415 | ||
|
|
aa5e999ed4 | ||
|
|
84d4b0c529 | ||
|
|
d3e5691478 | ||
|
|
1000b02ae0 | ||
|
|
1f795889a9 | ||
|
|
d33b45233a | ||
|
|
4f6df5b9a3 | ||
|
|
341b586cb3 | ||
|
|
9dcc9665ce | ||
|
|
1cd90926df | ||
|
|
ac1ff61e6d | ||
|
|
6ea41f79e9 | ||
|
|
dad02a2a30 | ||
|
|
280490fb36 | ||
|
|
455315a362 | ||
|
|
df51406638 | ||
|
|
531d42ef18 | ||
|
|
2645e8cd05 | ||
|
|
b2dc44a919 | ||
|
|
c8db397a3b | ||
|
|
6384d4b77a | ||
|
|
1039732d87 | ||
|
|
011123f690 | ||
|
|
e602a35b0c | ||
|
|
7792d3426e | ||
|
|
b59c8f85ad | ||
|
|
18834ac28e | ||
|
|
bf68e32ac8 | ||
|
|
54eb041fe4 | ||
|
|
57d8c100f9 | ||
|
|
56d973b99d | ||
|
|
670e5cdf6a | ||
|
|
1b35392f9c | ||
|
|
c4e1ffe023 | ||
|
|
44f4959477 | ||
|
|
0582b57d60 | ||
|
|
83d96fd2a1 | ||
|
|
8ac89c420c | ||
|
|
968c2f4ad5 | ||
|
|
ad940f30d5 | ||
|
|
308ad4e063 | ||
|
|
4d709b7eb5 | ||
|
|
5760ad924e | ||
|
|
fff171c8c9 | ||
|
|
44ff566a1d | ||
|
|
853f422964 | ||
|
|
3bb0917374 | ||
|
|
a86f0d0f34 | ||
|
|
16a9d7fc2e | ||
|
|
ee2b4abc85 | ||
|
|
a6c2799521 | ||
|
|
2370e4d2c6 | ||
|
|
32b0384ea0 | ||
|
|
50d4ffdb7b | ||
|
|
96463641f9 | ||
|
|
ddbba5d596 | ||
|
|
2a04f4531e | ||
|
|
a5b6fb781f | ||
|
|
8dfdab9d73 | ||
|
|
3a95270dfb | ||
|
|
2960ca54df | ||
|
|
f5fe3c6b1c | ||
|
|
a612cc15fb | ||
|
|
c9c0818069 | ||
|
|
2f8efc382f | ||
|
|
a0fb1880bd | ||
|
|
a408f14425 | ||
|
|
243b6c8927 | ||
|
|
ff3a44d017 | ||
|
|
67ef1f7112 | ||
|
|
5d7b8a1ef9 | ||
|
|
a68f3eea95 | ||
|
|
220fc42bf2 | ||
|
|
a45e6ea3da | ||
|
|
88394d4636 | ||
|
|
ef1ab940f5 | ||
|
|
97a1c408d8 | ||
|
|
abbf77df13 | ||
|
|
3b4021f680 | ||
|
|
68b1923cb6 | ||
|
|
3cdd4b29a5 | ||
|
|
af84c0f6de | ||
|
|
85a65f84d0 | ||
|
|
5027a911cd | ||
|
|
ac63bf7599 | ||
|
|
30b0e0b8fb | ||
|
|
ddda058d8d | ||
|
|
46db25e8e0 | ||
|
|
c07f421322 | ||
|
|
99a77966ad | ||
|
|
d00b917575 | ||
|
|
4fd8334c37 | ||
|
|
3aa4630558 | ||
|
|
cde5af7066 | ||
|
|
eb528e1726 | ||
|
|
5e01cc38fe | ||
|
|
9a787ccbc3 | ||
|
|
8a83c0df4e | ||
|
|
87dea01917 | ||
|
|
586ee4f0ba | ||
|
|
53f3387e1a | ||
|
|
be5d1918aa | ||
|
|
df2cc0ffa9 | ||
|
|
b8cfc3a201 | ||
|
|
8dc60ac2ea | ||
|
|
1719335d02 | ||
|
|
0cd46abc66 | ||
|
|
e4fd7c58ee | ||
|
|
d4abee52db | ||
|
|
d29c94e898 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
lib
|
||||
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -12,20 +12,29 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: crystallang/crystal:0.34.0-alpine
|
||||
image: crystallang/crystal:0.35.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
|
||||
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
|
||||
- name: Build
|
||||
run: make static
|
||||
run: make static || make static
|
||||
- name: Linter
|
||||
run: make check
|
||||
- name: Run tests
|
||||
run: make test
|
||||
- name: Upload artifact
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: mango
|
||||
path: mango
|
||||
- name: build arm32v7 object file
|
||||
run: make arm32v7 || make arm32v7
|
||||
- name: build arm64v8 object file
|
||||
run: make arm64v8 || make arm64v8
|
||||
- name: Upload object files
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: object files
|
||||
path: ./*.o
|
||||
|
||||
2
.github/workflows/dockerhub.yml
vendored
2
.github/workflows/dockerhub.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@master
|
||||
- name: Get release version
|
||||
id: get_version
|
||||
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
|
||||
run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
|
||||
- name: Publish to Dockerhub
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
with:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
FROM crystallang/crystal:0.34.0-alpine AS builder
|
||||
FROM crystallang/crystal:0.35.1-alpine AS builder
|
||||
|
||||
WORKDIR /Mango
|
||||
|
||||
COPY . .
|
||||
COPY package*.json .
|
||||
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
|
||||
&& make static
|
||||
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 make static || make static
|
||||
|
||||
FROM library/alpine
|
||||
|
||||
|
||||
14
Dockerfile.arm32v7
Normal file
14
Dockerfile.arm32v7
Normal file
@@ -0,0 +1,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/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
|
||||
|
||||
CMD ["./mango"]
|
||||
14
Dockerfile.arm64v8
Normal file
14
Dockerfile.arm64v8
Normal file
@@ -0,0 +1,14 @@
|
||||
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/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
|
||||
|
||||
CMD ["./mango"]
|
||||
10
Makefile
10
Makefile
@@ -12,10 +12,10 @@ setup: libs
|
||||
yarn gulp dev
|
||||
|
||||
build: libs
|
||||
crystal build src/mango.cr --release --progress
|
||||
crystal build src/mango.cr --release --progress --error-trace
|
||||
|
||||
static: uglify | libs
|
||||
crystal build src/mango.cr --release --progress --static
|
||||
crystal build src/mango.cr --release --progress --static --error-trace
|
||||
|
||||
libs:
|
||||
shards install --production
|
||||
@@ -31,6 +31,12 @@ check:
|
||||
./bin/ameba
|
||||
./dev/linewidth.sh
|
||||
|
||||
arm32v7:
|
||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
||||
|
||||
arm64v8:
|
||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
|
||||
|
||||
install:
|
||||
cp mango $(INSTALL_DIR)/mango
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -12,8 +12,9 @@ Mango is a self-hosted manga server and reader. Its features include
|
||||
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
||||
- Supports nested folders in library
|
||||
- Automatically stores reading progress
|
||||
- Thumbnail generation
|
||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||
- [Plugins](https://github.com/hkalexling/mango-plugins) support
|
||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||
|
||||
@@ -51,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.9.0
|
||||
Mango - Manga Server and Web Reader. Version 0.17.0
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -76,22 +77,27 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
||||
---
|
||||
port: 9000
|
||||
base_url: /
|
||||
session_secret: mango-session-secret
|
||||
library_path: ~/mango/library
|
||||
db_path: ~/mango/mango.db
|
||||
scan_interval_minutes: 5
|
||||
thumbnail_generation_interval_hours: 24
|
||||
db_optimization_interval_hours: 24
|
||||
log_level: info
|
||||
upload_path: ~/mango/uploads
|
||||
plugin_path: ~/mango/plugins
|
||||
download_timeout_seconds: 30
|
||||
mangadex:
|
||||
base_url: https://mangadex.org
|
||||
api_url: https://mangadex.org/api
|
||||
download_wait_seconds: 5
|
||||
download_retries: 4
|
||||
download_queue_db_path: ~/mango/queue.db
|
||||
download_queue_db_path: /home/alex_ling/mango/queue.db
|
||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||
manga_rename_rule: '{title}'
|
||||
```
|
||||
|
||||
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
||||
- `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
|
||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||
|
||||
### Library Structure
|
||||
@@ -139,8 +145,13 @@ Mobile UI:
|
||||
|
||||

|
||||
|
||||
## Sponsors
|
||||
|
||||
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
|
||||
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
|
||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
||||
|
||||
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \
|
||||
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
|
||||
&& echo "The above lines exceed the 80 characters limit" \
|
||||
|| exit 0
|
||||
|
||||
76
gulpfile.js
76
gulpfile.js
@@ -1,15 +1,43 @@
|
||||
const gulp = require('gulp');
|
||||
const minify = require("gulp-babel-minify");
|
||||
const babel = require('gulp-babel');
|
||||
const minify = require('gulp-babel-minify');
|
||||
const minifyCss = require('gulp-minify-css');
|
||||
const less = require('gulp-less');
|
||||
|
||||
gulp.task('copy-uikit-js', () => {
|
||||
return gulp.src('node_modules/uikit/dist/js/*.min.js')
|
||||
// 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'));
|
||||
});
|
||||
|
||||
gulp.task('minify-js', () => {
|
||||
return gulp.src('public/js/*.js')
|
||||
// Copy UIKit SVG icons to public/img
|
||||
gulp.task('copy-uikit-icons', () => {
|
||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||
.pipe(gulp.dest('public/img'));
|
||||
});
|
||||
|
||||
// Compile less
|
||||
gulp.task('less', () => {
|
||||
return gulp.src('public/css/*.less')
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest('public/css'));
|
||||
});
|
||||
|
||||
// Transpile and minify JS files and output to dist
|
||||
gulp.task('babel', () => {
|
||||
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
|
||||
.pipe(babel({
|
||||
presets: [
|
||||
['@babel/preset-env', {
|
||||
targets: '>0.25%, not dead, ios>=9'
|
||||
}]
|
||||
],
|
||||
}))
|
||||
.pipe(minify({
|
||||
removeConsole: true,
|
||||
builtIns: false
|
||||
@@ -17,40 +45,26 @@ gulp.task('minify-js', () => {
|
||||
.pipe(gulp.dest('dist/js'));
|
||||
});
|
||||
|
||||
gulp.task('less', () => {
|
||||
return gulp.src('public/css/*.less')
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest('public/css'));
|
||||
});
|
||||
|
||||
// Minify CSS and output to dist
|
||||
gulp.task('minify-css', () => {
|
||||
return gulp.src('public/css/*.css')
|
||||
.pipe(minifyCss())
|
||||
.pipe(gulp.dest('dist/css'));
|
||||
});
|
||||
|
||||
gulp.task('copy-uikit-icons', () => {
|
||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||
.pipe(gulp.dest('public/img'));
|
||||
});
|
||||
|
||||
gulp.task('img', () => {
|
||||
return gulp.src('public/img/*')
|
||||
.pipe(gulp.dest('dist/img'));
|
||||
});
|
||||
|
||||
// Copy static files (includeing images) to dist
|
||||
gulp.task('copy-files', () => {
|
||||
return gulp.src('public/*.*')
|
||||
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
|
||||
base: 'public'
|
||||
})
|
||||
.pipe(gulp.dest('dist'));
|
||||
});
|
||||
|
||||
gulp.task('default', gulp.parallel(
|
||||
gulp.series('copy-uikit-js', 'minify-js'),
|
||||
gulp.series('less', 'minify-css'),
|
||||
gulp.series('copy-uikit-icons', 'img'),
|
||||
'copy-files'
|
||||
));
|
||||
// Set up the public folder for development
|
||||
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
||||
|
||||
gulp.task('dev', gulp.parallel(
|
||||
'copy-uikit-js', 'less', 'copy-uikit-icons'
|
||||
));
|
||||
// Set up the dist folder for deployment
|
||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||
|
||||
// Default task
|
||||
gulp.task('default', gulp.series('dev', 'deploy'));
|
||||
|
||||
41
package.json
41
package.json
@@ -1,21 +1,24 @@
|
||||
{
|
||||
"name": "mango",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/hkalexling/Mango.git",
|
||||
"author": "Alex Ling <hkalexling@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-babel-minify": "^0.5.1",
|
||||
"gulp-less": "^4.0.1",
|
||||
"gulp-minify-css": "^1.2.4",
|
||||
"less": "^3.11.3"
|
||||
},
|
||||
"scripts": {
|
||||
"uglify": "gulp"
|
||||
},
|
||||
"dependencies": {
|
||||
"uikit": "^3.5.4"
|
||||
}
|
||||
"name": "mango",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/hkalexling/Mango.git",
|
||||
"author": "Alex Ling <hkalexling@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.11.5",
|
||||
"gulp": "^4.0.2",
|
||||
"gulp-babel": "^8.0.0",
|
||||
"gulp-babel-minify": "^0.5.1",
|
||||
"gulp-less": "^4.0.1",
|
||||
"gulp-minify-css": "^1.2.4",
|
||||
"less": "^3.11.3"
|
||||
},
|
||||
"scripts": {
|
||||
"uglify": "gulp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"uikit": "^3.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
}
|
||||
|
||||
.uk-card-media-top {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
@@ -122,3 +123,32 @@ td>.uk-dropdown {
|
||||
.uk-light .uk-description-list>dt {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#select-bar-controls a {
|
||||
transform: scale(1.5, 1.5);
|
||||
}
|
||||
|
||||
#select-bar-controls a:hover {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
#main-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#totop-wrapper {
|
||||
position: absolute;
|
||||
top: 100vh;
|
||||
right: 2em;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#totop-wrapper a {
|
||||
position: fixed;
|
||||
position: sticky;
|
||||
top: calc(100vh - 5em);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,90 @@
|
||||
let scanning = false;
|
||||
|
||||
const scan = () => {
|
||||
scanning = true;
|
||||
$('#scan-status > div').removeAttr('hidden');
|
||||
$('#scan-status > span').attr('hidden', '');
|
||||
const color = $('#scan').css('color');
|
||||
$('#scan').css('color', 'gray');
|
||||
$.post(base_url + 'api/admin/scan', (data) => {
|
||||
const ms = data.milliseconds;
|
||||
const titles = data.titles;
|
||||
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
||||
$('#scan-status > span').removeAttr('hidden');
|
||||
$('#scan').css('color', color);
|
||||
$('#scan-status > div').attr('hidden', '');
|
||||
scanning = false;
|
||||
});
|
||||
}
|
||||
|
||||
String.prototype.capitalize = function() {
|
||||
return this.charAt(0).toUpperCase() + this.slice(1);
|
||||
}
|
||||
|
||||
$(() => {
|
||||
$('li').click((e) => {
|
||||
const url = $(e.currentTarget).attr('data-url');
|
||||
if (url) {
|
||||
$(location).attr('href', url);
|
||||
}
|
||||
});
|
||||
|
||||
const setting = loadThemeSetting();
|
||||
$('#theme-select').val(setting.capitalize());
|
||||
|
||||
$('#theme-select').val(capitalize(setting));
|
||||
$('#theme-select').change((e) => {
|
||||
const newSetting = $(e.currentTarget).val().toLowerCase();
|
||||
saveThemeSetting(newSetting);
|
||||
setTheme();
|
||||
});
|
||||
|
||||
getProgress();
|
||||
setInterval(getProgress, 5000);
|
||||
});
|
||||
|
||||
/**
|
||||
* Capitalize String
|
||||
*
|
||||
* @function capitalize
|
||||
* @param {string} str - The string to be capitalized
|
||||
* @return {string} The capitalized string
|
||||
*/
|
||||
const capitalize = (str) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set an alpine.js property
|
||||
*
|
||||
* @function setProp
|
||||
* @param {string} key - Key of the data property
|
||||
* @param {*} prop - The data property
|
||||
*/
|
||||
const setProp = (key, prop) => {
|
||||
$('#root').get(0).__x.$data[key] = prop;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an alpine.js property
|
||||
*
|
||||
* @function getProp
|
||||
* @param {string} key - Key of the data property
|
||||
* @return {*} The data property
|
||||
*/
|
||||
const getProp = (key) => {
|
||||
return $('#root').get(0).__x.$data[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the thumbnail generation progress from the API
|
||||
*
|
||||
* @function getProgress
|
||||
*/
|
||||
const getProgress = () => {
|
||||
$.get(`${base_url}api/admin/thumbnail_progress`)
|
||||
.then(data => {
|
||||
setProp('progress', data.progress);
|
||||
const generating = data.progress > 0
|
||||
setProp('generating', generating);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger the thumbnail generation
|
||||
*
|
||||
* @function generateThumbnails
|
||||
*/
|
||||
const generateThumbnails = () => {
|
||||
setProp('generating', true);
|
||||
setProp('progress', 0.0);
|
||||
$.post(`${base_url}api/admin/generate_thumbnails`)
|
||||
.then(getProgress);
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger the scan
|
||||
*
|
||||
* @function scan
|
||||
*/
|
||||
const scan = () => {
|
||||
setProp('scanning', true);
|
||||
setProp('scanMs', -1);
|
||||
setProp('scanTitles', 0);
|
||||
$.post(`${base_url}api/admin/scan`)
|
||||
.then(data => {
|
||||
setProp('scanMs', data.milliseconds);
|
||||
setProp('scanTitles', data.titles);
|
||||
})
|
||||
.always(() => {
|
||||
setProp('scanning', false);
|
||||
});
|
||||
}
|
||||
|
||||
147
public/js/common.js
Normal file
147
public/js/common.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* --- Alpine helper functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set an alpine.js property
|
||||
*
|
||||
* @function setProp
|
||||
* @param {string} key - Key of the data property
|
||||
* @param {*} prop - The data property
|
||||
* @param {string} selector - The jQuery selector to the root element
|
||||
*/
|
||||
const setProp = (key, prop, selector = '#root') => {
|
||||
$(selector).get(0).__x.$data[key] = prop;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an alpine.js property
|
||||
*
|
||||
* @function getProp
|
||||
* @param {string} key - Key of the data property
|
||||
* @param {string} selector - The jQuery selector to the root element
|
||||
* @return {*} The data property
|
||||
*/
|
||||
const getProp = (key, selector = '#root') => {
|
||||
return $(selector).get(0).__x.$data[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* --- Theme related functions
|
||||
* Note: In the comments below we treat "theme" and "theme setting"
|
||||
* differently. A theme can have only two values, either "dark" or
|
||||
* "light", while a theme setting can have the third value "system".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the system setting prefers dark theme.
|
||||
* from https://flaviocopes.com/javascript-detect-dark-mode/
|
||||
*
|
||||
* @function preferDarkMode
|
||||
* @return {bool}
|
||||
*/
|
||||
const preferDarkMode = () => {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a given string represents a valid theme setting
|
||||
*
|
||||
* @function validThemeSetting
|
||||
* @param {string} theme - The string representing the theme setting
|
||||
* @return {bool}
|
||||
*/
|
||||
const validThemeSetting = (theme) => {
|
||||
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load theme setting from local storage, or use 'light'
|
||||
*
|
||||
* @function loadThemeSetting
|
||||
* @return {string} A theme setting ('dark', 'light', or 'system')
|
||||
*/
|
||||
const loadThemeSetting = () => {
|
||||
let str = localStorage.getItem('theme');
|
||||
if (!str || !validThemeSetting(str)) str = 'light';
|
||||
return str;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the current theme (not theme setting)
|
||||
*
|
||||
* @function loadTheme
|
||||
* @return {string} The current theme to use ('dark' or 'light')
|
||||
*/
|
||||
const loadTheme = () => {
|
||||
let setting = loadThemeSetting();
|
||||
if (setting === 'system') {
|
||||
setting = preferDarkMode() ? 'dark' : 'light';
|
||||
}
|
||||
return setting;
|
||||
};
|
||||
|
||||
/**
|
||||
* Save a theme setting
|
||||
*
|
||||
* @function saveThemeSetting
|
||||
* @param {string} setting - A theme setting
|
||||
*/
|
||||
const saveThemeSetting = setting => {
|
||||
if (!validThemeSetting(setting)) setting = 'light';
|
||||
localStorage.setItem('theme', setting);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the current theme. When the current theme setting is 'system', it
|
||||
* will be changed to either 'light' or 'dark'
|
||||
*
|
||||
* @function toggleTheme
|
||||
*/
|
||||
const toggleTheme = () => {
|
||||
const theme = loadTheme();
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
saveThemeSetting(newTheme);
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a theme, or load a theme and then apply it
|
||||
*
|
||||
* @function setTheme
|
||||
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
|
||||
* `loadTheme` to get a theme and apply it.
|
||||
*/
|
||||
const setTheme = (theme) => {
|
||||
if (!theme) theme = loadTheme();
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// do it before document is ready to prevent the initial flash of white on
|
||||
// most pages
|
||||
setTheme();
|
||||
$(() => {
|
||||
// hack for the reader page
|
||||
setTheme();
|
||||
|
||||
// on system dark mode setting change
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', event => {
|
||||
if (loadThemeSetting() === 'system')
|
||||
setTheme(event.matches ? 'dark' : 'light');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,17 +1,26 @@
|
||||
const truncate = () => {
|
||||
$('.uk-card-title').each((i, e) => {
|
||||
$(e).dotdotdot({
|
||||
truncate: 'letter',
|
||||
watch: true,
|
||||
callback: (truncated) => {
|
||||
if (truncated) {
|
||||
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||
} else {
|
||||
$(e).removeAttr('uk-tooltip');
|
||||
}
|
||||
/**
|
||||
* Truncate a .uk-card-title element
|
||||
*
|
||||
* @function truncate
|
||||
* @param {object} e - The title element to truncate
|
||||
*/
|
||||
const truncate = (e) => {
|
||||
$(e).dotdotdot({
|
||||
truncate: 'letter',
|
||||
watch: true,
|
||||
callback: (truncated) => {
|
||||
if (truncated) {
|
||||
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||
} else {
|
||||
$(e).removeAttr('uk-tooltip');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
truncate();
|
||||
$('.uk-card-title').each((i, e) => {
|
||||
// Truncate the title when it first enters the view
|
||||
$(e).one('inview', () => {
|
||||
truncate(e);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
$(() => {
|
||||
$('input.uk-checkbox').each((i, e) => {
|
||||
$(e).change(() => {
|
||||
loadConfig();
|
||||
/**
|
||||
* Get the current queue and update the view
|
||||
*
|
||||
* @function load
|
||||
*/
|
||||
const load = () => {
|
||||
try {
|
||||
setProp('loading', true);
|
||||
} catch {}
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: base_url + 'api/admin/mangadex/queue',
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (!data.success && data.error) {
|
||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
setProp('jobs', data.jobs);
|
||||
setProp('paused', data.paused);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
setProp('loading', false);
|
||||
});
|
||||
});
|
||||
loadConfig();
|
||||
load();
|
||||
|
||||
const intervalMS = 5000;
|
||||
setTimeout(() => {
|
||||
setInterval(() => {
|
||||
if (globalConfig.autoRefresh !== true) return;
|
||||
load();
|
||||
}, intervalMS);
|
||||
}, intervalMS);
|
||||
});
|
||||
var globalConfig = {};
|
||||
var loading = false;
|
||||
|
||||
const loadConfig = () => {
|
||||
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
||||
};
|
||||
const remove = (id) => {
|
||||
var url = base_url + 'api/admin/mangadex/queue/delete';
|
||||
|
||||
/**
|
||||
* Perform an action on either a specific job or the entire queue
|
||||
*
|
||||
* @function jobAction
|
||||
* @param {string} action - The action to perform. Should be either 'delete' or 'retry'
|
||||
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
|
||||
*/
|
||||
const jobAction = (action, id) => {
|
||||
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||
if (id !== undefined)
|
||||
url += '?' + $.param({
|
||||
id: id
|
||||
@@ -35,42 +49,24 @@ const remove = (id) => {
|
||||
})
|
||||
.done(data => {
|
||||
if (!data.success && data.error) {
|
||||
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
||||
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
load();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
};
|
||||
const refresh = (id) => {
|
||||
var url = base_url + 'api/admin/mangadex/queue/retry';
|
||||
if (id !== undefined)
|
||||
url += '?' + $.param({
|
||||
id: id
|
||||
});
|
||||
console.log(url);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (!data.success && data.error) {
|
||||
alert('danger', `Failed to restart download job. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
load();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause/resume the download
|
||||
*
|
||||
* @function toggle
|
||||
*/
|
||||
const toggle = () => {
|
||||
$('#pause-resume-btn').attr('disabled', '');
|
||||
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
||||
const action = paused ? 'resume' : 'pause';
|
||||
setProp('toggling', true);
|
||||
const action = getProp('paused') ? 'resume' : 'pause';
|
||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
@@ -82,64 +78,47 @@ const toggle = () => {
|
||||
})
|
||||
.always(() => {
|
||||
load();
|
||||
$('#pause-resume-btn').removeAttr('disabled');
|
||||
setProp('toggling', false);
|
||||
});
|
||||
};
|
||||
const load = () => {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
console.log('fetching');
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: base_url + 'api/admin/mangadex/queue',
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (!data.success && data.error) {
|
||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
console.log(data);
|
||||
const btnText = data.paused ? "Resume download" : "Pause download";
|
||||
$('#pause-resume-btn').text(btnText);
|
||||
$('#pause-resume-btn').removeAttr('hidden');
|
||||
const rows = data.jobs.map(obj => {
|
||||
var cls = 'label ';
|
||||
if (obj.status === 'Pending')
|
||||
cls += 'label-pending';
|
||||
if (obj.status === 'Completed')
|
||||
cls += 'label-success';
|
||||
if (obj.status === 'Error')
|
||||
cls += 'label-danger';
|
||||
if (obj.status === 'MissingPages')
|
||||
cls += 'label-warning';
|
||||
|
||||
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
||||
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
||||
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
|
||||
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
|
||||
return `<tr id="chapter-${obj.id}">
|
||||
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
|
||||
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
|
||||
<td>${obj.success_count}/${obj.pages}</td>
|
||||
<td>${moment(obj.time).fromNow()}</td>
|
||||
<td>${statusSpan} ${dropdown}</td>
|
||||
<td>${obj.plugin_id || ""}</td>
|
||||
<td>
|
||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
||||
${retryBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
||||
$('tbody').remove();
|
||||
$('table').append(tbody);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
loading = false;
|
||||
});
|
||||
/**
|
||||
* Get the uk-label class name for a given job status
|
||||
*
|
||||
* @function statusClass
|
||||
* @param {string} status - The job status
|
||||
* @return {string} The class name string
|
||||
*/
|
||||
const statusClass = status => {
|
||||
let cls = 'label ';
|
||||
switch (status) {
|
||||
case 'Pending':
|
||||
cls += 'label-pending';
|
||||
break;
|
||||
case 'Completed':
|
||||
cls += 'label-success';
|
||||
break;
|
||||
case 'Error':
|
||||
cls += 'label-danger';
|
||||
break;
|
||||
case 'MissingPages':
|
||||
cls += 'label-warning';
|
||||
break;
|
||||
}
|
||||
return cls;
|
||||
};
|
||||
|
||||
$(() => {
|
||||
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
|
||||
ws.onmessage = event => {
|
||||
const data = JSON.parse(event.data);
|
||||
setProp('jobs', data.jobs);
|
||||
setProp('paused', data.paused);
|
||||
};
|
||||
ws.onerror = err => {
|
||||
alert('danger', `Socket connection failed. Error: ${err}`);
|
||||
};
|
||||
ws.onclose = err => {
|
||||
alert('danger', 'Socket connection failed');
|
||||
};
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ const search = () => {
|
||||
|
||||
try {
|
||||
const path = new URL(input).pathname;
|
||||
const match = /\/title\/([0-9]+)/.exec(path);
|
||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||
int_id = parseInt(match[1]);
|
||||
} catch (e) {
|
||||
int_id = parseInt(input);
|
||||
|
||||
5
public/js/fontawesome.min.js
vendored
5
public/js/fontawesome.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -33,14 +33,13 @@ const search = () => {
|
||||
if (searching)
|
||||
return;
|
||||
|
||||
const query = $('#search-input').val();
|
||||
const query = $.param({
|
||||
query: $('#search-input').val(),
|
||||
plugin: pid
|
||||
});
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/plugin/list',
|
||||
data: JSON.stringify({
|
||||
query: query,
|
||||
plugin: pid
|
||||
}),
|
||||
type: 'GET',
|
||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
|
||||
@@ -1,64 +1,149 @@
|
||||
$(function() {
|
||||
function bind() {
|
||||
var controller = new ScrollMagic.Controller();
|
||||
let lastSavedPage = page;
|
||||
let items = [];
|
||||
let longPages = false;
|
||||
|
||||
// replace history on scroll
|
||||
$('img').each(function(idx) {
|
||||
var scene = new ScrollMagic.Scene({
|
||||
triggerElement: $(this).get(),
|
||||
triggerHook: 'onEnter',
|
||||
reverse: true
|
||||
})
|
||||
.addTo(controller)
|
||||
.on('enter', function(event) {
|
||||
current = $(event.target.triggerElement()).attr('id');
|
||||
replaceHistory(current);
|
||||
})
|
||||
.on('leave', function(event) {
|
||||
var prev = $(event.target.triggerElement()).prev();
|
||||
current = $(prev).attr('id');
|
||||
replaceHistory(current);
|
||||
});
|
||||
});
|
||||
$(() => {
|
||||
getPages();
|
||||
|
||||
// poor man's infinite scroll
|
||||
var scene = new ScrollMagic.Scene({
|
||||
triggerElement: $('.next-url').get(),
|
||||
triggerHook: 'onEnter',
|
||||
offset: -500
|
||||
})
|
||||
.addTo(controller)
|
||||
.on('enter', function() {
|
||||
var nextURL = $('.next-url').attr('href');
|
||||
$('.next-url').remove();
|
||||
if (!nextURL) {
|
||||
console.log('No .next-url found. Reached end of page');
|
||||
var lastURL = $('img').last().attr('id');
|
||||
// load the reader URL for the last page to update reading progrss to 100%
|
||||
$.get(lastURL);
|
||||
$('#next-btn').removeAttr('hidden');
|
||||
return;
|
||||
}
|
||||
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
|
||||
if (status === 'error') console.log(xhr.statusText);
|
||||
if (status === 'success') {
|
||||
console.log(nextURL + ' loaded');
|
||||
// new page loaded to #hidden, we now append it
|
||||
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
|
||||
$('#hidden').empty();
|
||||
bind();
|
||||
}
|
||||
});
|
||||
});
|
||||
$('#page-select').change(() => {
|
||||
const p = parseInt($('#page-select').val());
|
||||
toPage(p);
|
||||
});
|
||||
|
||||
$('#mode-select').change(() => {
|
||||
const mode = $('#mode-select').val();
|
||||
const curIdx = parseInt($('#page-select').val());
|
||||
|
||||
updateMode(mode, curIdx);
|
||||
});
|
||||
});
|
||||
|
||||
$(window).resize(() => {
|
||||
const mode = getProp('mode');
|
||||
if (mode === 'continuous') return;
|
||||
|
||||
const wideScreen = $(window).width() > $(window).height();
|
||||
const propMode = wideScreen ? 'height' : 'width';
|
||||
setProp('mode', propMode);
|
||||
});
|
||||
|
||||
/**
|
||||
* Update the reader mode
|
||||
*
|
||||
* @function updateMode
|
||||
* @param {string} mode - The mode. Can be one of the followings:
|
||||
* {'continuous', 'paged', 'height', 'width'}
|
||||
* @param {number} targetPage - The one-based index of the target page
|
||||
*/
|
||||
const updateMode = (mode, targetPage) => {
|
||||
localStorage.setItem('mode', mode);
|
||||
|
||||
// The mode to be put into the `mode` prop. It can't be `screen`
|
||||
let propMode = mode;
|
||||
|
||||
if (mode === 'paged') {
|
||||
const wideScreen = $(window).width() > $(window).height();
|
||||
propMode = wideScreen ? 'height' : 'width';
|
||||
}
|
||||
|
||||
bind();
|
||||
});
|
||||
$('#page-select').change(function() {
|
||||
jumpTo(parseInt($('#page-select').val()));
|
||||
});
|
||||
setProp('mode', propMode);
|
||||
|
||||
function showControl(idx) {
|
||||
if (mode === 'continuous') {
|
||||
waitForPage(items.length, () => {
|
||||
setupScroller();
|
||||
});
|
||||
}
|
||||
|
||||
waitForPage(targetPage, () => {
|
||||
setTimeout(() => {
|
||||
toPage(targetPage);
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get dimension of the pages in the entry from the API and update the view
|
||||
*/
|
||||
const getPages = () => {
|
||||
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
||||
.then(data => {
|
||||
if (!data.success && data.error)
|
||||
throw new Error(resp.error);
|
||||
const dimensions = data.dimensions;
|
||||
|
||||
items = dimensions.map((d, i) => {
|
||||
return {
|
||||
id: i + 1,
|
||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||
width: d.width,
|
||||
height: d.height
|
||||
};
|
||||
});
|
||||
|
||||
const avgRatio = items.reduce((acc, cur) => {
|
||||
return acc + cur.height / cur.width
|
||||
}, 0) / items.length;
|
||||
|
||||
console.log(avgRatio);
|
||||
longPages = avgRatio > 2;
|
||||
|
||||
setProp('items', items);
|
||||
setProp('loading', false);
|
||||
|
||||
const storedMode = localStorage.getItem('mode') || 'continuous';
|
||||
|
||||
setProp('mode', storedMode);
|
||||
updateMode(storedMode, page);
|
||||
$('#mode-select').val(storedMode);
|
||||
})
|
||||
.catch(e => {
|
||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||
console.error(e);
|
||||
setProp('alertClass', 'uk-alert-danger');
|
||||
setProp('msg', errMsg);
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Jump to a specific page
|
||||
*
|
||||
* @function toPage
|
||||
* @param {number} idx - One-based index of the page
|
||||
*/
|
||||
const toPage = (idx) => {
|
||||
const mode = getProp('mode');
|
||||
if (mode === 'continuous') {
|
||||
$(`#${idx}`).get(0).scrollIntoView(true);
|
||||
} else {
|
||||
if (idx >= 1 && idx <= items.length) {
|
||||
setProp('curItem', items[idx - 1]);
|
||||
}
|
||||
}
|
||||
replaceHistory(idx);
|
||||
UIkit.modal($('#modal-sections')).hide();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a page exists every 100ms. If so, invoke the callback function.
|
||||
*
|
||||
* @function waitForPage
|
||||
* @param {number} idx - One-based index of the page
|
||||
* @param {function} cb - Callback function
|
||||
*/
|
||||
const waitForPage = (idx, cb) => {
|
||||
if ($(`#${idx}`).length > 0) return cb();
|
||||
setTimeout(() => {
|
||||
waitForPage(idx, cb)
|
||||
}, 100);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the control modal
|
||||
*
|
||||
* @function showControl
|
||||
* @param {string} idx - One-based index of the current page
|
||||
*/
|
||||
const showControl = (idx) => {
|
||||
const pageCount = $('#page-select > option').length;
|
||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||
$('#progress-label').text(progressText);
|
||||
@@ -66,19 +151,143 @@ function showControl(idx) {
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
}
|
||||
|
||||
function jumpTo(page) {
|
||||
var ary = window.location.pathname.split('/');
|
||||
ary[ary.length - 1] = page;
|
||||
ary.shift(); // remove leading `/`
|
||||
ary.unshift(window.location.origin);
|
||||
window.location.replace(ary.join('/'));
|
||||
}
|
||||
|
||||
function replaceHistory(url) {
|
||||
history.replaceState(null, "", url);
|
||||
console.log('reading ' + url);
|
||||
}
|
||||
|
||||
function redirect(url) {
|
||||
/**
|
||||
* Redirect to a URL
|
||||
*
|
||||
* @function redirect
|
||||
* @param {string} url - The target URL
|
||||
*/
|
||||
const redirect = (url) => {
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the address bar history and save th ereading progress if necessary
|
||||
*
|
||||
* @function replaceHistory
|
||||
* @param {number} idx - One-based index of the current page
|
||||
*/
|
||||
const replaceHistory = (idx) => {
|
||||
const ary = window.location.pathname.split('/');
|
||||
ary[ary.length - 1] = idx;
|
||||
ary.shift(); // remove leading `/`
|
||||
ary.unshift(window.location.origin);
|
||||
const url = ary.join('/');
|
||||
saveProgress(idx);
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the scroll handler that calls `replaceHistory` when an image
|
||||
* enters the view port
|
||||
*
|
||||
* @function setupScroller
|
||||
*/
|
||||
const setupScroller = () => {
|
||||
const mode = getProp('mode');
|
||||
if (mode !== 'continuous') return;
|
||||
$('#root img').each((idx, el) => {
|
||||
$(el).on('inview', (event, inView) => {
|
||||
if (inView) {
|
||||
const current = $(event.currentTarget).attr('id');
|
||||
|
||||
setProp('curItem', getProp('items')[current - 1]);
|
||||
replaceHistory(current);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the backend reading progress if:
|
||||
* 1) the current page is more than five pages away from the last
|
||||
* saved page, or
|
||||
* 2) the average height/width ratio of the pages is over 2, or
|
||||
* 3) the current page is the first page, or
|
||||
* 4) the current page is the last page
|
||||
*
|
||||
* @function saveProgress
|
||||
* @param {number} idx - One-based index of the page
|
||||
* @param {function} cb - Callback
|
||||
*/
|
||||
const saveProgress = (idx, cb) => {
|
||||
idx = parseInt(idx);
|
||||
if (Math.abs(idx - lastSavedPage) >= 5 ||
|
||||
longPages ||
|
||||
idx === 1 || idx === items.length
|
||||
) {
|
||||
lastSavedPage = idx;
|
||||
console.log('saving progress', idx);
|
||||
|
||||
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
||||
$.ajax({
|
||||
method: 'PUT',
|
||||
url: url,
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error)
|
||||
alert('danger', data.error);
|
||||
if (cb) cb();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark progress to 100% and redirect to the next entry
|
||||
* Used as the onclick handler for the "Next Entry" button
|
||||
*
|
||||
* @function nextEntry
|
||||
* @param {string} nextUrl - URL of the next entry
|
||||
*/
|
||||
const nextEntry = (nextUrl) => {
|
||||
saveProgress(items.length, () => {
|
||||
redirect(nextUrl);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the next or the previous page
|
||||
*
|
||||
* @function flipPage
|
||||
* @param {bool} isNext - Whether we are going to the next page
|
||||
*/
|
||||
const flipPage = (isNext) => {
|
||||
const curItem = getProp('curItem');
|
||||
const idx = parseInt(curItem.id);
|
||||
const delta = isNext ? 1 : -1;
|
||||
const newIdx = idx + delta;
|
||||
|
||||
toPage(newIdx);
|
||||
|
||||
if (isNext)
|
||||
setProp('flipAnimation', 'right');
|
||||
else
|
||||
setProp('flipAnimation', 'left');
|
||||
|
||||
setTimeout(() => {
|
||||
setProp('flipAnimation', null);
|
||||
}, 500);
|
||||
|
||||
replaceHistory(newIdx);
|
||||
saveProgress(newIdx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the global keydown events
|
||||
*
|
||||
* @function keyHandler
|
||||
* @param {event} event - The $event object
|
||||
*/
|
||||
const keyHandler = (event) => {
|
||||
const mode = getProp('mode');
|
||||
if (mode === 'continuous') return;
|
||||
|
||||
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||
flipPage(false);
|
||||
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||
flipPage(true);
|
||||
};
|
||||
|
||||
5
public/js/solid.min.js
vendored
5
public/js/solid.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,72 +0,0 @@
|
||||
// https://flaviocopes.com/javascript-detect-dark-mode/
|
||||
const preferDarkMode = () => {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const validThemeSetting = (theme) => {
|
||||
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||
};
|
||||
|
||||
// dark / light / system
|
||||
const loadThemeSetting = () => {
|
||||
let str = localStorage.getItem('theme');
|
||||
if (!str || !validThemeSetting(str)) str = 'light';
|
||||
return str;
|
||||
};
|
||||
|
||||
// dark / light
|
||||
const loadTheme = () => {
|
||||
let setting = loadThemeSetting();
|
||||
if (setting === 'system') {
|
||||
setting = preferDarkMode() ? 'dark' : 'light';
|
||||
}
|
||||
return setting;
|
||||
};
|
||||
|
||||
const saveThemeSetting = setting => {
|
||||
if (!validThemeSetting(setting)) setting = 'light';
|
||||
localStorage.setItem('theme', setting);
|
||||
};
|
||||
|
||||
// when toggled, Auto will be changed to light or dark
|
||||
const toggleTheme = () => {
|
||||
const theme = loadTheme();
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
saveThemeSetting(newTheme);
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const setTheme = (theme) => {
|
||||
if (!theme) theme = loadTheme();
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// do it before document is ready to prevent the initial flash of white on
|
||||
// most pages
|
||||
setTheme();
|
||||
|
||||
$(() => {
|
||||
// hack for the reader page
|
||||
setTheme();
|
||||
|
||||
// on system dark mode setting change
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', event => {
|
||||
if (loadThemeSetting() === 'system')
|
||||
setTheme(event.matches ? 'dark' : 'light');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
|
||||
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
||||
|
||||
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
|
||||
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
|
||||
|
||||
UIkit.modal($('#modal')).show();
|
||||
}
|
||||
@@ -63,18 +63,27 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
const updateProgress = (tid, eid, page) => {
|
||||
let url = `${base_url}api/progress/${tid}/${page}`
|
||||
const query = $.param({
|
||||
entry: eid
|
||||
eid: eid
|
||||
});
|
||||
if (eid)
|
||||
url += `?${query}`;
|
||||
$.post(url, (data) => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
error = data.error;
|
||||
alert('danger', error);
|
||||
}
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
method: 'PUT',
|
||||
url: url,
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
error = data.error;
|
||||
alert('danger', error);
|
||||
}
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
const renameSubmit = (name, eid) => {
|
||||
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
|
||||
}
|
||||
|
||||
const query = $.param({
|
||||
entry: eid
|
||||
eid: eid
|
||||
});
|
||||
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||
if (eid)
|
||||
url += `?${query}`;
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
type: 'PUT',
|
||||
url: url,
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
@@ -131,6 +140,7 @@ const edit = (eid) => {
|
||||
|
||||
const displayNameField = $('#display-name-field');
|
||||
displayNameField.attr('value', displayName);
|
||||
console.log(displayNameField);
|
||||
displayNameField.keyup(event => {
|
||||
if (event.keyCode === 13) {
|
||||
renameSubmit(displayNameField.val(), eid);
|
||||
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
|
||||
const bar = $('#upload-progress').get(0);
|
||||
const titleId = upload.attr('data-title-id');
|
||||
const queryObj = {
|
||||
title: titleId
|
||||
tid: titleId
|
||||
};
|
||||
if (eid)
|
||||
queryObj['entry'] = eid;
|
||||
queryObj['eid'] = eid;
|
||||
const query = $.param(queryObj);
|
||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||
console.log(url);
|
||||
@@ -182,3 +192,63 @@ const setupUpload = (eid) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
data['selected'] = false;
|
||||
});
|
||||
$('#select-bar')[0].__x.$data['count'] = 0;
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
let count = 0;
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
if (!data['disabled']) {
|
||||
data['selected'] = true;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
$('#select-bar')[0].__x.$data['count'] = count;
|
||||
};
|
||||
|
||||
const selectedIDs = () => {
|
||||
const ary = [];
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
if (!data['disabled'] && data['selected']) {
|
||||
const item = $(e).closest('.item');
|
||||
ary.push($(item).attr('id'));
|
||||
}
|
||||
});
|
||||
return ary;
|
||||
};
|
||||
|
||||
const bulkProgress = (action, el) => {
|
||||
const tid = $(el).attr('data-id');
|
||||
const ids = selectedIDs();
|
||||
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
|
||||
$.ajax({
|
||||
type: 'PUT',
|
||||
url: url,
|
||||
contentType: "application/json",
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
ids: ids
|
||||
})
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
deselectAll();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
function remove(username) {
|
||||
$.post(base_url + 'api/admin/user/delete/' + username, function(data) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
else {
|
||||
error = data.error;
|
||||
alert('danger', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
const remove = (username) => {
|
||||
$.ajax({
|
||||
url: `${base_url}api/admin/user/delete/${username}`,
|
||||
type: 'DELETE',
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.success)
|
||||
location.reload();
|
||||
else
|
||||
alert('danger', data.error);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
48
shard.lock
48
shard.lock
@@ -1,54 +1,70 @@
|
||||
version: 1.0
|
||||
version: 2.0
|
||||
shards:
|
||||
ameba:
|
||||
github: crystal-ameba/ameba
|
||||
git: https://github.com/crystal-ameba/ameba.git
|
||||
version: 0.12.1
|
||||
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
git: https://github.com/hkalexling/archive.cr.git
|
||||
version: 0.4.0
|
||||
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
version: 0.9.8
|
||||
git: https://github.com/schovi/baked_file_system.git
|
||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||
|
||||
clim:
|
||||
github: at-grandpa/clim
|
||||
git: https://github.com/at-grandpa/clim.git
|
||||
version: 0.12.0
|
||||
|
||||
db:
|
||||
github: crystal-lang/crystal-db
|
||||
git: https://github.com/crystal-lang/crystal-db.git
|
||||
version: 0.9.0
|
||||
|
||||
duktape:
|
||||
github: jessedoyle/duktape.cr
|
||||
git: https://github.com/jessedoyle/duktape.cr.git
|
||||
version: 0.20.0
|
||||
|
||||
exception_page:
|
||||
github: crystal-loot/exception_page
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.1.4
|
||||
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
version: 0.7.1
|
||||
|
||||
image_size:
|
||||
git: https://github.com/hkalexling/image_size.cr.git
|
||||
version: 0.4.0
|
||||
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
version: 0.26.1
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 0.27.0
|
||||
|
||||
kemal-session:
|
||||
github: kemalcr/kemal-session
|
||||
git: https://github.com/kemalcr/kemal-session.git
|
||||
version: 0.12.1
|
||||
|
||||
kilt:
|
||||
github: jeromegn/kilt
|
||||
git: https://github.com/jeromegn/kilt.git
|
||||
version: 0.4.0
|
||||
|
||||
koa:
|
||||
git: https://github.com/hkalexling/koa.git
|
||||
version: 0.5.0
|
||||
|
||||
myhtml:
|
||||
github: kostya/myhtml
|
||||
git: https://github.com/kostya/myhtml.git
|
||||
version: 1.5.1
|
||||
|
||||
open_api:
|
||||
git: https://github.com/jreinert/open_api.cr.git
|
||||
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
||||
|
||||
radix:
|
||||
github: luislavena/radix
|
||||
git: https://github.com/luislavena/radix.git
|
||||
version: 0.3.9
|
||||
|
||||
sqlite3:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||
version: 0.16.0
|
||||
|
||||
|
||||
11
shard.yml
11
shard.yml
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.9.0
|
||||
version: 0.17.0
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -8,7 +8,7 @@ targets:
|
||||
mango:
|
||||
main: src/mango.cr
|
||||
|
||||
crystal: 0.34.0
|
||||
crystal: 0.35.1
|
||||
|
||||
license: MIT
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
ameba:
|
||||
@@ -32,3 +33,9 @@ dependencies:
|
||||
version: ~> 0.20.0
|
||||
myhtml:
|
||||
github: kostya/myhtml
|
||||
http_proxy:
|
||||
github: mamantoha/http_proxy
|
||||
image_size:
|
||||
github: hkalexling/image_size.cr
|
||||
koa:
|
||||
github: hkalexling/koa
|
||||
|
||||
@@ -2,6 +2,7 @@ require "spec"
|
||||
require "../src/queue"
|
||||
require "../src/server"
|
||||
require "../src/config"
|
||||
require "../src/main_fiber"
|
||||
|
||||
class State
|
||||
@@hash = {} of String => String
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
require "zip"
|
||||
require "compress/zip"
|
||||
require "archive"
|
||||
|
||||
# A unified class to handle all supported archive formats. It uses the ::Zip
|
||||
# module in crystal standard library if the target file is a zip archive.
|
||||
# Otherwise it uses `archive.cr`.
|
||||
# A unified class to handle all supported archive formats. It uses the
|
||||
# Compress::Zip module in crystal standard library if the target file is
|
||||
# a zip archive. Otherwise it uses `archive.cr`.
|
||||
class ArchiveFile
|
||||
def initialize(@filename : String)
|
||||
if [".cbz", ".zip"].includes? File.extname filename
|
||||
@archive_file = Zip::File.new filename
|
||||
@archive_file = Compress::Zip::File.new filename
|
||||
else
|
||||
@archive_file = Archive::File.new filename
|
||||
end
|
||||
@@ -20,16 +20,16 @@ class ArchiveFile
|
||||
end
|
||||
|
||||
def close
|
||||
if @archive_file.is_a? Zip::File
|
||||
@archive_file.as(Zip::File).close
|
||||
if @archive_file.is_a? Compress::Zip::File
|
||||
@archive_file.as(Compress::Zip::File).close
|
||||
end
|
||||
end
|
||||
|
||||
# Lists all file entries
|
||||
def entries
|
||||
ary = [] of Zip::File::Entry | Archive::Entry
|
||||
ary = [] of Compress::Zip::File::Entry | Archive::Entry
|
||||
@archive_file.entries.map do |e|
|
||||
if (e.is_a? Zip::File::Entry && e.file?) ||
|
||||
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
|
||||
(e.is_a? Archive::Entry && e.info.file?)
|
||||
ary.push e
|
||||
end
|
||||
@@ -37,8 +37,8 @@ class ArchiveFile
|
||||
ary
|
||||
end
|
||||
|
||||
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
|
||||
if e.is_a? Zip::File::Entry
|
||||
def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
|
||||
if e.is_a? Compress::Zip::File::Entry
|
||||
data = nil
|
||||
e.open do |io|
|
||||
slice = Bytes.new e.uncompressed_size
|
||||
|
||||
@@ -11,13 +11,15 @@ class Config
|
||||
property library_path : String = File.expand_path "~/mango/library",
|
||||
home: true
|
||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||
@[YAML::Field(key: "scan_interval_minutes")]
|
||||
property scan_interval : Int32 = 5
|
||||
property scan_interval_minutes : Int32 = 5
|
||||
property thumbnail_generation_interval_hours : Int32 = 24
|
||||
property db_optimization_interval_hours : Int32 = 24
|
||||
property log_level : String = "info"
|
||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||
home: true
|
||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||
home: true
|
||||
property download_timeout_seconds : Int32 = 30
|
||||
property mangadex = Hash(String, String | Int32).new
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@@ -52,12 +54,8 @@ class Config
|
||||
config.fill_defaults
|
||||
return config
|
||||
end
|
||||
puts "The config file #{cfg_path} does not exist." \
|
||||
" Do you want mango to dump the default config there? [Y/n]"
|
||||
input = gets
|
||||
if input && input.downcase == "n"
|
||||
abort "Aborting..."
|
||||
end
|
||||
puts "The config file #{cfg_path} does not exist. " \
|
||||
"Dumping the default config there."
|
||||
default = self.allocate
|
||||
default.path = path
|
||||
default.fill_defaults
|
||||
|
||||
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
|
||||
|
||||
slice = Bytes.new file.size
|
||||
file.read slice
|
||||
return send_file env, slice, file.mime_type
|
||||
return send_file env, slice, MIME.from_filename file.path
|
||||
end
|
||||
call_next env
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require "image_size"
|
||||
|
||||
class Entry
|
||||
property zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, encoded_path : String,
|
||||
@@ -45,8 +47,7 @@ class Entry
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id",
|
||||
"encoded_path", "encoded_title"] %}
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "title_id", @book.id
|
||||
@@ -67,7 +68,7 @@ class Entry
|
||||
|
||||
def cover_url
|
||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1"
|
||||
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
@@ -77,11 +78,9 @@ class Entry
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||
img = nil
|
||||
private def sorted_archive_entries
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
entries = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
@@ -89,7 +88,15 @@ class Entry
|
||||
.sort { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
yield file, entries
|
||||
end
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||
img = nil
|
||||
sorted_archive_entries do |file, entries|
|
||||
page = entries[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
@@ -99,6 +106,26 @@ class Entry
|
||||
img
|
||||
end
|
||||
|
||||
def page_dimensions
|
||||
sizes = [] of Hash(String, Int32)
|
||||
sorted_archive_entries do |file, entries|
|
||||
entries.each_with_index do |e, i|
|
||||
begin
|
||||
data = file.read_entry(e).not_nil!
|
||||
size = ImageSize.get data
|
||||
sizes << {
|
||||
"width" => size.width,
|
||||
"height" => size.height,
|
||||
}
|
||||
rescue e
|
||||
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
||||
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||
end
|
||||
end
|
||||
end
|
||||
sizes
|
||||
end
|
||||
|
||||
def next_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
@@ -179,4 +206,33 @@ class Entry
|
||||
def started?(username)
|
||||
load_progress(username) > 0
|
||||
end
|
||||
|
||||
def generate_thumbnail : Image?
|
||||
return if @err_msg
|
||||
|
||||
img = read_page(1).not_nil!
|
||||
begin
|
||||
size = ImageSize.get img.data
|
||||
if size.height > size.width
|
||||
thumbnail = ImageSize.resize img.data, width: 200
|
||||
else
|
||||
thumbnail = ImageSize.resize img.data, height: 300
|
||||
end
|
||||
img.data = thumbnail
|
||||
img.size = thumbnail.size
|
||||
unless img.mime == "image/webp"
|
||||
# image_size.cr resizes non-webp images to jpg
|
||||
img.mime = "image/jpeg"
|
||||
end
|
||||
Storage.default.save_thumbnail @id, img
|
||||
rescue e
|
||||
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
|
||||
end
|
||||
|
||||
img
|
||||
end
|
||||
|
||||
def get_thumbnail : Image?
|
||||
Storage.default.get_thumbnail @id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
property dir : String, title_ids : Array(String),
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
use_default
|
||||
@@ -8,20 +8,48 @@ class Library
|
||||
register_mime_types
|
||||
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
return scan if @scan_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
sleep @scan_interval * 60
|
||||
@entries_count = 0
|
||||
@thumbnails_count = 0
|
||||
|
||||
scan_interval = Config.current.scan_interval_minutes
|
||||
if scan_interval < 1
|
||||
scan
|
||||
else
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
sleep scan_interval.minutes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
|
||||
unless thumbnail_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
# Wait for scan to complete (in most cases)
|
||||
sleep 1.minutes
|
||||
generate_thumbnails
|
||||
sleep thumbnail_interval.hours
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
db_interval = Config.current.db_optimization_interval_hours
|
||||
unless db_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
Storage.default.optimize
|
||||
sleep db_interval.hours
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -30,6 +58,41 @@ class Library
|
||||
@title_ids.map { |tid| self.get_title!(tid) }
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# This is a hack to bypass a compiler bug
|
||||
ary = titles
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
end
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
end
|
||||
@@ -57,7 +120,6 @@ class Library
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
@@ -68,6 +130,7 @@ class Library
|
||||
.map { |path| Title.new path, "", storage, self }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.tap { |_| @title_ids.clear }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
@@ -83,7 +146,7 @@ class Library
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0..11]
|
||||
.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
|
||||
@@ -143,41 +206,66 @@ class Library
|
||||
end
|
||||
end
|
||||
|
||||
recently_added[0..11]
|
||||
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
def get_start_reading_titles(username)
|
||||
# Here we are not using `deep_titles` as it may cause unexpected behaviors
|
||||
# For example, consider the following nested titles:
|
||||
# - One Puch Man
|
||||
# - Vol. 1
|
||||
# - Vol. 2
|
||||
# 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 }
|
||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||
.shuffle
|
||||
end
|
||||
|
||||
def thumbnail_generation_progress
|
||||
return 0 if @entries_count == 0
|
||||
@thumbnails_count / @entries_count
|
||||
end
|
||||
|
||||
def generate_thumbnails
|
||||
if @thumbnails_count > 0
|
||||
Logger.debug "Thumbnail generation in progress"
|
||||
return
|
||||
end
|
||||
|
||||
Logger.info "Starting thumbnail generation"
|
||||
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
||||
@entries_count = entries.size
|
||||
@thumbnails_count = 0
|
||||
|
||||
# Report generation progress regularly
|
||||
spawn do
|
||||
loop do
|
||||
unless @thumbnails_count == 0
|
||||
Logger.debug "Thumbnail generation progress: " \
|
||||
"#{(thumbnail_generation_progress * 100).round 1}%"
|
||||
end
|
||||
# Generation is completed. We reset the count to 0 to allow subsequent
|
||||
# calls to the function, and break from the loop to stop the progress
|
||||
# report fiber
|
||||
if thumbnail_generation_progress.to_i == 1
|
||||
@thumbnails_count = 0
|
||||
break
|
||||
end
|
||||
sleep 10.seconds
|
||||
end
|
||||
end
|
||||
|
||||
# This is a hack to bypass a compiler bug
|
||||
ary = titles
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
entries.each do |e|
|
||||
unless e.get_thumbnail
|
||||
e.generate_thumbnail
|
||||
# Sleep after each generation to minimize the impact on disk IO
|
||||
# and CPU
|
||||
sleep 0.5.seconds
|
||||
end
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||
@thumbnails_count += 1
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
Logger.info "Thumbnail generation finished"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -56,7 +56,7 @@ class Title
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", display_name
|
||||
@@ -355,4 +355,24 @@ class Title
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
end
|
||||
|
||||
def bulk_progress(action, ids : Array(String), username)
|
||||
selected_entries = ids
|
||||
.map { |id|
|
||||
@entries.find { |e| e.id == id }
|
||||
}
|
||||
.select(Entry)
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
selected_entries.each do |e|
|
||||
page = action == "read" ? e.pages : 0
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {e.title => page}
|
||||
else
|
||||
info.progress[username][e.title] = page
|
||||
end
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,6 +57,16 @@ struct Image
|
||||
|
||||
def initialize(@data, @mime, @filename, @size)
|
||||
end
|
||||
|
||||
def self.from_db(res : DB::ResultSet)
|
||||
img = Image.allocate
|
||||
res.read String
|
||||
img.data = res.read Bytes
|
||||
img.filename = res.read String
|
||||
img.mime = res.read String
|
||||
img.size = res.read Int32
|
||||
img
|
||||
end
|
||||
end
|
||||
|
||||
class TitleInfo
|
||||
|
||||
@@ -26,9 +26,9 @@ class Logger
|
||||
{% end %}
|
||||
|
||||
@log = Log.for("")
|
||||
|
||||
@backend = Log::IOBackend.new
|
||||
@backend.formatter = ->(entry : Log::Entry, io : IO) do
|
||||
|
||||
format_proc = ->(entry : Log::Entry, io : IO) do
|
||||
color = :default
|
||||
{% begin %}
|
||||
case entry.severity.label.to_s().downcase
|
||||
@@ -45,12 +45,14 @@ class Logger
|
||||
io << entry.message
|
||||
end
|
||||
|
||||
Log.builder.bind "*", @@severity, @backend
|
||||
@backend.formatter = Log::Formatter.new &format_proc
|
||||
Log.setup @@severity, @backend
|
||||
end
|
||||
|
||||
# Ignores @@severity and always log msg
|
||||
def log(msg)
|
||||
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
||||
@backend.write Log::Entry.new "", Log::Severity::None, msg,
|
||||
Log::Metadata.empty, nil
|
||||
end
|
||||
|
||||
def self.log(msg)
|
||||
|
||||
34
src/main_fiber.cr
Normal file
34
src/main_fiber.cr
Normal file
@@ -0,0 +1,34 @@
|
||||
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
|
||||
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
|
||||
# This is a temporary workaround that forces the relevant code to run in the
|
||||
# main fiber
|
||||
|
||||
class MainFiber
|
||||
@@channel = Channel(-> Nil).new
|
||||
@@done = Channel(Bool).new
|
||||
@@main_fiber = Fiber.current
|
||||
|
||||
def self.start_and_block
|
||||
loop do
|
||||
if proc = @@channel.receive
|
||||
begin
|
||||
proc.call
|
||||
ensure
|
||||
@@done.send true
|
||||
end
|
||||
end
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
||||
def self.run(&block : -> Nil)
|
||||
if @@main_fiber == Fiber.current
|
||||
block.call
|
||||
else
|
||||
@@channel.send block
|
||||
until @@done.receive
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,3 @@
|
||||
require "http/client"
|
||||
require "json"
|
||||
require "csv"
|
||||
require "../rename"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
require "./api"
|
||||
require "zip"
|
||||
require "compress/zip"
|
||||
|
||||
module MangaDex
|
||||
class PageJob
|
||||
property success = false
|
||||
property url : String
|
||||
property filename : String
|
||||
property writer : Zip::Writer
|
||||
property writer : Compress::Zip::Writer
|
||||
property tries_remaning : Int32
|
||||
|
||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||
@@ -27,14 +27,16 @@ module MangaDex
|
||||
|
||||
def pop : Queue::Job?
|
||||
job = nil
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id not like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id not like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
end
|
||||
rescue
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
job
|
||||
@@ -67,7 +69,7 @@ module MangaDex
|
||||
# Find the number of digits needed to store the number of pages
|
||||
len = Math.log10(chapter.pages.size).to_i + 1
|
||||
|
||||
writer = Zip::Writer.new zip_path
|
||||
writer = Compress::Zip::Writer.new zip_path
|
||||
# Create a buffered channel. It works as an FIFO queue
|
||||
channel = Channel(PageJob).new chapter.pages.size
|
||||
spawn do
|
||||
@@ -89,6 +91,7 @@ module MangaDex
|
||||
end
|
||||
|
||||
channel.send page_job
|
||||
break unless @queue.exists? job
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,6 +99,9 @@ module MangaDex
|
||||
page_jobs = [] of PageJob
|
||||
chapter.pages.size.times do
|
||||
page_job = channel.receive
|
||||
|
||||
break unless @queue.exists? job
|
||||
|
||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||
"#{page_job.url}"
|
||||
page_jobs << page_job
|
||||
@@ -108,6 +114,13 @@ module MangaDex
|
||||
Logger.error msg
|
||||
end
|
||||
end
|
||||
|
||||
unless @queue.exists? job
|
||||
Logger.debug "Download cancelled"
|
||||
@downloading = false
|
||||
next
|
||||
end
|
||||
|
||||
fail_count = page_jobs.count { |j| !j.success }
|
||||
Logger.debug "Download completed. " \
|
||||
"#{fail_count}/#{page_jobs.size} failed"
|
||||
|
||||
37
src/mango.cr
37
src/mango.cr
@@ -1,12 +1,29 @@
|
||||
require "./config"
|
||||
require "./queue"
|
||||
require "./server"
|
||||
require "./main_fiber"
|
||||
require "./mangadex/*"
|
||||
require "option_parser"
|
||||
require "clim"
|
||||
require "./plugin/*"
|
||||
|
||||
MANGO_VERSION = "0.9.0"
|
||||
MANGO_VERSION = "0.17.0"
|
||||
|
||||
# From http://www.network-science.de/ascii/
|
||||
BANNER = %{
|
||||
|
||||
_| _|
|
||||
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
|
||||
_| _| _| _| _| _| _| _| _| _| _|
|
||||
_| _| _| _| _| _| _| _| _| _|
|
||||
_| _| _|_|_| _| _| _|_|_| _|_|
|
||||
_|
|
||||
_|_|
|
||||
|
||||
|
||||
}
|
||||
|
||||
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
||||
|
||||
macro common_option
|
||||
option "-c PATH", "--config=PATH", type: String,
|
||||
@@ -22,20 +39,28 @@ end
|
||||
|
||||
class CLI < Clim
|
||||
main do
|
||||
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
||||
desc DESCRIPTION
|
||||
usage "mango [sub_command] [options]"
|
||||
help short: "-h"
|
||||
version "Version #{MANGO_VERSION}", short: "-v"
|
||||
common_option
|
||||
run do |opts|
|
||||
puts BANNER
|
||||
puts DESCRIPTION
|
||||
puts
|
||||
|
||||
# empty ARGV so it won't be passed to Kemal
|
||||
ARGV.clear
|
||||
|
||||
Config.load(opts.config).set_current
|
||||
MangaDex::Downloader.default
|
||||
Plugin::Downloader.default
|
||||
|
||||
# empty ARGV so it won't be passed to Kemal
|
||||
ARGV.clear
|
||||
server = Server.new
|
||||
server.start
|
||||
spawn do
|
||||
Server.new.start
|
||||
end
|
||||
|
||||
MainFiber.start_and_block
|
||||
end
|
||||
|
||||
sub "admin" do
|
||||
|
||||
@@ -8,14 +8,16 @@ class Plugin
|
||||
|
||||
def pop : Queue::Job?
|
||||
job = nil
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
end
|
||||
rescue
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
job
|
||||
@@ -51,7 +53,7 @@ class Plugin
|
||||
end
|
||||
|
||||
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
||||
writer = Zip::Writer.new zip_path
|
||||
writer = Compress::Zip::Writer.new zip_path
|
||||
rescue e
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
unless e.message.nil?
|
||||
@@ -64,6 +66,8 @@ class Plugin
|
||||
fail_count = 0
|
||||
|
||||
while page = plugin.next_page
|
||||
break unless @queue.exists? job
|
||||
|
||||
fn = process_filename page["filename"].as_s
|
||||
url = page["url"].as_s
|
||||
headers = HTTP::Headers.new
|
||||
@@ -107,6 +111,12 @@ class Plugin
|
||||
end
|
||||
end
|
||||
|
||||
unless @queue.exists? job
|
||||
Logger.debug "Download cancelled"
|
||||
@downloading = false
|
||||
return
|
||||
end
|
||||
|
||||
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
||||
writer.close
|
||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
require "duktape/runtime"
|
||||
require "myhtml"
|
||||
require "http"
|
||||
require "xml"
|
||||
|
||||
class Plugin
|
||||
|
||||
161
src/queue.cr
161
src/queue.cr
@@ -119,22 +119,24 @@ class Queue
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
db.exec "create table if not exists queue " \
|
||||
"(id text, manga_id text, title text, manga_title " \
|
||||
"text, status integer, status_message text, " \
|
||||
"pages integer, success_count integer, " \
|
||||
"fail_count integer, time integer)"
|
||||
db.exec "create unique index if not exists id_idx " \
|
||||
"on queue (id)"
|
||||
db.exec "create index if not exists manga_id_idx " \
|
||||
"on queue (manga_id)"
|
||||
db.exec "create index if not exists status_idx " \
|
||||
"on queue (status)"
|
||||
rescue e
|
||||
Logger.error "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
db.exec "create table if not exists queue " \
|
||||
"(id text, manga_id text, title text, manga_title " \
|
||||
"text, status integer, status_message text, " \
|
||||
"pages integer, success_count integer, " \
|
||||
"fail_count integer, time integer)"
|
||||
db.exec "create unique index if not exists id_idx " \
|
||||
"on queue (id)"
|
||||
db.exec "create index if not exists manga_id_idx " \
|
||||
"on queue (manga_id)"
|
||||
db.exec "create index if not exists status_idx " \
|
||||
"on queue (status)"
|
||||
rescue e
|
||||
Logger.error "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -143,23 +145,27 @@ class Queue
|
||||
# inserted. Any job already exists in the queue will be ignored.
|
||||
def push(jobs : Array(Job))
|
||||
start_count = self.count
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs.each do |job|
|
||||
db.exec "insert or ignore into queue values " \
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
job.id, job.manga_id, job.title, job.manga_title,
|
||||
job.status.to_i, job.status_message, job.pages,
|
||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs.each do |job|
|
||||
db.exec "insert or ignore into queue values " \
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
job.id, job.manga_id, job.title, job.manga_title,
|
||||
job.status.to_i, job.status_message, job.pages,
|
||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||
end
|
||||
end
|
||||
end
|
||||
self.count - start_count
|
||||
end
|
||||
|
||||
def reset(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where id = (?)", id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where id = (?)", id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -169,16 +175,20 @@ class Queue
|
||||
|
||||
# Reset all failed tasks (missing pages and error)
|
||||
def reset
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where status = 2 or status = 4"
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where status = 2 or status = 4"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where id = (?)", id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where id = (?)", id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -186,72 +196,105 @@ class Queue
|
||||
self.delete job.id
|
||||
end
|
||||
|
||||
def exists?(id : String)
|
||||
res = false
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
res = db.query_one "select count(*) from queue where id = (?)", id,
|
||||
as: Bool
|
||||
end
|
||||
end
|
||||
res
|
||||
end
|
||||
|
||||
def exists?(job : Job)
|
||||
self.exists? job.id
|
||||
end
|
||||
|
||||
def delete_status(status : JobStatus)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where status = (?)", status.to_i
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where status = (?)", status.to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def count_status(status : JobStatus)
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue where " \
|
||||
"status = (?)", status.to_i, as: Int32
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue where " \
|
||||
"status = (?)", status.to_i, as: Int32
|
||||
end
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def count
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue", as: Int32
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue", as: Int32
|
||||
end
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def set_status(status : JobStatus, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = (?) where id = (?)",
|
||||
status.to_i, job.id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = (?) where id = (?)",
|
||||
status.to_i, job.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_all
|
||||
jobs = [] of Job
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||
Job.from_query_result rs
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||
Job.from_query_result rs
|
||||
end
|
||||
end
|
||||
end
|
||||
jobs
|
||||
end
|
||||
|
||||
def add_success(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set success_count = success_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set success_count = success_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_fail(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set_pages(pages : Int32, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||
"fail_count = 0 where id = (?)", pages, job.id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||
"fail_count = 0 where id = (?)", pages, job.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(msg : String, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status_message = " \
|
||||
"status_message || (?) || (?) where id = (?)",
|
||||
"\n", msg, job.id
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status_message = " \
|
||||
"status_message || (?) || (?) where id = (?)",
|
||||
"\n", msg, job.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,171 @@
|
||||
require "./router"
|
||||
require "../mangadex/*"
|
||||
require "../upload"
|
||||
require "koa"
|
||||
|
||||
class APIRouter < Router
|
||||
@@api_json : String?
|
||||
|
||||
API_VERSION = "0.1.0"
|
||||
|
||||
macro s(fields)
|
||||
{
|
||||
{% for field in fields %}
|
||||
{{field}} => "string",
|
||||
{% end %}
|
||||
}
|
||||
end
|
||||
|
||||
def initialize
|
||||
Koa.init "Mango API", version: API_VERSION, desc: <<-MD
|
||||
# A Word of Caution
|
||||
|
||||
This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
|
||||
|
||||
# Authentication
|
||||
|
||||
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
|
||||
|
||||
# Terminologies
|
||||
|
||||
- Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
|
||||
- Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
|
||||
- Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
|
||||
MD
|
||||
|
||||
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
|
||||
Koa.global_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"
|
||||
|
||||
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,
|
||||
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.object "progressResult", {
|
||||
"progress" => "number",
|
||||
}
|
||||
|
||||
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?",
|
||||
"error" => "string?",
|
||||
}
|
||||
|
||||
Koa.object "ids", {
|
||||
"ids" => "$strAry",
|
||||
}
|
||||
|
||||
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.response 500, "Page not found or not readable"
|
||||
get "/api/page/:tid/:eid/:page" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -26,6 +188,37 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
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 500, "Page not found or not readable"
|
||||
get "/api/cover/:tid/:eid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
eid = env.params.url["eid"]
|
||||
|
||||
title = @context.library.get_title tid
|
||||
raise "Title ID `#{tid}` not found" if title.nil?
|
||||
entry = title.get_entry eid
|
||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||
|
||||
img = entry.get_thumbnail || entry.read_page 1
|
||||
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
|
||||
if img.nil?
|
||||
|
||||
send_img env, img
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 500
|
||||
e.message
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Returns the book with title `tid`"
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.response 200, ref: "$title"
|
||||
Koa.response 404, "Title not found"
|
||||
get "/api/book/:tid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
@@ -35,15 +228,20 @@ class APIRouter < Router
|
||||
send_json env, title.to_json
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 500
|
||||
env.response.status_code = 404
|
||||
e.message
|
||||
end
|
||||
end
|
||||
|
||||
get "/api/book" do |env|
|
||||
Koa.describe "Returns the entire library with all titles and entries"
|
||||
Koa.response 200, ref: "$library"
|
||||
get "/api/library" do |env|
|
||||
send_json env, @context.library.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Triggers a library scan"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$scanResult"
|
||||
post "/api/admin/scan" do |env|
|
||||
start = Time.utc
|
||||
@context.library.scan
|
||||
@@ -54,7 +252,27 @@ class APIRouter < Router
|
||||
}.to_json
|
||||
end
|
||||
|
||||
post "/api/admin/user/delete/:username" do |env|
|
||||
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$progressResult"
|
||||
get "/api/admin/thumbnail_progress" do |env|
|
||||
send_json env, {
|
||||
"progress" => Library.default.thumbnail_generation_progress,
|
||||
}.to_json
|
||||
end
|
||||
|
||||
Koa.describe "Triggers a thumbnail generation"
|
||||
Koa.tag "admin"
|
||||
post "/api/admin/generate_thumbnails" do |env|
|
||||
spawn do
|
||||
Library.default.generate_thumbnails
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Deletes a user with `username`"
|
||||
Koa.tag "admin"
|
||||
Koa.response 200, ref: "$result"
|
||||
delete "/api/admin/user/delete/:username" do |env|
|
||||
begin
|
||||
username = env.params.url["username"]
|
||||
@context.storage.delete_user username
|
||||
@@ -69,13 +287,24 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/progress/:title/:page" do |env|
|
||||
Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
|
||||
When `eid` is provided, sets the reading progress of the entry to `page`.
|
||||
|
||||
When `eid` is omitted, updates the progress of the entire title. Specifically:
|
||||
|
||||
- if `page` is 0, marks the entire title as unread
|
||||
- otherwise, marks the entire title as read
|
||||
MD
|
||||
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"
|
||||
put "/api/progress/:tid/:page" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
title = (@context.library.get_title env.params.url["title"])
|
||||
.not_nil!
|
||||
title = (@context.library.get_title env.params.url["tid"]).not_nil!
|
||||
page = env.params.url["page"].to_i
|
||||
entry_id = env.params.query["entry"]?
|
||||
entry_id = env.params.query["eid"]?
|
||||
|
||||
if !entry_id.nil?
|
||||
entry = title.get_entry(entry_id).not_nil!
|
||||
@@ -97,12 +326,47 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/admin/display_name/:title/:name" do |env|
|
||||
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"
|
||||
put "/api/bulk_progress/:action/:tid" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["title"])
|
||||
username = get_username env
|
||||
title = (@context.library.get_title env.params.url["tid"]).not_nil!
|
||||
action = env.params.url["action"]
|
||||
ids = env.params.json["ids"].as(Array).map &.as_s
|
||||
|
||||
unless action.in? ["read", "unread"]
|
||||
raise "Unknow action #{action}"
|
||||
end
|
||||
title.bulk_progress action, ids, username
|
||||
rescue e
|
||||
@context.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
else
|
||||
send_json env, {"success" => true}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
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.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"
|
||||
put "/api/admin/display_name/:tid/:name" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["tid"])
|
||||
.not_nil!
|
||||
name = env.params.url["name"]
|
||||
entry = env.params.query["entry"]?
|
||||
entry = env.params.query["eid"]?
|
||||
if entry.nil?
|
||||
title.set_display_name name
|
||||
else
|
||||
@@ -120,6 +384,12 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
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.path "id", desc: "A MangaDex manga ID"
|
||||
Koa.response 200, ref: "$mangadexManga"
|
||||
get "/api/admin/mangadex/manga/:id" do |env|
|
||||
begin
|
||||
id = env.params.url["id"]
|
||||
@@ -132,6 +402,12 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
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"
|
||||
post "/api/admin/mangadex/download" do |env|
|
||||
begin
|
||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||
@@ -156,6 +432,23 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
ws "/api/admin/mangadex/queue" do |socket, env|
|
||||
interval_raw = env.params.query["interval"]?
|
||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||
loop do
|
||||
socket.send({
|
||||
"jobs" => @context.queue.get_all,
|
||||
"paused" => @context.queue.paused?,
|
||||
}.to_json)
|
||||
sleep interval.seconds
|
||||
end
|
||||
end
|
||||
|
||||
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"
|
||||
get "/api/admin/mangadex/queue" do |env|
|
||||
begin
|
||||
jobs = @context.queue.get_all
|
||||
@@ -172,6 +465,19 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD
|
||||
The `action` parameter can be `delete`, `retry`, `pause` or `resume`.
|
||||
|
||||
When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively.
|
||||
|
||||
When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs in the queue.
|
||||
|
||||
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.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"
|
||||
post "/api/admin/mangadex/queue/:action" do |env|
|
||||
begin
|
||||
action = env.params.url["action"]
|
||||
@@ -206,6 +512,22 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
Koa.describe "Uploads a file to the server", <<-MD
|
||||
Currently the only supported value for the `target` parameter is `cover`.
|
||||
|
||||
### Cover
|
||||
|
||||
Uploads a cover image for a title or an entry.
|
||||
|
||||
Query parameters:
|
||||
- `tid`: A title ID
|
||||
- `eid`: (Optional) An entry ID
|
||||
|
||||
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"
|
||||
post "/api/admin/upload/:target" do |env|
|
||||
begin
|
||||
target = env.params.url["target"]
|
||||
@@ -220,8 +542,8 @@ class APIRouter < Router
|
||||
|
||||
case target
|
||||
when "cover"
|
||||
title_id = env.params.query["title"]
|
||||
entry_id = env.params.query["entry"]?
|
||||
title_id = env.params.query["tid"]
|
||||
entry_id = env.params.query["eid"]?
|
||||
title = @context.library.get_title(title_id).not_nil!
|
||||
|
||||
unless SUPPORTED_IMG_TYPES.includes? \
|
||||
@@ -260,10 +582,14 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/admin/plugin/list" do |env|
|
||||
Koa.describe "Lists the chapters in a title from a plugin"
|
||||
Koa.tag "admin"
|
||||
Koa.body ref: "$pluginListBody"
|
||||
Koa.response 200, ref: "$pluginList"
|
||||
get "/api/admin/plugin/list" do |env|
|
||||
begin
|
||||
query = env.params.json["query"].as String
|
||||
plugin = Plugin.new env.params.json["plugin"].as String
|
||||
query = env.params.query["query"].as String
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
|
||||
json = plugin.list_chapters query
|
||||
chapters = json["chapters"]
|
||||
@@ -282,6 +608,10 @@ class APIRouter < Router
|
||||
end
|
||||
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"
|
||||
post "/api/admin/plugin/download" do |env|
|
||||
begin
|
||||
plugin = Plugin.new env.params.json["plugin"].as String
|
||||
@@ -310,5 +640,60 @@ class APIRouter < Router
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
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"
|
||||
get "/api/dimensions/:tid/:eid" do |env|
|
||||
begin
|
||||
tid = env.params.url["tid"]
|
||||
eid = env.params.url["eid"]
|
||||
|
||||
title = @context.library.get_title tid
|
||||
raise "Title ID `#{tid}` not found" if title.nil?
|
||||
entry = title.get_entry eid
|
||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||
|
||||
sizes = entry.page_dimensions
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"dimensions" => sizes,
|
||||
}.to_json
|
||||
rescue e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
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 404, "Entry not found"
|
||||
get "/api/download/:tid/:eid" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["tid"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||
|
||||
send_attachment env, entry.zip_path
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
|
||||
doc = Koa.generate
|
||||
@@api_json = doc.to_json if doc
|
||||
|
||||
get "/openapi.json" do |env|
|
||||
if @@api_json
|
||||
send_json env, @@api_json
|
||||
else
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +103,7 @@ class MainRouter < Router
|
||||
continue_reading = @context
|
||||
.library.get_continue_reading_entries username
|
||||
recently_added = @context.library.get_recently_added_entries username
|
||||
start_reading = @context.library.get_start_reading_titles username
|
||||
titles = @context.library.titles
|
||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||
empty_library = titles.size == 0
|
||||
@@ -112,5 +113,9 @@ class MainRouter < Router
|
||||
env.response.status_code = 500
|
||||
end
|
||||
end
|
||||
|
||||
get "/api" do |env|
|
||||
render "src/views/api.html.ecr"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,17 +16,5 @@ class OPDSRouter < Router
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
|
||||
get "/opds/download/:title/:entry" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||
|
||||
send_attachment env, entry.zip_path
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,11 +12,7 @@ class ReaderRouter < Router
|
||||
next layout "reader-error" if entry.err_msg
|
||||
|
||||
# load progress
|
||||
page = entry.load_progress username
|
||||
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
|
||||
# library perloads a few pages in advance, and the user
|
||||
# might not have actually read them
|
||||
page = [page - 2 * IMGS_PER_PAGE, 1].max
|
||||
page = [1, entry.load_progress username].max
|
||||
|
||||
# start from page 1 if the user has finished reading the entry
|
||||
page = 1 if entry.finished? username
|
||||
@@ -32,29 +28,17 @@ class ReaderRouter < Router
|
||||
begin
|
||||
base_url = Config.current.base_url
|
||||
|
||||
username = get_username env
|
||||
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||
page = env.params.url["page"].to_i
|
||||
raise "" if page > entry.pages || page <= 0
|
||||
|
||||
# save progress
|
||||
username = get_username env
|
||||
entry.save_progress username, page
|
||||
|
||||
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
|
||||
urls = pages.map { |idx|
|
||||
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
|
||||
}
|
||||
reader_urls = pages.map { |idx|
|
||||
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
|
||||
}
|
||||
next_page = page + IMGS_PER_PAGE
|
||||
next_url = next_entry_url = nil
|
||||
exit_url = "#{base_url}book/#{title.id}"
|
||||
|
||||
next_entry_url = nil
|
||||
next_entry = entry.next_entry username
|
||||
unless next_page > entry.pages
|
||||
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||
end
|
||||
unless next_entry.nil?
|
||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
||||
end
|
||||
|
||||
270
src/storage.cr
270
src/storage.cr
@@ -32,38 +32,42 @@ class Storage
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
# We create the `ids` table first. even if the uses has an
|
||||
# early version installed and has the `user` table only,
|
||||
# we will still be able to create `ids`
|
||||
db.exec "create table ids" \
|
||||
"(path text, id text, is_title integer)"
|
||||
db.exec "create unique index path_idx on ids (path)"
|
||||
db.exec "create unique index id_idx on ids (id)"
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
db.exec "create table thumbnails " \
|
||||
"(id text, data blob, filename text, " \
|
||||
"mime text, size integer)"
|
||||
db.exec "create unique index tn_index on thumbnails (id)"
|
||||
|
||||
db.exec "create table users" \
|
||||
"(username text, password text, token text, admin integer)"
|
||||
rescue e
|
||||
unless e.message.not_nil!.ends_with? "already exists"
|
||||
Logger.fatal "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
db.exec "create table ids" \
|
||||
"(path text, id text, is_title integer)"
|
||||
db.exec "create unique index path_idx on ids (path)"
|
||||
db.exec "create unique index id_idx on ids (id)"
|
||||
|
||||
db.exec "create table users" \
|
||||
"(username text, password text, token text, admin integer)"
|
||||
rescue e
|
||||
unless e.message.not_nil!.ends_with? "already exists"
|
||||
Logger.fatal "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
end
|
||||
|
||||
# If the DB is initialized through CLI but no user is added, we need
|
||||
# to create the admin user when first starting the app
|
||||
user_count = db.query_one "select count(*) from users", as: Int32
|
||||
init_admin if init_user && user_count == 0
|
||||
else
|
||||
Logger.debug "Creating DB file at #{@path}"
|
||||
db.exec "create unique index username_idx on users (username)"
|
||||
db.exec "create unique index token_idx on users (token)"
|
||||
|
||||
init_admin if init_user
|
||||
end
|
||||
|
||||
# If the DB is initialized through CLI but no user is added, we need
|
||||
# to create the admin user when first starting the app
|
||||
user_count = db.query_one "select count(*) from users", as: Int32
|
||||
init_admin if init_user && user_count == 0
|
||||
else
|
||||
Logger.debug "Creating DB file at #{@path}"
|
||||
db.exec "create unique index username_idx on users (username)"
|
||||
db.exec "create unique index token_idx on users (token)"
|
||||
|
||||
init_admin if init_user
|
||||
end
|
||||
end
|
||||
unless @auto_close
|
||||
@db = DB.open "sqlite3://#{@path}"
|
||||
unless @auto_close
|
||||
@db = DB.open "sqlite3://#{@path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,37 +91,45 @@ class Storage
|
||||
end
|
||||
|
||||
def verify_user(username, password)
|
||||
get_db do |db|
|
||||
begin
|
||||
hash, token = db.query_one "select password, token from " \
|
||||
"users where username = (?)",
|
||||
username, as: {String, String?}
|
||||
unless verify_password hash, password
|
||||
Logger.debug "Password does not match the hash"
|
||||
return nil
|
||||
out_token = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
begin
|
||||
hash, token = db.query_one "select password, token from " \
|
||||
"users where username = (?)",
|
||||
username, as: {String, String?}
|
||||
unless verify_password hash, password
|
||||
Logger.debug "Password does not match the hash"
|
||||
next
|
||||
end
|
||||
Logger.debug "User #{username} verified"
|
||||
if token
|
||||
out_token = token
|
||||
next
|
||||
end
|
||||
token = random_str
|
||||
Logger.debug "Updating token for #{username}"
|
||||
db.exec "update users set token = (?) where username = (?)",
|
||||
token, username
|
||||
out_token = token
|
||||
rescue e
|
||||
Logger.error "Error when verifying user #{username}: #{e}"
|
||||
end
|
||||
Logger.debug "User #{username} verified"
|
||||
return token if token
|
||||
token = random_str
|
||||
Logger.debug "Updating token for #{username}"
|
||||
db.exec "update users set token = (?) where username = (?)",
|
||||
token, username
|
||||
return token
|
||||
rescue e
|
||||
Logger.error "Error when verifying user #{username}: #{e}"
|
||||
return nil
|
||||
end
|
||||
end
|
||||
out_token
|
||||
end
|
||||
|
||||
def verify_token(token)
|
||||
username = nil
|
||||
get_db do |db|
|
||||
begin
|
||||
username = db.query_one "select username from users where " \
|
||||
"token = (?)", token, as: String
|
||||
rescue e
|
||||
Logger.debug "Unable to verify token"
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
begin
|
||||
username = db.query_one "select username from users where " \
|
||||
"token = (?)", token, as: String
|
||||
rescue e
|
||||
Logger.debug "Unable to verify token"
|
||||
end
|
||||
end
|
||||
end
|
||||
username
|
||||
@@ -125,12 +137,14 @@ class Storage
|
||||
|
||||
def verify_admin(token)
|
||||
is_admin = false
|
||||
get_db do |db|
|
||||
begin
|
||||
is_admin = db.query_one "select admin from users where " \
|
||||
"token = (?)", token, as: Bool
|
||||
rescue e
|
||||
Logger.debug "Unable to verify user as admin"
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
begin
|
||||
is_admin = db.query_one "select admin from users where " \
|
||||
"token = (?)", token, as: Bool
|
||||
rescue e
|
||||
Logger.debug "Unable to verify user as admin"
|
||||
end
|
||||
end
|
||||
end
|
||||
is_admin
|
||||
@@ -138,10 +152,12 @@ class Storage
|
||||
|
||||
def list_users
|
||||
results = Array(Tuple(String, Bool)).new
|
||||
get_db do |db|
|
||||
db.query "select username, admin from users" do |rs|
|
||||
rs.each do
|
||||
results << {rs.read(String), rs.read(Bool)}
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query "select username, admin from users" do |rs|
|
||||
rs.each do
|
||||
results << {rs.read(String), rs.read(Bool)}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -152,10 +168,12 @@ class Storage
|
||||
validate_username username
|
||||
validate_password password
|
||||
admin = (admin ? 1 : 0)
|
||||
get_db do |db|
|
||||
hash = hash_password password
|
||||
db.exec "insert into users values (?, ?, ?, ?)",
|
||||
username, hash, nil, admin
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
hash = hash_password password
|
||||
db.exec "insert into users values (?, ?, ?, ?)",
|
||||
username, hash, nil, admin
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,40 +181,48 @@ class Storage
|
||||
admin = (admin ? 1 : 0)
|
||||
validate_username username
|
||||
validate_password password unless password.empty?
|
||||
get_db do |db|
|
||||
if password.empty?
|
||||
db.exec "update users set username = (?), admin = (?) " \
|
||||
"where username = (?)",
|
||||
username, admin, original_username
|
||||
else
|
||||
hash = hash_password password
|
||||
db.exec "update users set username = (?), admin = (?)," \
|
||||
"password = (?) where username = (?)",
|
||||
username, admin, hash, original_username
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
if password.empty?
|
||||
db.exec "update users set username = (?), admin = (?) " \
|
||||
"where username = (?)",
|
||||
username, admin, original_username
|
||||
else
|
||||
hash = hash_password password
|
||||
db.exec "update users set username = (?), admin = (?)," \
|
||||
"password = (?) where username = (?)",
|
||||
username, admin, hash, original_username
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_user(username)
|
||||
get_db do |db|
|
||||
db.exec "delete from users where username = (?)", username
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.exec "delete from users where username = (?)", username
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def logout(token)
|
||||
get_db do |db|
|
||||
begin
|
||||
db.exec "update users set token = (?) where token = (?)", nil, token
|
||||
rescue
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
begin
|
||||
db.exec "update users set token = (?) where token = (?)", nil, token
|
||||
rescue
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_id(path, is_title)
|
||||
id = nil
|
||||
get_db do |db|
|
||||
id = db.query_one? "select id from ids where path = (?)", path,
|
||||
as: {String}
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
id = db.query_one? "select id from ids where path = (?)", path,
|
||||
as: {String}
|
||||
end
|
||||
end
|
||||
id
|
||||
end
|
||||
@@ -206,20 +232,76 @@ class Storage
|
||||
end
|
||||
|
||||
def bulk_insert_ids
|
||||
get_db do |db|
|
||||
db.transaction do |tx|
|
||||
@insert_ids.each do |tp|
|
||||
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
|
||||
tp[:id], tp[:is_title] ? 1 : 0
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.transaction do |tx|
|
||||
@insert_ids.each do |tp|
|
||||
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
|
||||
tp[:id], tp[:is_title] ? 1 : 0
|
||||
end
|
||||
end
|
||||
end
|
||||
@insert_ids.clear
|
||||
end
|
||||
end
|
||||
|
||||
def save_thumbnail(id : String, img : Image)
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.exec "insert into thumbnails values (?, ?, ?, ?, ?)", id, img.data,
|
||||
img.filename, img.mime, img.size
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_thumbnail(id : String) : Image?
|
||||
img = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query_one? "select * from thumbnails where id = (?)", id do |res|
|
||||
img = Image.from_db res
|
||||
end
|
||||
end
|
||||
end
|
||||
@insert_ids.clear
|
||||
img
|
||||
end
|
||||
|
||||
def optimize
|
||||
MainFiber.run do
|
||||
Logger.info "Starting DB optimization"
|
||||
get_db do |db|
|
||||
trash_ids = [] of String
|
||||
db.query "select path, id from ids" do |rs|
|
||||
rs.each do
|
||||
path = rs.read String
|
||||
trash_ids << rs.read String unless File.exists? path
|
||||
end
|
||||
end
|
||||
|
||||
# Delete dangling IDs
|
||||
db.exec "delete from ids where id in " \
|
||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
||||
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
|
||||
if trash_ids.size > 0
|
||||
|
||||
# Delete dangling thumbnails
|
||||
trash_thumbnails_count = db.query_one "select count(*) from " \
|
||||
"thumbnails where id not in " \
|
||||
"(select id from ids)", as: Int32
|
||||
if trash_thumbnails_count > 0
|
||||
db.exec "delete from thumbnails where id not in (select id from ids)"
|
||||
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
|
||||
end
|
||||
end
|
||||
Logger.info "DB optimization finished"
|
||||
end
|
||||
end
|
||||
|
||||
def close
|
||||
unless @db.nil?
|
||||
@db.not_nil!.close
|
||||
MainFiber.run do
|
||||
unless @db.nil?
|
||||
@db.not_nil!.close
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
43
src/util/proxy.cr
Normal file
43
src/util/proxy.cr
Normal file
@@ -0,0 +1,43 @@
|
||||
require "http_proxy"
|
||||
|
||||
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
|
||||
# environment variables
|
||||
module HTTP
|
||||
class Client
|
||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||
Logger.debug "Setting proxy"
|
||||
previous_def uri, tls do |client, path|
|
||||
client.set_proxy get_proxy uri
|
||||
yield client, path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
|
||||
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
|
||||
return if no_proxy &&
|
||||
no_proxy.split(",").any? &.== uri.hostname
|
||||
|
||||
case uri.scheme
|
||||
when "http"
|
||||
env_to_proxy "http_proxy"
|
||||
when "https"
|
||||
env_to_proxy "https_proxy"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
|
||||
val = ENV[key.downcase]? || ENV[key.upcase]?
|
||||
return if val.nil?
|
||||
|
||||
begin
|
||||
uri = URI.parse val
|
||||
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
|
||||
username: uri.user, password: uri.password
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
IMGS_PER_PAGE = 5
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||
IMGS_PER_PAGE = 5
|
||||
ENTRIES_IN_HOME_SECTIONS = 8
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
@@ -60,3 +61,9 @@ class String
|
||||
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
||||
end
|
||||
end
|
||||
|
||||
def env_is_true?(key : String) : Bool
|
||||
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||
return false unless val
|
||||
val.downcase.in? "1", "true"
|
||||
end
|
||||
|
||||
@@ -2,9 +2,9 @@ def validate_username(username)
|
||||
if username.size < 3
|
||||
raise "Username should contain at least 3 characters"
|
||||
end
|
||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||
raise "Username should contain alphanumeric characters " \
|
||||
"and underscores only"
|
||||
if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
|
||||
raise "Username can only contain alphanumeric characters, " \
|
||||
"underscores, and hyphens"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -81,3 +81,20 @@ macro get_sort_opt
|
||||
sort_opt = SortOptions.new sort_method, is_ascending
|
||||
end
|
||||
end
|
||||
|
||||
module HTTP
|
||||
class Client
|
||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||
previous_def uri, tls do |client, path|
|
||||
if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
|
||||
Logger.debug "Disabling SSL verification"
|
||||
client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
|
||||
end
|
||||
Logger.debug "Setting read timeout"
|
||||
client.read_timeout = Config.current.download_timeout_seconds.seconds
|
||||
Logger.debug "Requesting #{uri}"
|
||||
yield client, path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<ul class="uk-list uk-list-large uk-list-divider">
|
||||
<li data-url="<%= base_url %>admin/user">User Managerment</li>
|
||||
<li onclick="if(!scanning){scan()}">
|
||||
<span id="scan">Scan Library Files</span>
|
||||
<span id="scan-status" class="uk-align-right">
|
||||
<div uk-spinner hidden></div>
|
||||
<span hidden></span>
|
||||
</span>
|
||||
<ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}">
|
||||
<li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li>
|
||||
<li :class="{'nopointer' : scanning}" @click="scan()">
|
||||
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
|
||||
<div class="uk-align-right">
|
||||
<div uk-spinner x-show="scanning"></div>
|
||||
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li :class="{'nopointer' : generating}" @click="generateThumbnails()">
|
||||
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
|
||||
<div class="uk-align-right">
|
||||
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nopointer">
|
||||
<span>Theme</span>
|
||||
|
||||
14
src/views/api.html.ecr
Normal file
14
src/views/api.html.ecr
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="X-UA-Compatible" content="IE=edge">
|
||||
<title>Mango API Documentation</title>
|
||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="/openapi.json"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,12 +35,20 @@
|
||||
onclick="location='<%= base_url %>book/<%= item.id %>'"
|
||||
<% end %>>
|
||||
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img
|
||||
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
|
||||
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
|
||||
x-init="disabled = false"
|
||||
<% end %>>
|
||||
<div class="uk-card-media-top uk-inline" @mouseenter="hover = true" @mouseleave="hover = false">
|
||||
<img data-src="<%= item.cover_url %>" width="100%" height="100%" alt="" uk-img
|
||||
<% if item.is_a? Entry && item.err_msg %>
|
||||
class="grayscale"
|
||||
<% end %>>
|
||||
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
|
||||
<div class="uk-position-center">
|
||||
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-card-body">
|
||||
|
||||
3
src/views/components/dots-scripts.html.ecr
Normal file
3
src/views/components/dots-scripts.html.ecr
Normal file
@@ -0,0 +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="<%= base_url %>js/dots.js"></script>
|
||||
@@ -7,8 +7,12 @@
|
||||
<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://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="<%= base_url %>js/theme.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
|
||||
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
|
||||
<script src="<%= base_url %>js/common.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
<div class="uk-margin">
|
||||
<div id="actions" class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
||||
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
||||
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
||||
</div>
|
||||
<div id="config" class="uk-margin">
|
||||
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
||||
<div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
|
||||
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
|
||||
<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"></div>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||
|
||||
<td>
|
||||
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</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="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd>
|
||||
<dt style="font-weight: 500;">Can't see your files yet?</dt>
|
||||
<dd>
|
||||
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete
|
||||
You must wait <%= Config.current.scan_interval_minutes %> minutes for the library scan to complete
|
||||
<% if is_admin %>
|
||||
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
|
||||
<% end %>.
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<%- unless continue_reading.empty? -%>
|
||||
<h2 class="uk-title home-headings">Continue Reading</h2>
|
||||
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- continue_reading.each do |cr| -%>
|
||||
<% item = cr[:entry] %>
|
||||
<% progress = cr[:percentage] %>
|
||||
@@ -50,9 +50,20 @@
|
||||
</div>
|
||||
<%- end -%>
|
||||
|
||||
<%- unless start_reading.empty? -%>
|
||||
<h2 class="uk-title home-headings">Start Reading</h2>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- start_reading.each do |t| -%>
|
||||
<% item = t %>
|
||||
<% progress = 0.0 %>
|
||||
<%= render_component "card" %>
|
||||
<%- end -%>
|
||||
</div>
|
||||
<%- end -%>
|
||||
|
||||
<%- unless recently_added.empty? -%>
|
||||
<h2 class="uk-title home-headings">Recently Added</h2>
|
||||
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- recently_added.each do |ra| -%>
|
||||
<% item = ra %>
|
||||
<% progress = ra[:percentage] %>
|
||||
@@ -66,8 +77,7 @@
|
||||
<%- end -%>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/title.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -67,10 +67,13 @@
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
</div>
|
||||
<div class="uk-section uk-section-small">
|
||||
<div class="uk-section uk-section-small" id="main-section">
|
||||
<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>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<%= render_component "sort-form" %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% titles.each_with_index do |item, i| %>
|
||||
<% progress = percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
@@ -24,8 +24,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -21,18 +21,18 @@
|
||||
<% end %>
|
||||
|
||||
<% title.entries.each do |e| %>
|
||||
<entry>
|
||||
<title><%= HTML.escape(e.display_name) %></title>
|
||||
<id>urn:mango:<%= e.id %></id>
|
||||
<% next if e.err_msg %>
|
||||
<entry>
|
||||
<title><%= HTML.escape(e.display_name) %></title>
|
||||
<id>urn:mango:<%= e.id %></id>
|
||||
|
||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||
|
||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||
|
||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||
</entry>
|
||||
<% end %>
|
||||
|
||||
</feed>
|
||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||
</entry>
|
||||
<% end %>
|
||||
</feed>
|
||||
|
||||
@@ -3,24 +3,70 @@
|
||||
|
||||
<%= render_component "head" %>
|
||||
|
||||
<body>
|
||||
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
||||
<div class="uk-container uk-container-small">
|
||||
<%- urls.each_with_index do |url, i| -%>
|
||||
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
|
||||
<%- end -%>
|
||||
<%- if next_url -%>
|
||||
<a class="next-url" href="<%= next_url %>"></a>
|
||||
<%- end -%>
|
||||
</div>
|
||||
<%- if next_entry_url -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
|
||||
<%- else -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||
<%- end -%>
|
||||
</div>
|
||||
<body style="position:relative;">
|
||||
<div class="uk-section uk-section-default uk-section-small reader-bg"
|
||||
id="root"
|
||||
:style="mode === 'continuous' ? '' : 'padding:0'"
|
||||
x-data="{
|
||||
loading: true,
|
||||
mode: 'continuous', // can be 'continuous', 'height' or 'width'
|
||||
msg: 'Loading the web reader. Please wait...',
|
||||
alertClass: 'uk-alert-primary',
|
||||
items: [],
|
||||
curItem: {},
|
||||
flipAnimation: null
|
||||
}">
|
||||
|
||||
<div id="hidden" hidden></div>
|
||||
<div @keydown.window.debounce="keyHandler($event)"></div>
|
||||
|
||||
<div class="uk-container uk-container-small">
|
||||
<div id="alert"></div>
|
||||
<div x-show="loading">
|
||||
<div :class="alertClass" x-show="msg" uk-alert>
|
||||
<p x-text="msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||
<template x-for="item in items">
|
||||
<img
|
||||
uk-img
|
||||
class="uk-align-center"
|
||||
:data-src="item.url"
|
||||
:width="item.width"
|
||||
:height="item.height"
|
||||
:id="item.id"
|
||||
:onclick="`showControl('${item.id}')`"
|
||||
/>
|
||||
</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="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
|
||||
|
||||
<img uk-img :class="{
|
||||
'uk-align-center': true,
|
||||
'uk-animation-slide-left': flipAnimation === 'left',
|
||||
'uk-animation-slide-right': flipAnimation === 'right'
|
||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
|
||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||
margin-bottom:0;
|
||||
`" />
|
||||
|
||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
||||
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-sections" class="uk-flex-top" uk-modal>
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||
@@ -34,7 +80,7 @@
|
||||
<p id="progress-label"></p>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="form-stacked-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">
|
||||
<%- (1..entry.pages).each do |p| -%>
|
||||
@@ -43,20 +89,38 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="mode-select" class="uk-select">
|
||||
<option value="continuous">Continuous</option>
|
||||
<option value="paged">Paged</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const base_url = "<%= base_url %>"
|
||||
const base_url = "<%= base_url %>";
|
||||
const page = <%= page %>;
|
||||
const tid = "<%= title.id %>";
|
||||
const eid = "<%= entry.id %>";
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.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/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; }
|
||||
#root img { width: 100%; }
|
||||
</style>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
<div>
|
||||
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
|
||||
<div class="uk-child-width-1-3" uk-grid>
|
||||
<div>
|
||||
<p x-text="count + ' items selected'" style="color:orange"></p>
|
||||
</div>
|
||||
<div class="uk-text-center" id="select-bar-controls">
|
||||
<a class="uk-icon uk-margin-right" uk-tooltip="title: Mark selected as read" href="" @click.prevent="bulkProgress('read', $el)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</a>
|
||||
<a class="uk-icon" uk-tooltip="title: Mark selected as unread" href="" @click.prevent="bulkProgress('unread', $el)">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="uk-text-right">
|
||||
<a @click="selectAll()" uk-tooltip="title: Select all"><i class="fas fa-check-double uk-margin-small-right"></i></a>
|
||||
<a @click="deselectAll();" uk-tooltip="title: Deselect all"><i class="fas fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||
|
||||
<% if is_admin %>
|
||||
@@ -32,11 +51,14 @@
|
||||
<%= render_component "sort-form" %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% title.titles.each_with_index do |item, i| %>
|
||||
<% progress = title_percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% entries.each_with_index do |item, i| %>
|
||||
<% progress = percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
@@ -95,8 +117,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/title.js"></script>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user