Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Ling
e65cd05ef1 Add the option to disable ellipsis when truncating 2020-05-15 09:50:35 +00:00
112 changed files with 2563 additions and 6826 deletions

View File

@@ -1,102 +0,0 @@
{
"projectName": "Mango",
"projectOwner": "hkalexling",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "none",
"contributors": [
{
"login": "hkalexling",
"name": "Alex Ling",
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
"profile": "https://github.com/hkalexling/",
"contributions": [
"code",
"doc",
"infra"
]
},
{
"login": "jaredlt",
"name": "jaredlt",
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
"profile": "https://github.com/jaredlt",
"contributions": [
"code",
"ideas",
"design"
]
},
{
"login": "shincurry",
"name": "ココロ",
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
"profile": "https://windisco.com/",
"contributions": [
"infra"
]
},
{
"login": "noirscape",
"name": "Valentijn",
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
"profile": "https://catgirlsin.space/",
"contributions": [
"infra"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc",
"ideas"
]
},
{
"login": "XavierSchiller",
"name": "Xavier",
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
"profile": "https://github.com/XavierSchiller",
"contributions": [
"infra"
]
},
{
"login": "WROIATE",
"name": "Jarao",
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
"profile": "https://github.com/WROIATE",
"contributions": [
"infra"
]
},
{
"login": "Leeingnyo",
"name": "이인용",
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
"profile": "https://github.com/Leeingnyo",
"contributions": [
"code"
]
},
{
"login": "h45h74x",
"name": "Simon",
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
"profile": "http://h45h74x.eu.org",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}

View File

@@ -1,2 +0,0 @@
node_modules
lib

2
.github/FUNDING.yml vendored
View File

@@ -1,5 +1,3 @@
# These are supported funding model platforms # These are supported funding model platforms
open_collective: mango
patreon: hkalexling patreon: hkalexling
ko_fi: hkalexling

View File

@@ -8,13 +8,10 @@ assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. E.g. I'm always frustrated when [...] A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Describe a small use-case for this feature request**
How would you imagine this to be used? What would be the advantage of this for the users of the application?
**Additional context** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.

View File

@@ -8,33 +8,19 @@ on:
jobs: jobs:
build: build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.35.1-alpine image: crystallang/crystal:0.34.0-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev run: apk add --no-cache yarn yaml sqlite-static
- name: Build - name: Build
run: make static || make static run: make
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- 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

View File

@@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Get release version - name: Get release version
id: get_version id: get_version
run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
- name: Publish to Dockerhub - name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master uses: elgohr/Publish-Docker-Github-Action@master
with: with:

4
.gitignore vendored
View File

@@ -8,7 +8,3 @@ yarn.lock
dist dist
mango mango
.env .env
*.md
public/css/uikit.css
public/img/*.svg
public/js/*.min.js

View File

@@ -1,10 +1,11 @@
FROM crystallang/crystal:0.35.1-alpine AS builder FROM crystallang/crystal:0.34.0-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
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 COPY package*.json .
RUN make static || make static RUN apk add --no-cache yarn yaml sqlite-static \
&& make static
FROM library/alpine FROM library/alpine

View File

@@ -1,14 +0,0 @@
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"]

View File

@@ -1,14 +0,0 @@
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"]

View File

@@ -7,18 +7,14 @@ uglify:
yarn yarn
yarn uglify yarn uglify
setup: libs
yarn
yarn gulp dev
build: libs build: libs
crystal build src/mango.cr --release --progress --error-trace crystal build src/mango.cr --release --progress
static: uglify | libs static: uglify | libs
crystal build src/mango.cr --release --progress --static --error-trace crystal build src/mango.cr --release --progress --static
libs: libs:
shards install --production shards install
run: run:
crystal run src/mango.cr --error-trace crystal run src/mango.cr --error-trace
@@ -29,13 +25,6 @@ test:
check: check:
crystal tool format --check crystal tool format --check
./bin/ameba ./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: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango

View File

@@ -1,3 +1,6 @@
![banner](./public/img/banner-paddings.png) ![banner](./public/img/banner-paddings.png)
# Mango # Mango
@@ -7,19 +10,14 @@
Mango is a self-hosted manga server and reader. Its features include Mango is a self-hosted manga server and reader. Its features include
- Multi-user support - Multi-user support
- OPDS support
- Dark/light mode switch - Dark/light mode switch
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supports both `.zip` and `.cbz` formats
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Thumbnail generation
- Built-in [MangaDex](https://mangadex.org/) downloader - Built-in [MangaDex](https://mangadex.org/) downloader
- 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 - 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 - All the static files are embedded in the binary, so the deployment process is easy and painless
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
## Installation ## Installation
### Pre-built Binary ### Pre-built Binary
@@ -37,11 +35,11 @@ Simply download the pre-built binary file `mango` for the latest [release](https
### Docker (via Dockerhub) ### Docker (via Dockerhub)
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango). The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
### Build from source ### Build from source
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies 1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
2. Clone the repository 2. Clone the repository
3. `make && sudo make install` 3. `make && sudo make install`
4. Start Mango by running the command `mango` 4. Start Mango by running the command `mango`
@@ -52,21 +50,11 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.18.2 Mango e-manga server/reader. Version 0.3.0
Usage: -v, --version Show version
-h, --help Show help
mango [sub_command] [options] -c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
Options:
-c PATH, --config=PATH Path to the config file [type:String]
-h, --help Show this help.
-v, --version Show version.
Sub Commands:
admin Run admin tools
``` ```
### Config ### Config
@@ -76,37 +64,25 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
port: 9000 port: 9000
base_url: / library_path: /home/alex_ling/mango/library
session_secret: mango-session-secret upload_path: /home/alex_ling/mango/uploads
library_path: ~/mango/library db_path: /home/alex_ling/mango/mango.db
db_path: ~/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 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`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks - `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
### Library Structure ### Library Structure
You can organize your archive files in nested folders in the library directory. Here's an example: You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
``` ```
. .
@@ -118,8 +94,8 @@ You can organize your archive files in nested folders in the library directory.
└── Manga 2 └── Manga 2
   └── Vol. 1    └── Vol. 1
   └── Ch.1 - Ch.3    └── Ch.1 - Ch.3
   ├── 1.zip    ├── 1.zip
   ├── 2.zip    ├── 2.zip
   └── 3.zip    └── 3.zip
``` ```
@@ -149,34 +125,6 @@ Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png) ![mobile screenshot](./.github/screenshots/mobile.png)
## 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 ## Contributors
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/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->

View File

@@ -1,5 +0,0 @@
#!/bin/sh
[ ! -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

View File

@@ -1,70 +1,29 @@
const gulp = require('gulp'); const gulp = require('gulp');
const babel = require('gulp-babel'); const minify = require("gulp-babel-minify");
const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css'); const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
// Copy libraries from node_moduels to public/js gulp.task('minify-js', () => {
gulp.task('copy-js', () => { return gulp.src('public/js/*.js')
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
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({ .pipe(minify({
removeConsole: true, removeConsole: true
builtIns: false
})) }))
.pipe(gulp.dest('dist/js')); .pipe(gulp.dest('dist/js'));
}); });
// Minify CSS and output to dist
gulp.task('minify-css', () => { gulp.task('minify-css', () => {
return gulp.src('public/css/*.css') return gulp.src('public/css/*.css')
.pipe(minifyCss()) .pipe(minifyCss())
.pipe(gulp.dest('dist/css')); .pipe(gulp.dest('dist/css'));
}); });
// Copy static files (includeing images) to dist gulp.task('img', () => {
gulp.task('copy-files', () => { return gulp.src('public/img/*')
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], { .pipe(gulp.dest('dist/img'));
base: 'public' });
})
gulp.task('favicon', () => {
return gulp.src('public/favicon.ico')
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
// Set up the public folder for development gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon'));
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
// 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'));

View File

@@ -1,25 +1,16 @@
{ {
"name": "mango", "name": "mango",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/hkalexling/Mango.git", "repository": "https://github.com/hkalexling/Mango.git",
"author": "Alex Ling <hkalexling@gmail.com>", "author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.11.5", "gulp": "^4.0.2",
"all-contributors-cli": "^6.19.0", "gulp-babel-minify": "^0.5.1",
"gulp": "^4.0.2", "gulp-minify-css": "^1.2.4"
"gulp-babel": "^8.0.0", },
"gulp-babel-minify": "^0.5.1", "scripts": {
"gulp-less": "^4.0.1", "uglify": "gulp"
"gulp-minify-css": "^1.2.4", }
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "^3.5.4"
}
} }

View File

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

View File

@@ -1,45 +0,0 @@
@import "node_modules/uikit/src/less/uikit.theme.less";
.label {
display: inline-block;
padding: @label-padding-vertical @label-padding-horizontal;
background: @label-background;
line-height: @label-line-height;
font-size: @label-font-size;
color: @label-color;
vertical-align: middle;
white-space: nowrap;
.hook-label;
}
.label-success {
background-color: @label-success-background;
color: @label-success-color;
}
.label-warning {
background-color: @label-warning-background;
color: @label-warning-color;
}
.label-danger {
background-color: @label-danger-background;
color: @label-danger-color;
}
.label-pending {
background-color: @global-secondary-background;
color: @global-inverse-color;
}
@internal-divider-icon-image: "../img/divider-icon.svg";
@internal-form-select-image: "../img/form-select.svg";
@internal-form-datalist-image: "../img/form-datalist.svg";
@internal-form-radio-image: "../img/form-radio.svg";
@internal-form-checkbox-image: "../img/form-checkbox.svg";
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
@internal-list-bullet-image: "../img/list-bullet.svg";
@internal-accordion-open-image: "../img/accordion-open.svg";
@internal-accordion-close-image: "../img/accordion-close.svg";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -1,55 +1,25 @@
const component = () => { var scanning = false;
return { function scan() {
progress: 1.0, scanning = true;
generating: false, $('#scan-status > div').removeAttr('hidden');
scanning: false, $('#scan-status > span').attr('hidden', '');
scanTitles: 0, var color = $('#scan').css('color');
scanMs: -1, $('#scan').css('color', 'gray');
themeSetting: '', $.post('/api/admin/scan', function (data) {
var ms = data.milliseconds;
init() { var titles = data.titles;
this.getProgress(); $('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
setInterval(() => { $('#scan-status > span').removeAttr('hidden');
this.getProgress(); $('#scan').css('color', color);
}, 5000); $('#scan-status > div').attr('hidden', '');
scanning = false;
const setting = loadThemeSetting(); });
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1); }
}, $(function() {
themeChanged(event) { $('li').click(function() {
const newSetting = $(event.currentTarget).val().toLowerCase(); url = $(this).attr('data-url');
saveThemeSetting(newSetting); if (url) {
setTheme(); $(location).attr('href', url);
}, }
scan() { });
if (this.scanning) return; });
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
};

View File

@@ -1,147 +0,0 @@
/**
* --- 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 = 'system';
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 = 'system';
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');
});
}
});

View File

@@ -1,26 +1,18 @@
/** const truncate = () => {
* Truncate a .uk-card-title element $('.acard .uk-card-title').each((i, e) => {
* $(e).dotdotdot({
* @function truncate truncate: 'letter',
* @param {object} e - The title element to truncate watch: true,
*/ callback: (truncated) => {
const truncate = (e) => { if (truncated) {
$(e).dotdotdot({ $(e).attr('uk-tooltip', $(e).attr('data-title'));
truncate: 'letter', }
watch: true, else {
callback: (truncated) => { $(e).removeAttr('uk-tooltip');
if (truncated) { }
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
} }
} });
}); });
}; };
$('.uk-card-title').each((i, e) => { truncate();
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});

View File

@@ -1,107 +1,138 @@
const component = () => { $(() => {
return { $('input.uk-checkbox').each((i, e) => {
jobs: [], $(e).change(() => {
paused: undefined, loadConfig();
loading: false, });
toggling: false, });
loadConfig();
load();
init() { const intervalMS = 5000;
const ws = new WebSocket(`ws://${location.host}${base_url}api/admin/mangadex/queue`); setTimeout(() => {
ws.onmessage = event => { setInterval(() => {
const data = JSON.parse(event.data); if (globalConfig.autoRefresh !== true) return;
this.jobs = data.jobs; load();
this.paused = data.paused; }, intervalMS);
}; }, intervalMS);
ws.onerror = err => { });
alert('danger', `Socket connection failed. Error: ${err}`); var globalConfig = {};
}; var loading = false;
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
this.load(); const loadConfig = () => {
}, globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
load() { };
this.loading = true; const remove = (id) => {
$.ajax({ var url = '/api/admin/mangadex/queue/delete';
type: 'GET', if (id !== undefined)
url: base_url + 'api/admin/mangadex/queue', url += '?' + $.param({id: id});
dataType: 'json' console.log(url);
}) $.ajax({
.done(data => { type: 'POST',
if (!data.success && data.error) { url: url,
alert('danger', `Failed to fetch download queue. Error: ${data.error}`); dataType: 'json'
return; })
} .done(data => {
this.jobs = data.jobs; if (!data.success && data.error) {
this.paused = data.paused; alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
}) return;
.fail((jqXHR, status) => { }
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); load();
}) })
.always(() => { .fail((jqXHR, status) => {
this.loading = false; alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}, };
jobAction(action, event) { const refresh = (id) => {
let url = `${base_url}api/admin/mangadex/queue/${action}`; var url = '/api/admin/mangadex/queue/retry';
if (event) { if (id !== undefined)
const id = event.currentTarget.closest('tr').id.split('-')[1]; url += '?' + $.param({id: id});
url = `${url}?${$.param({ console.log(url);
id: id $.ajax({
})}`; type: 'POST',
} url: url,
console.log(url); dataType: 'json'
$.ajax({ })
type: 'POST', .done(data => {
url: url, if (!data.success && data.error) {
dataType: 'json' alert('danger', `Failed to restart download job. Error: ${data.error}`);
}) return;
.done(data => { }
if (!data.success && data.error) { load();
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`); })
return; .fail((jqXHR, status) => {
} alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
this.load(); });
}) };
.fail((jqXHR, status) => { const toggle = () => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); $('#pause-resume-btn').attr('disabled', '');
}); const paused = $('#pause-resume-btn').text() === 'Resume download';
}, const action = paused ? 'resume' : 'pause';
toggle() { const url = `/api/admin/mangadex/queue/${action}`;
this.toggling = true; $.ajax({
const action = this.paused ? 'resume' : 'pause'; type: 'POST',
const url = `${base_url}api/admin/mangadex/queue/${action}`; url: url,
$.ajax({ dataType: 'json'
type: 'POST', })
url: url, .fail((jqXHR, status) => {
dataType: 'json' alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { .always(() => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); load();
}) $('#pause-resume-btn').removeAttr('disabled');
.always(() => { });
this.load(); };
this.toggling = false; const load = () => {
}); if (loading) return;
}, loading = true;
statusClass(status) { console.log('fetching');
let cls = 'label '; $.ajax({
switch (status) { type: 'GET',
case 'Pending': url: '/api/admin/mangadex/queue',
cls += 'label-pending'; dataType: 'json'
break; })
case 'Completed': .done(data => {
cls += 'label-success'; if (!data.success && data.error) {
break; alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
case 'Error': return;
cls += 'label-danger'; }
break; console.log(data);
case 'MissingPages': const btnText = data.paused ? "Resume download" : "Pause download";
cls += 'label-warning'; $('#pause-resume-btn').text(btnText);
break; $('#pause-resume-btn').removeAttr('hidden');
} const rows = data.jobs.map(obj => {
return cls; var cls = 'uk-label ';
} if (obj.status === 'Completed')
}; cls += 'uk-label-success';
if (obj.status === 'Error')
cls += 'uk-label-danger';
if (obj.status === 'MissingPages')
cls += 'uk-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><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td>
<td><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>
<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;
});
}; };

View File

@@ -32,41 +32,42 @@ const download = () => {
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0); const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids); console.log(ids);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: base_url + 'api/admin/mangadex/download', url: '/api/admin/mangadex/download',
data: JSON.stringify({ data: JSON.stringify({chapters: chapters}),
chapters: chapters contentType: "application/json",
}), dataType: 'json'
contentType: "application/json", })
dataType: 'json' .done(data => {
}) console.log(data);
.done(data => { if (data.error) {
console.log(data); alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
if (data.error) { return;
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`); }
return; const successCount = parseInt(data.success);
} const failCount = parseInt(data.fail);
const successCount = parseInt(data.success); UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
const failCount = parseInt(data.fail); window.location.href = '/admin/downloads';
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
}); });
styleModal();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
}); });
styleModal();
}; };
const toggleSpinner = () => { const toggleSpinner = () => {
var attr = $('#spinner').attr('hidden'); var attr = $('#spinner').attr('hidden');
if (attr) { if (attr) {
$('#spinner').removeAttr('hidden'); $('#spinner').removeAttr('hidden');
$('#search-btn').attr('hidden', ''); $('#search-btn').attr('hidden', '');
} else { }
else {
$('#search-btn').removeAttr('hidden'); $('#search-btn').removeAttr('hidden');
$('#spinner').attr('hidden', ''); $('#spinner').attr('hidden', '');
} }
@@ -95,9 +96,10 @@ const search = () => {
try { try {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path); const match = /\/title\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} catch (e) { }
catch(e) {
int_id = parseInt(input); int_id = parseInt(input);
} }
@@ -107,7 +109,7 @@ const search = () => {
return; return;
} }
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`) $.getJSON("/api/admin/mangadex/manga/" + int_id)
.done((data) => { .done((data) => {
if (data.error) { if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error); alert('danger', 'Failed to get manga info. Error: ' + data.error);
@@ -137,12 +139,8 @@ const search = () => {
const comp = (a, b) => { const comp = (a, b) => {
var ai; var ai;
var bi; var bi;
try { try {ai = parseFloat(a);} catch(e) {}
ai = parseFloat(a); try {bi = parseFloat(b);} catch(e) {}
} catch (e) {}
try {
bi = parseFloat(b);
} catch (e) {}
if (typeof ai === 'undefined') return -1; if (typeof ai === 'undefined') return -1;
if (typeof bi === 'undefined') return 1; if (typeof bi === 'undefined') return 1;
if (ai < bi) return 1; if (ai < bi) return 1;
@@ -178,7 +176,8 @@ const parseRange = str => {
if (!matches) { if (!matches) {
alert('danger', `Failed to parse filter input ${str}`); alert('danger', `Failed to parse filter input ${str}`);
return [null, null]; return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') { }
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30 // e.g., <= 30
num = parseInt(matches[2]); num = parseInt(matches[2]);
if (isNaN(num)) { if (isNaN(num)) {
@@ -195,7 +194,8 @@ const parseRange = str => {
case '>=': case '>=':
return [num, null]; return [num, null];
} }
} else if (typeof matches[3] !== 'undefined') { }
else if (typeof matches[3] !== 'undefined') {
// a single number // a single number
num = parseInt(matches[3]); num = parseInt(matches[3]);
if (isNaN(num)) { if (isNaN(num)) {
@@ -203,7 +203,8 @@ const parseRange = str => {
return [null, null]; return [null, null];
} }
return [num, num]; return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') { }
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23 // e.g., 10 - 23
num = parseInt(matches[4]); num = parseInt(matches[4]);
const n2 = parseInt(matches[5]); const n2 = parseInt(matches[5]);
@@ -212,7 +213,8 @@ const parseRange = str => {
return [null, null]; return [null, null];
} }
return [num, n2]; return [num, n2];
} else { }
else {
// empty or space only // empty or space only
return [null, null]; return [null, null];
} }
@@ -240,10 +242,7 @@ const buildTable = () => {
Object.entries(filters).forEach(([k, v]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return; if (v === 'All') return;
if (k === 'group') { if (k === 'group') {
chapters = chapters.filter(c => { chapters = chapters.filter(c => v in c.groups);
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return; return;
} }
if (k === 'lang') { if (k === 'lang') {
@@ -278,7 +277,8 @@ const buildTable = () => {
const group_str = Object.entries(chp.groups).map(([k, v]) => { const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`; return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | '); }).join(' | ');
return `<tr class="ui-widget-content"> const dark = getTheme() === 'dark' ? 'dark' : '';
return `<tr class="ui-widget-content ${dark}">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td> <td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td> <td>${chp.title}</td>
<td>${chp.language}</td> <td>${chp.language}</td>
@@ -297,9 +297,3 @@ const buildTable = () => {
}); });
$('#selection-controls').removeAttr('hidden'); $('#selection-controls').removeAttr('hidden');
}; };
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};

5
public/js/fontawesome.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,141 +0,0 @@
const loadPlugin = id => {
localStorage.setItem('plugin', id);
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
plugin: id
})}`;
window.location.href = newURL;
};
$(() => {
var storedID = localStorage.getItem('plugin');
if (storedID && storedID !== pid) {
loadPlugin(storedID);
} else {
$('#controls').removeAttr('hidden');
}
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('#plugin-select').val(pid);
$('#plugin-select').change(() => {
const id = $('#plugin-select').val();
loadPlugin(id);
});
});
let mangaTitle = "";
let searching = false;
const search = () => {
if (searching)
return;
const query = $.param({
query: $('#search-input').val(),
plugin: pid
});
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Search failed. Error: ${data.error}`);
return;
}
mangaTitle = data.title;
$('#title-text').text(data.title);
buildTable(data.chapters);
})
.fail((jqXHR, status) => {
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {});
};
const buildTable = (chapters) => {
$('#table').attr('hidden', '');
$('table').empty();
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
const thead = `<thead><tr>${keys}</tr></thead>`;
$('table').append(thead);
const rows = chapters.map(ch => {
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
});
const tbody = `<tbody id="selectable">${rows}</tbody>`;
$('table').append(tbody);
$('#selectable').selectable({
filter: 'tr'
});
$('#table table').tablesorter();
$('#table').removeAttr('hidden');
};
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const chapters = selected.map((i, e) => {
return {
id: $(e).attr('data-id'),
title: $(e).attr('data-title')
}
}).get();
console.log(chapters);
$.ajax({
type: 'POST',
url: base_url + 'api/admin/plugin/download',
data: JSON.stringify({
plugin: pid,
chapters: chapters,
title: mangaTitle
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
});
};

View File

@@ -1,281 +1,81 @@
const readerComponent = () => { $(function() {
return { function bind() {
loading: true, var controller = new ScrollMagic.Controller();
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null,
longPages: false,
lastSavedPage: page,
/** // replace history on scroll
* Initialize the component by fetching the page dimensions $('img').each(function(idx){
*/ var scene = new ScrollMagic.Scene({
init(nextTick) { triggerElement: $(this).get(),
$.get(`${base_url}api/dimensions/${tid}/${eid}`) triggerHook: 'onEnter',
.then(data => { reverse: true
if (!data.success && data.error) })
throw new Error(resp.error); .addTo(controller)
const dimensions = data.dimensions; .on('enter', function(event){
current = $(event.target.triggerElement()).attr('id');
this.items = dimensions.map((d, i) => { replaceHistory(current);
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
};
});
const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / this.items.length;
console.log(avgRatio);
this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
// Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
}) })
.catch(e => { .on('leave', function(event){
const errMsg = `Failed to get the page dimensions. ${e}`; var prev = $(event.target.triggerElement()).prev();
console.error(e); current = $(prev).attr('id');
this.alertClass = 'uk-alert-danger'; replaceHistory(current);
this.msg = errMsg;
})
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0 || newIdx > this.items.length) return;
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
setTimeout(() => {
this.flipAnimation = null;
}, 500);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
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('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates 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
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.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}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
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';
}
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
}); });
} });
nextTick(() => { // poor man's infinite scroll
this.toPage(targetPage); var scene = new ScrollMagic.Scene({
}); triggerElement: $('.next-url').get(),
}, triggerHook: 'onEnter',
/** offset: -500
* Shows the control modal })
* .addTo(controller)
* @param {Event} event - The triggering event .on('enter', function(){
*/ var nextURL = $('.next-url').attr('href');
showControl(event) { $('.next-url').remove();
const idx = event.currentTarget.id; if (!nextURL) {
const pageCount = this.items.length; console.log('No .next-url found. Reached end of page');
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; var lastURL = $('img').last().attr('id');
$('#progress-label').text(progressText); // load the reader URL for the last page to update reading progrss to 100%
$('#page-select').val(idx); $.get(lastURL);
UIkit.modal($('#modal-sections')).show(); $('#next-btn').removeAttr('hidden');
}, return;
/** }
* Redirects to a URL $('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){
* if (status === 'error') console.log(xhr.statusText);
* @param {string} url - The target URL if (status === 'success') {
*/ console.log(nextURL + ' loaded');
redirect(url) { // new page loaded to #hidden, we now append it
window.location.replace(url); $('.uk-section > .uk-container').append($('#hidden .uk-container').children());
}, $('#hidden').empty();
/** bind();
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
} }
}); });
}); });
}, }
/**
* Marks progress as 100% and jumps to the next entry bind();
* });
* @param {string} nextUrl - URL of the next entry $('#page-select').change(function(){
*/ jumpTo(parseInt($('#page-select').val()));
nextEntry(nextUrl) { });
this.saveProgress(this.items.length, () => { function showControl(idx) {
this.redirect(nextUrl); const pageCount = $('#page-select > option').length;
}); const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
}, $('#progress-label').text(progressText);
/** $('#page-select').val(idx);
* Exits the reader, and optionally sets the reading progress tp 100% UIkit.modal($('#modal-sections')).show();
* styleModal();
* @param {string} exitUrl - The Exit URL }
* @param {boolean} [markCompleted] - Whether we should mark the function jumpTo(page) {
* reading progress to 100% var ary = window.location.pathname.split('/');
*/ ary[ary.length - 1] = page;
exitReader(exitUrl, markCompleted = false) { ary.shift(); // remove leading `/`
if (!markCompleted) { ary.unshift(window.location.origin);
return this.redirect(exitUrl); window.location.replace(ary.join('/'));
} }
this.saveProgress(this.items.length, () => { function replaceHistory(url) {
this.redirect(exitUrl); history.replaceState(null, "", url);
}); console.log('reading ' + url);
} }
}; function redirect(url) {
window.location.replace(url);
} }

5
public/js/solid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

43
public/js/theme.js Normal file
View File

@@ -0,0 +1,43 @@
const getTheme = () => {
var theme = localStorage.getItem('theme');
if (!theme) theme = 'light';
return theme;
};
const saveTheme = theme => {
localStorage.setItem('theme', theme);
};
const toggleTheme = () => {
const theme = getTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
saveTheme(newTheme);
};
const setTheme = themeStr => {
if (themeStr === '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');
}
};
const styleModal = () => {
const color = getTheme() === 'dark' ? '#222' : '';
$('.uk-modal-header').css('background', color);
$('.uk-modal-body').css('background', color);
$('.uk-modal-footer').css('background', color);
};
// do it before document is ready to prevent the initial flash of white
setTheme(getTheme());

View File

@@ -1,89 +1,57 @@
$(() => {
setupAcard();
});
const setupAcard = () => {
$('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard');
showModal(
$(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'),
$(card).attr('data-book-id'),
$(card).attr('data-id')
);
});
};
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) { function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath); const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle); const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle); const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function() { $('#modal button, #modal a').each(function(){
$(this).removeAttr('hidden'); $(this).removeAttr('hidden');
}); });
if (percentage === 0) { if (percentage === 0) {
$('#continue-btn').attr('hidden', ''); $('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', ''); $('#unread-btn').attr('hidden', '');
} else if (percentage === 100) { }
$('#read-btn').attr('hidden', ''); else {
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%'); $('#continue-btn').text('Continue from ' + percentage + '%');
} }
if (percentage === 100) {
$('#modal-entry-title').find('span').text(entry); $('#read-btn').attr('hidden', '');
$('#modal-entry-title').next().attr('data-id', titleID); }
$('#modal-entry-title').next().attr('data-entry-id', entryID); $('#modal-title').find('span').text(entry);
$('#modal-entry-title').next().find('.title-rename-field').val(entry); $('#modal-title').next().attr('data-id', titleID);
$('#modal-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`); $('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`); $('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
$('#read-btn').click(function() { $('#read-btn').click(function(){
updateProgress(titleID, entryID, pages); updateProgress(titleID, entryID, pages);
}); });
$('#unread-btn').click(function() { $('#unread-btn').click(function(){
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
styleModal();
} }
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `/api/progress/${tid}/${page}`
const query = $.param({ const query = $.param({entry: eid});
eid: eid
});
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.post(url, (data) => {
$.ajax({ if (data.success) {
method: 'PUT', location.reload();
url: url, }
dataType: 'json' else {
}) error = data.error;
.done(data => { alert('danger', error);
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) => { const renameSubmit = (name, eid) => {
@@ -97,29 +65,27 @@ const renameSubmit = (name, eid) => {
return; return;
} }
const query = $.param({ const query = $.param({ entry: eid });
eid: eid let url = `/api/admin/display_name/${titleId}/${name}`;
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.ajax({ $.ajax({
type: 'PUT', type: 'POST',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
}) })
.done(data => { .done(data => {
if (data.error) { if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`); alert('danger', `Failed to update display name. Error: ${data.error}`);
return; return;
} }
location.reload(); location.reload();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
const edit = (eid) => { const edit = (eid) => {
@@ -132,7 +98,8 @@ const edit = (eid) => {
url = item.find('img').attr('data-src'); url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title'); displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', ''); $('#title-progress-control').attr('hidden', '');
} else { }
else {
$('#title-progress-control').removeAttr('hidden'); $('#title-progress-control').removeAttr('hidden');
} }
@@ -140,7 +107,6 @@ const edit = (eid) => {
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName); displayNameField.attr('value', displayName);
console.log(displayNameField);
displayNameField.keyup(event => { displayNameField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid); renameSubmit(displayNameField.val(), eid);
@@ -153,19 +119,18 @@ const edit = (eid) => {
setupUpload(eid); setupUpload(eid);
UIkit.modal($('#edit-modal')).show(); UIkit.modal($('#edit-modal')).show();
styleModal();
}; };
const setupUpload = (eid) => { const setupUpload = (eid) => {
const upload = $('.upload-field'); const upload = $('.upload-field');
const bar = $('#upload-progress').get(0); const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = { const queryObj = {title: titleId};
tid: titleId
};
if (eid) if (eid)
queryObj['eid'] = eid; queryObj['entry'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`; const url = `/api/admin/upload/cover?${query}`;
console.log(url); console.log(url);
UIkit.upload('.upload-field', { UIkit.upload('.upload-field', {
url: url, url: url,
@@ -192,128 +157,3 @@ 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();
});
};
const tagsComponent = () => {
return {
loading: true,
isAdmin: false,
tags: [],
newTag: '',
inputShown: false,
tid: $('.upload-field').attr('data-title-id'),
load(admin) {
this.isAdmin = admin;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => {
this.tags = data.tags;
this.loading = false;
});
},
add() {
const tag = this.newTag.trim();
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT', () => {
this.tags.push(tag);
this.newTag = '';
});
},
keydown(event) {
if (event.key === 'Enter')
this.add()
},
rm(event) {
const tag = event.currentTarget.id.split('-')[0];
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE', () => {
const idx = this.tags.indexOf(tag);
if (idx < 0) return;
this.tags.splice(idx, 1);
});
},
toggleInput(nextTick) {
this.inputShown = !this.inputShown;
if (this.inputShown) {
nextTick(() => {
$('#tag-input').get(0).focus();
});
}
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success)
cb(data);
else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};

View File

@@ -1,5 +1,5 @@
$(() => { $(() => {
var target = base_url + 'admin/user/edit'; var target = '/admin/user/edit';
if (username) target += username; if (username) target += username;
$('form').attr('action', target); $('form').attr('action', target);
if (error) alert('danger', error); if (error) alert('danger', error);

View File

@@ -1,16 +1,11 @@
const remove = (username) => { function remove(username) {
$.ajax({ $.post('/api/admin/user/delete/' + username, function(data) {
url: `${base_url}api/admin/user/delete/${username}`, if (data.success) {
type: 'DELETE', location.reload();
dataType: 'json' }
}) else {
.done(data => { error = data.error;
if (data.success) alert('danger', error);
location.reload(); }
else });
alert('danger', data.error); }
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@@ -1,74 +1,34 @@
version: 2.0 version: 1.0
shards: shards:
ameba: ameba:
git: https://github.com/crystal-ameba/ameba.git github: crystal-ameba/ameba
version: 0.12.1
archive:
git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0
baked_file_system:
git: https://github.com/schovi/baked_file_system.git
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim:
git: https://github.com/at-grandpa/clim.git
version: 0.12.0 version: 0.12.0
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8
db: db:
git: https://github.com/crystal-lang/crystal-db.git github: crystal-lang/crystal-db
version: 0.9.0 version: 0.9.0
duktape:
git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0
exception_page: exception_page:
git: https://github.com/crystal-loot/exception_page.git github: crystal-loot/exception_page
version: 0.1.4 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: kemal:
git: https://github.com/kemalcr/kemal.git github: kemalcr/kemal
version: 0.27.0 version: 0.26.1
kemal-session:
git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1
kilt: kilt:
git: https://github.com/jeromegn/kilt.git github: jeromegn/kilt
version: 0.4.0 version: 0.4.0
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
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: radix:
git: https://github.com/luislavena/radix.git github: luislavena/radix
version: 0.3.9 version: 0.3.9
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git github: crystal-lang/crystal-sqlite3
version: 0.16.0 version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3

View File

@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.18.2 version: 0.3.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -8,36 +8,18 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.35.1 crystal: 0.34.0
license: MIT license: MIT
dependencies: dependencies:
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
kemal-session:
github: kemalcr/kemal-session
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive: development_dependencies:
github: hkalexling/archive.cr
ameba: ameba:
github: crystal-ameba/ameba github: crystal-ameba/ameba
clim:
github: at-grandpa/clim
duktape:
github: jessedoyle/duktape.cr
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
tallboy:
github: epoch/tallboy

View File

@@ -2,7 +2,7 @@ require "./spec_helper"
describe Config do describe Config do
it "creates config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |_, path| with_default_config do |_, _, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end

104
spec/mangadex_spec.cr Normal file
View File

@@ -0,0 +1,104 @@
require "./spec_helper"
include MangaDex
describe Queue do
it "creates DB at given path" do
with_queue do |_, path|
File.exists?(path).should be_true
end
end
it "pops nil when empty" do
with_queue do |queue|
queue.pop.should be_nil
end
end
it "inserts multiple jobs" do
with_queue do |queue|
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
Time.utc
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
Time.utc
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
Time.utc
j4 = Job.new "4", "4", "title", "manga_title",
JobStatus::Downloading, Time.utc
count = queue.push [j1, j2, j3, j4]
count.should eq 4
end
end
it "pops pending job" do
with_queue do |queue|
job = queue.pop
job.should_not be_nil
job.not_nil!.id.should eq "3"
end
end
it "correctly counts jobs" do
with_queue do |queue|
queue.count.should eq 4
end
end
it "deletes job" do
with_queue do |queue|
queue.delete "4"
queue.count.should eq 3
end
end
it "sets status" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.set_status JobStatus::Downloading, job
job = queue.pop
job.should_not be_nil
job.not_nil!.status.should eq JobStatus::Downloading
end
end
it "sets number of pages" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.set_pages 100, job
job = queue.pop
job.should_not be_nil
job.not_nil!.pages.should eq 100
end
end
it "adds fail/success counts" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.add_success job
queue.add_success job
queue.add_fail job
job = queue.pop
job.should_not be_nil
job.not_nil!.success_count.should eq 2
job.not_nil!.fail_count.should eq 1
end
end
it "appends status message" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.add_message "hello", job
queue.add_message "world", job
job = queue.pop
job.should_not be_nil
job.not_nil!.status_message.should eq "\nhello\nworld"
end
end
it "cleans up" do
with_queue do
true
end
State.reset
end
end

View File

@@ -1,76 +0,0 @@
require "./spec_helper"
require "../src/rename"
include Rename
describe Rule do
it "raises on nested brackets" do
expect_raises Exception do
Rule.new "[[]]"
end
expect_raises Exception do
Rule.new "{{}}"
end
end
it "raises on unclosed brackets" do
expect_raises Exception do
Rule.new "["
end
expect_raises Exception do
Rule.new "{"
end
expect_raises Exception do
Rule.new "[{]}"
end
end
it "raises when closing unopened brackets" do
expect_raises Exception do
Rule.new "]"
end
expect_raises Exception do
Rule.new "[}"
end
end
it "handles `|` in patterns" do
rule = Rule.new "{a|b|c}"
rule.render({"b" => "b"}).should eq "b"
rule.render({"a" => "a", "b" => "b"}).should eq "a"
end
it "raises on escaped characters" do
expect_raises Exception do
Rule.new "hello/world"
end
end
it "handles spaces in patterns" do
rule = Rule.new "{ a }"
rule.render({"a" => "a"}).should eq "a"
end
it "strips leading and tailing spaces" do
rule = Rule.new " hello "
rule.render({"a" => "a"}).should eq "hello"
end
it "renders a few examples correctly" do
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
rule.render({"id" => "ID"}).should eq "ID testing"
rule.render({"chapter" => "CH", "id" => "ID"})
.should eq "Ch. CH ID testing"
rule.render({} of String => String).should eq "testing"
end
it "escapes illegal characters" do
rule = Rule.new "{a}"
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
end
it "strips trailing spaces and dots" do
rule = Rule.new "hello. world. .."
rule.render({} of String => String).should eq "hello. world"
end
end

View File

@@ -1,8 +1,6 @@
require "spec" require "spec"
require "../src/queue" require "../src/context"
require "../src/server" require "../src/server"
require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@@ -39,18 +37,29 @@ end
def with_default_config def with_default_config
temp_config = get_tempfile "mango-test-config" temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path config = Config.load temp_config.path
config.set_current logger = Logger.new config.log_level
yield config, temp_config.path yield config, logger, temp_config.path
temp_config.delete temp_config.delete
end end
def with_storage def with_storage
with_default_config do with_default_config do |_, logger|
temp_db = get_tempfile "mango-test-db" temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, false storage = Storage.new temp_db.path, logger
clear = yield storage, temp_db.path clear = yield storage, temp_db.path
if clear == true if clear == true
temp_db.delete temp_db.delete
end end
end end
end end
def with_queue
with_default_config do |_, logger|
temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path, logger
clear = yield queue, temp_queue_db.path
if clear == true
temp_queue_db.delete
end
end
end

View File

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

View File

@@ -1,59 +0,0 @@
require "compress/zip"
require "archive"
# 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 = Compress::Zip::File.new filename
else
@archive_file = Archive::File.new filename
end
end
def self.open(filename : String, &)
s = self.new filename
yield s
s.close
end
def 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 Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e|
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?)
ary.push e
end
end
ary
end
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
bytes_read = io.read_fully? slice
data = slice if bytes_read
end
data
else
e.read
end
end
def check
if @archive_file.is_a? Archive::File
@archive_file.as(Archive::File).check
end
end
end

View File

@@ -3,26 +3,16 @@ require "yaml"
class Config class Config
include YAML::Serializable include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
property port : Int32 = 9000 property port : Int32 = 9000
property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library", property library_path : String = File.expand_path "~/mango/library",
home: true home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
property scan_interval_minutes : Int32 = 5 @[YAML::Field(key: "scan_interval_minutes")]
property thumbnail_generation_interval_hours : Int32 = 24 property scan_interval : Int32 = 5
property db_optimization_interval_hours : Int32 = 24
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
property plugin_path : String = File.expand_path "~/mango/plugins", property disable_ellipsis_truncation : Bool = false
home: true
property download_timeout_seconds : Int32 = 30
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -33,34 +23,23 @@ class Config
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true), home: true),
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
} }
@@singlet : Config?
def self.current
@@singlet.not_nil!
end
def set_current
@@singlet = self
end
def self.load(path : String?) def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path
config.fill_defaults config.fill_defaults
return config return config
end end
puts "The config file #{cfg_path} does not exist. " \ puts "The config file #{cfg_path} does not exist." \
"Dumping the default config there." " Do you want mango to dump the default config there? [Y/n]"
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate default = self.allocate
default.path = path
default.fill_defaults default.fill_defaults
cfg_dir = File.dirname cfg_path cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir unless Dir.exists? cfg_dir
@@ -80,17 +59,4 @@ class Config
end end
{% end %} {% end %}
end end
def preprocess
unless base_url.starts_with? "/"
raise "base url (#{base_url}) should start with `/`"
end
unless base_url.ends_with? "/"
@base_url += "/"
end
if disable_login && default_username.empty?
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
end
end end

21
src/context.cr Normal file
View File

@@ -0,0 +1,21 @@
require "./config"
require "./library"
require "./storage"
require "./logger"
class Context
property config : Config
property library : Library
property storage : Storage
property logger : Logger
property queue : MangaDex::Queue
def initialize(@config, @logger, @library, @storage, @queue)
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
@logger.{{lvl.id}} msg
end
{% end %}
end

View File

@@ -1,98 +1,25 @@
require "kemal" require "kemal"
require "../storage" require "../storage"
require "../util/*" require "../util"
class AuthHandler < Kemal::Handler class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub def initialize(@storage : Storage)
BASIC = "Basic"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def require_basic_auth(env)
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
call_next env
end
def validate_token(env)
token = env.session.string? "token"
!token.nil? && Storage.default.verify_token token
end
def validate_token_admin(env)
token = env.session.string? "token"
!token.nil? && Storage.default.verify_admin token
end
def validate_auth_header(env)
if env.request.headers[AUTH]?
if value = env.request.headers[AUTH]
if value.size > 0 && value.starts_with?(BASIC)
token = verify_user value
return false if token.nil?
env.session.string "token", token
return true
end
end
end
false
end
def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":")
Storage.default.verify_user username, password
end
def handle_opds_auth(env)
if validate_token(env) || validate_auth_header(env)
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
if request_path_startswith(env, ["/login", "/logout"]) ||
requesting_static_file env
return call_next(env)
end
unless validate_token(env) || Config.current.disable_login
env.session.string "callback", env.request.path
return redirect env, "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
env.response.status_code = 403 if should_reject
end
call_next env
end end
def call(env) def call(env)
if request_path_startswith env, ["/opds"] return call_next(env) if request_path_startswith env, ["/login", "/logout"]
handle_opds_auth env
else cookie = env.request.cookies.find { |c| c.name == "token" }
handle_auth env if cookie.nil? || !@storage.verify_token cookie.value
return env.redirect "/login"
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value
env.response.status_code = 403
end
end
call_next env
end end
end end

View File

@@ -2,17 +2,20 @@ require "kemal"
require "../logger" require "../logger"
class LogHandler < Kemal::BaseLogHandler class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : Logger)
end
def call(env) def call(env)
elapsed_time = Time.measure { call_next env } elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \ msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}" " #{env.request.resource} #{elapsed_text}"
Logger.debug msg @logger.debug msg
env env
end end
def write(msg) def write(msg)
Logger.debug msg @logger.debug msg
end end
private def elapsed_text(elapsed) private def elapsed_text(elapsed)

View File

@@ -1,6 +1,6 @@
require "baked_file_system" require "baked_file_system"
require "kemal" require "kemal"
require "../util/*" require "../util"
class FS class FS
extend BakedFileSystem extend BakedFileSystem
@@ -16,14 +16,16 @@ class FS
end end
class StaticHandler < Kemal::Handler class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env) def call(env)
if requesting_static_file env if request_path_startswith env, @dirs
file = FS.get? env.request.path file = FS.get? env.request.path
return call_next env if file.nil? return call_next env if file.nil?
slice = Bytes.new file.size slice = Bytes.new file.size
file.read slice file.read slice
return send_file env, slice, MIME.from_filename file.path return send_file env, slice, file.mime_type
end end
call_next env call_next env
end end

View File

@@ -1,5 +1,5 @@
require "kemal" require "kemal"
require "../util/*" require "../util"
class UploadHandler < Kemal::Handler class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String) def initialize(@upload_dir : String)
@@ -11,9 +11,7 @@ class UploadHandler < Kemal::Handler
return call_next env return call_next env
end end
ary = env.request.path.split(File::SEPARATOR).select do |part| ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
!part.empty?
end
ary[0] = @upload_dir ary[0] = @upload_dir
path = File.join ary path = File.join ary

431
src/library.cr Normal file
View File

@@ -0,0 +1,431 @@
require "zip"
require "mime"
require "json"
require "uri"
require "./util"
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = Zip::File.new path
@pages = file.entries.count do |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
end
file.close
@id = storage.get_id @zip_path, false
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
url = "/api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_alphanumerically a.filename, b.filename
}
.[page_num - 1]
page.open do |io|
slice = Bytes.new page.uncompressed_size
bytes_read = io.read_fully? slice
unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),
page.filename, bytes_read
end
end
end
end
class Title
property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@logger : Logger, @library : Library)
@id = storage.get_id @dir, true
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@entries = [] of Entry
@mtime = File.info(dir).modification_time
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, @logger, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz"].includes? File.extname path
zip_exception = validate_zip path
unless zip_exception.nil?
@logger.warn "File #{path} is corrupted or is not a valid zip " \
"archive. Ignoring it."
@logger.debug "Zip error: #{zip_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_alphanumerically @library.title_hash[a].title,
@library.title_hash[b].title
end
@entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary
end
def size
@entries.size + @title_ids.size
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
def display_name
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
URI.encode display_name
end
def display_name(entry_name)
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
dn
end
def set_display_name(dn)
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
save_progress username, e.title, e.pages
end
titles.each do |t|
t.read_all username
end
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
save_progress username, e.title, 0
end
titles.each do |t|
t.unread_all username
end
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
TitleInfo.new @dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
else
info.progress[username][entry] = page
end
info.save
end
end
def load_progress(username, entry)
progress = 0
TitleInfo.new @dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end
end
progress
end
def load_percetage(username, entry)
page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry }
return 0.0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
return 0.0 if @entries.empty?
read_pages = total_pages = 0
@entries.each do |e|
read_pages += load_progress username, e.title
total_pages += e.pages
end
read_pages / total_pages
end
def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1
@entries[idx + 1]
end
end
class TitleInfo
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
@[JSON::Field(ignore: true)]
property dir : String = ""
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
end
end
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
logger : Logger, storage : Storage, title_hash : Hash(String, Title)
def initialize(@dir, @scan_interval, @logger, @storage)
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@title_ids = [] of String
@title_hash = {} of String => Title
return scan if @scan_interval < 1
spawn do
loop do
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def titles
@title_ids.map { |tid| self.get_title!(tid) }
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
@logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, @logger, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
@logger.debug "Scan completed"
end
end

View File

@@ -1,239 +0,0 @@
require "image_size"
class Entry
getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_id @zip_path, false
if id.nil?
id = random_str
storage.insert_id({
path: @zip_path,
id: id,
is_title: false,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "title_id", @book.id
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg
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?
url = File.join Config.current.base_url, info_url
end
end
url
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
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
return nil if idx.nil? || idx == entries.size - 1
entries[idx + 1]
end
def previous_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == 0
@book.entries[idx - 1]
end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, page)
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
else
info.progress[username][@title] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {@title => Time.utc}
else
info.last_read[username][@title] = Time.utc
end
info.save
end
end
def load_progress(username)
progress = 0
TitleInfo.new @book.dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][@title]?.nil?
progress = info.progress[username][@title]
end
end
[progress, @pages].min
end
def load_percentage(username)
page = load_progress username
page / @pages
end
def load_last_read(username)
last_read = nil
TitleInfo.new @book.dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][@title]?.nil?
last_read = info.last_read[username][@title]
end
end
last_read
end
def finished?(username)
load_progress(username) == @pages
end
def started?(username)
load_progress(username) > 0
end
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

View File

@@ -1,250 +0,0 @@
class Library
getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title)
use_default
def initialize
register_mime_types
@dir = Config.current.library_path
# 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
@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
def titles
@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
# Helper function from src/util/util.cr
sort_titles titles, opt.not_nil!, username
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
storage = Storage.new auto_close: false
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "" }
.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
end
storage.bulk_insert_ids
storage.close
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
.map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
last_read = e.load_last_read username
pe = e.previous_entry
if last_read.nil? && pe
last_read = pe.load_last_read username
end
{
entry: e,
percentage: e.load_percentage(username),
last_read: last_read,
}
}
# Sort by by last_read, most recent first (nils at the end)
cr_entries.sort { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}
end
alias RA = NamedTuple(
entry: Entry,
percentage: Float64,
grouped_count: Int32)
def get_recently_added_entries(username)
recently_added = [] of RA
last_date_added = nil
titles.map { |t| t.deep_entries_with_date_added }.flatten
.select { |e| e[:date_added] > 1.month.ago }
.sort { |a, b| b[:date_added] <=> a[:date_added] }
.each do |e|
break if recently_added.size > 12
last = recently_added.last?
if last && e[:entry].book.id == last[:entry].book.id &&
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32)
last_hash[:grouped_count] = count + 1
# Setting the percentage to a negative value will hide the
# percentage badge on the card
last_hash[:percentage] = -1.0
recently_added[recently_added.size - 1] = RA.from last_hash
else
last_date_added = e[:date_added]
recently_added << {
entry: e[:entry],
percentage: e[:entry].load_percentage(username),
grouped_count: 1,
}
end
end
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
end
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
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
@thumbnails_count += 1
end
Logger.info "Thumbnail generation finished"
end
end

View File

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

View File

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

View File

@@ -8,10 +8,7 @@ class Logger
@@severity : Log::Severity = :info @@severity : Log::Severity = :info
use_default def initialize(level : String)
def initialize
level = Config.current.log_level
{% begin %} {% begin %}
case level.downcase case level.downcase
when "off" when "off"
@@ -26,9 +23,9 @@ class Logger
{% end %} {% end %}
@log = Log.for("") @log = Log.for("")
@backend = Log::IOBackend.new
format_proc = ->(entry : Log::Entry, io : IO) do @backend = Log::IOBackend.new
@backend.formatter = ->(entry : Log::Entry, io : IO) do
color = :default color = :default
{% begin %} {% begin %}
case entry.severity.label.to_s().downcase case entry.severity.label.to_s().downcase
@@ -45,26 +42,17 @@ class Logger
io << entry.message io << entry.message
end end
@backend.formatter = Log::Formatter.new &format_proc Log.builder.bind "*", @@severity, @backend
Log.setup @@severity, @backend
end end
# Ignores @@severity and always log msg # Ignores @@severity and always log msg
def log(msg) def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg, @backend.write Log::Entry.new "", Log::Severity::None, msg, nil
Log::Metadata.empty, nil
end
def self.log(msg)
default.log msg
end end
{% for lvl in LEVELS %} {% for lvl in LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg } @log.{{lvl.id}} { msg }
end end
def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg
end
{% end %} {% end %}
end end

View File

@@ -1,34 +0,0 @@
# 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

View File

@@ -1,6 +1,6 @@
require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename"
macro string_properties(names) macro string_properties(names)
{% for name in names %} {% for name in names %}
@@ -14,14 +14,6 @@ macro parse_strings_from_json(names)
{% end %} {% end %}
end end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex module MangaDex
class Chapter class Chapter
string_properties ["lang_code", "title", "volume", "chapter"] string_properties ["lang_code", "title", "volume", "chapter"]
@@ -72,20 +64,16 @@ module MangaDex
gname = obj["group_name#{s}"].as_s gname = obj["group_name#{s}"].as_s
@groups << {gid, gname} @groups << {gid, gname}
end end
@full_title = @title
rename_rule = Rename::Rule.new \ unless @chapter.empty?
Config.current.mangadex["chapter_rename_rule"].to_s @full_title = "Ch.#{@chapter} " + @full_title
@full_title = rename rename_rule end
unless @volume.empty?
@full_title = "Vol.#{@volume} " + @full_title
end
rescue e rescue e
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end end
def rename(rule : Rename::Rule)
hash = properties_to_hash ["id", "title", "volume", "chapter",
"lang_code", "language", "pages"]
hash["groups"] = @groups.map { |g| g[1] }.join ","
rule.render hash
end
end end
class Manga class Manga
@@ -123,18 +111,10 @@ module MangaDex
rescue e rescue e
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end end
class API class API
use_default def initialize(@base_url = "https://mangadex.org/api/")
def initialize
@base_url = Config.current.mangadex["api_url"].to_s ||
"https://mangadex.org/api/"
@lang = {} of String => String @lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row| CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0] @lang[row[1]] = row[0]

View File

@@ -1,55 +1,289 @@
require "./api" require "./api"
require "compress/zip" require "sqlite3"
module MangaDex module MangaDex
class PageJob class PageJob
property success = false property success = false
property url : String property url : String
property filename : String property filename : String
property writer : Compress::Zip::Writer property writer : Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning) def initialize(@url, @filename, @writer, @tries_remaning)
end end
end end
class Downloader < Queue::Downloader enum JobStatus
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"] Pending # 0
.to_i32 Downloading # 1
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32 Error # 2
Completed # 3
MissingPages # 4
end
use_default struct Job
property id : String
property manga_id : String
property title : String
property manga_title : String
property status : JobStatus
property status_message : String = ""
property pages : Int32 = 0
property success_count : Int32 = 0
property fail_count : Int32 = 0
property time : Time
def initialize def parse_query_result(res : DB::ResultSet)
@api = API.default @id = res.read String
super @manga_id = res.read String
@title = res.read String
@manga_title = res.read String
status = res.read Int32
@status_message = res.read String
@pages = res.read Int32
@success_count = res.read Int32
@fail_count = res.read Int32
time = res.read Int64
@status = JobStatus.new status
@time = Time.unix_ms time
end end
def pop : Queue::Job? # Raises if the result set does not contain the correct set of columns
job = nil def self.from_query_result(res : DB::ResultSet)
MainFiber.run do job = Job.allocate
DB.open "sqlite3://#{@queue.path}" do |db| job.parse_query_result res
begin job
db.query_one "select * from queue where id not like '%-%' " \ end
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res| def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
job = Queue::Job.from_query_result res end
end
rescue def to_json(json)
json.object do
{% for name in ["id", "manga_id", "title", "manga_title",
"status_message"] %}
json.field {{name}}, @{{name.id}}
{% end %}
{% for name in ["pages", "success_count", "fail_count"] %}
json.field {{name}} do
json.number @{{name.id}}
end end
{% end %}
json.field "status", @status.to_s
json.field "time" do
json.number @time.to_unix_ms
end
end
end
end
class Queue
property downloader : Downloader?
def initialize(@path : String, @logger : Logger)
dir = File.dirname path
unless Dir.exists? dir
@logger.info "The queue DB directory #{dir} does not exist. " \
"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
end
end
end
# Returns the earliest job in queue or nil if the job cannot be parsed.
# Returns nil if queue is empty
def pop
job = nil
DB.open "sqlite3://#{@path}" do |db|
begin
db.query_one "select * from queue where status = 0 " \
"or status = 1 order by time limit 1" do |res|
job = Job.from_query_result res
end
rescue
end end
end end
job job
end end
private def download(job : Queue::Job) # Push an array of jobs into the queue, and return the number of jobs
# 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
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
end
end
def reset(job : Job)
self.reset job.id
end
# 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"
end
end
def delete(id : String)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end
def delete(job : Job)
self.delete job.id
end
def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
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
end
num
end
def count
num = 0
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
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
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
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
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
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
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
end
end
def pause
@downloader.not_nil!.stopped = true
end
def resume
@downloader.not_nil!.stopped = false
end
def paused?
@downloader.not_nil!.stopped
end
end
class Downloader
property stopped = false
@downloading = false
def initialize(@queue : Queue, @api : API, @library_path : String,
@wait_seconds : Int32, @retries : Int32,
@logger : Logger)
@queue.downloader = self
spawn do
loop do
sleep 1.second
next if @stopped || @downloading
begin
job = @queue.pop
next if job.nil?
download job
rescue e
@logger.error e
end
end
end
end
private def download(job : Job)
@downloading = true @downloading = true
@queue.set_status Queue::JobStatus::Downloading, job @queue.set_status JobStatus::Downloading, job
begin begin
chapter = @api.get_chapter(job.id) chapter = @api.get_chapter(job.id)
rescue e rescue e
Logger.error e @logger.error e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@queue.add_message e.message.not_nil!, job @queue.add_message e.message.not_nil!, job
end end
@@ -58,18 +292,16 @@ module MangaDex
end end
@queue.set_pages chapter.pages.size, job @queue.set_pages chapter.pages.size, job
lib_dir = @library_path lib_dir = @library_path
rename_rule = Rename::Rule.new \ manga_dir = File.join lib_dir, chapter.manga.title
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir unless File.exists? manga_dir
Dir.mkdir_p manga_dir Dir.mkdir_p manga_dir
end end
zip_path = File.join manga_dir, "#{job.title}.cbz.part" zip_path = File.join manga_dir, "#{job.title}.cbz"
# Find the number of digits needed to store the number of pages # Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1 len = Math.log10(chapter.pages.size).to_i + 1
writer = Compress::Zip::Writer.new zip_path writer = Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue # Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size channel = Channel(PageJob).new chapter.pages.size
spawn do spawn do
@@ -78,20 +310,19 @@ module MangaDex
ext = File.extname fn ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}" fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}" @logger.debug "Downloading #{url}"
loop do loop do
sleep @wait_seconds.seconds sleep @wait_seconds.seconds
download_page page_job download_page page_job
break if page_job.success || break if page_job.success ||
page_job.tries_remaning <= 0 page_job.tries_remaning <= 0
page_job.tries_remaning -= 1 page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \ @logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \ "Retrying... Remaining retries: " \
"#{page_job.tries_remaning}" "#{page_job.tries_remaning}"
end end
channel.send page_job channel.send page_job
break unless @queue.exists? job
end end
end end
@@ -99,11 +330,8 @@ module MangaDex
page_jobs = [] of PageJob page_jobs = [] of PageJob
chapter.pages.size.times do chapter.pages.size.times do
page_job = channel.receive page_job = channel.receive
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
break unless @queue.exists? job "#{page_job.url}"
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job page_jobs << page_job
if page_job.success if page_job.success
@queue.add_success job @queue.add_success job
@@ -111,41 +339,31 @@ module MangaDex
@queue.add_fail job @queue.add_fail job
msg = "Failed to download page #{page_job.url}" msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job @queue.add_message msg, job
Logger.error msg @logger.error msg
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success } fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \ @logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed" "#{fail_count}/#{page_jobs.size} failed"
writer.close writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path, @logger.debug "cbz File created at #{zip_path}"
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive filename zip_exception = validate_zip zip_path
if !zip_exception.nil? if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \ @queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job "Error: #{zip_exception}", job
@queue.set_status Queue::JobStatus::Error, job @queue.set_status JobStatus::Error, job
elsif fail_count > 0 elsif fail_count > 0
@queue.set_status Queue::JobStatus::MissingPages, job @queue.set_status JobStatus::MissingPages, job
else else
@queue.set_status Queue::JobStatus::Completed, job @queue.set_status JobStatus::Completed, job
end end
@downloading = false @downloading = false
end end
end end
private def download_page(job : PageJob) private def download_page(job : PageJob)
Logger.debug "downloading #{job.url}" @logger.debug "downloading #{job.url}"
headers = HTTP::Headers{ headers = HTTP::Headers{
"User-agent" => "Mangadex.cr", "User-agent" => "Mangadex.cr",
} }
@@ -159,7 +377,7 @@ module MangaDex
end end
job.success = true job.success = true
rescue e rescue e
Logger.error e @logger.error e
job.success = false job.success = false
end end
end end

View File

@@ -1,132 +1,41 @@
require "./config"
require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./context"
require "./mangadex/*" require "./mangadex/*"
require "./plugin/*"
require "option_parser" require "option_parser"
require "clim"
require "tallboy"
MANGO_VERSION = "0.18.2" VERSION = "0.3.0"
# From http://www.network-science.de/ascii/ config_path = nil
BANNER = %{
_| _| OptionParser.parse do |parser|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_| parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
parser.on "-v", "--version", "Show version" do
} puts "Version #{VERSION}"
exit
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" end
parser.on "-h", "--help", "Show help" do
macro common_option puts parser
option "-c PATH", "--config=PATH", type: String, exit
desc: "Path to the config file" end
end parser.on "-c PATH", "--config=PATH",
"Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
macro throw(msg) config_path = path
puts "ERROR: #{{{msg}}}"
puts
puts "Please see the `--help`."
exit 1
end
class CLI < Clim
main do
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
# Initialize main components
Storage.default
Queue.default
Library.default
MangaDex::Downloader.default
Plugin::Downloader.default
spawn do
Server.new.start
end
MainFiber.start_and_block
end
sub "admin" do
desc "Run admin tools"
usage "mango admin [tool]"
help short: "-h"
run do |opts|
puts opts.help_string
end
sub "user" do
desc "User management tool"
usage "mango admin user [arguments] [options]"
help short: "-h"
argument "action", type: String,
desc: "Action to perform. Can be add/delete/update/list"
argument "username", type: String,
desc: "Username to update or delete"
option "-u USERNAME", "--username=USERNAME", type: String,
desc: "Username"
option "-p PASSWORD", "--password=PASSWORD", type: String,
desc: "Password"
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
common_option
run do |opts, args|
Config.load(opts.config).set_current
storage = Storage.new nil, false
case args.action
when "add"
throw "Options `-u` and `-p` required." if opts.username.nil? ||
opts.password.nil?
storage.new_user opts.username.not_nil!,
opts.password.not_nil!, opts.admin
when "delete"
throw "Argument `username` required." if args.username.nil?
storage.delete_user args.username
when "update"
throw "Argument `username` required." if args.username.nil?
username = opts.username || args.username
password = opts.password || ""
storage.update_user args.username, username.not_nil!,
password.not_nil!, opts.admin
when "list"
users = storage.list_users
table = Tallboy.table do
header ["username", "admin access"]
users.each do |name, admin|
row [name, admin]
end
end
puts table
when nil
puts opts.help_string
else
throw "Unknown action \"#{args.action}\"."
end
end
end
end
end end
end end
CLI.start(ARGV) config = Config.load config_path
logger = Logger.new config.log_level
storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
logger
api = MangaDex::API.new config.mangadex["api_url"].to_s
MangaDex::Downloader.new queue, api, config.library_path,
config.mangadex["download_wait_seconds"].to_i,
config.mangadex["download_retries"].to_i, logger
context = Context.new config, logger, library, storage, queue
server = Server.new context
server.start

View File

@@ -1,141 +0,0 @@
class Plugin
class Downloader < Queue::Downloader
use_default
def initialize
super
end
def pop : Queue::Job?
job = nil
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
end
end
job
end
private def process_filename(str)
return "_" if str == ".."
str.gsub "/", "_"
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
unless job.plugin_id
raise "Job does not have a plugin ID specificed"
end
plugin = Plugin.new job.plugin_id.not_nil!
info = plugin.select_chapter job.plugin_chapter_id.not_nil!
pages = info["pages"].as_i
manga_title = process_filename job.manga_title
chapter_title = process_filename info["title"].as_s
@queue.set_pages pages, job
lib_dir = @library_path
manga_dir = File.join lib_dir, manga_title
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
writer = Compress::Zip::Writer.new zip_path
rescue e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
raise e
end
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
if page["headers"]?
page["headers"].as_h.each do |k, v|
headers.add k, v.as_s
end
end
page_success = false
tries = 4
loop do
sleep plugin.info.wait_seconds.seconds
Logger.debug "downloading #{url}"
tries -= 1
begin
HTTP::Client.get url, headers do |res|
unless res.success?
raise "Failed to download page #{url}. " \
"[#{res.status_code}] #{res.status_message}"
end
writer.add fn, res.body_io
end
rescue e
@queue.add_fail job
fail_count += 1
msg = "Failed to download page #{url}. Error: #{e}"
@queue.add_message msg, job
Logger.error msg
Logger.debug "[failed] #{url}"
else
@queue.add_success job
Logger.debug "[success] #{url}"
page_success = true
end
break if page_success || tries < 0
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,
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive filename
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status Queue::JobStatus::Error, job
elsif fail_count > 0
@queue.set_status Queue::JobStatus::MissingPages, job
else
@queue.set_status Queue::JobStatus::Completed, job
end
@downloading = false
end
end
end

View File

@@ -1,384 +0,0 @@
require "duktape/runtime"
require "myhtml"
require "xml"
class Plugin
class Error < ::Exception
end
class MetadataError < Error
end
class PluginException < Error
end
class SyntaxError < Error
end
struct Info
{% for name in ["id", "title", "placeholder"] %}
getter {{name.id}} = ""
{% end %}
getter wait_seconds : UInt64 = 0
getter dir : String
def initialize(@dir)
info_path = File.join @dir, "info.json"
unless File.exists? info_path
raise MetadataError.new "File `info.json` not found in the " \
"plugin directory #{dir}"
end
@json = JSON.parse File.read info_path
begin
{% for name in ["id", "title", "placeholder"] %}
@{{name.id}} = @json[{{name}}].as_s
{% end %}
@wait_seconds = @json["wait_seconds"].as_i.to_u64
unless @id.alphanumeric_underscore?
raise "Plugin ID can only contain alphanumeric characters and " \
"underscores"
end
rescue e
raise MetadataError.new "Failed to retrieve metadata from plugin " \
"at #{@dir}. Error: #{e.message}"
end
end
def each(&block : String, JSON::Any -> _)
@json.as_h.each &block
end
end
struct Storage
@hash = {} of String => String
def initialize(@path : String)
unless File.exists? @path
save
end
json = JSON.parse File.read @path
json.as_h.each do |k, v|
@hash[k] = v.as_s
end
end
def []?(key)
@hash[key]?
end
def []=(key, val : String)
@hash[key] = val
end
def save
File.write @path, @hash.to_pretty_json
end
end
@@info_ary = [] of Info
@info : Info?
getter js_path = ""
getter storage_path = ""
def self.build_info_ary
@@info_ary.clear
dir = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f|
path = File.join dir, f
next unless File.directory? path
begin
@@info_ary << Info.new path
rescue e : MetadataError
Logger.warn e
end
end
end
def self.list
self.build_info_ary
@@info_ary.map do |m|
{id: m.id, title: m.title}
end
end
def info
@info.not_nil!
end
def initialize(id : String)
Plugin.build_info_ary
@info = @@info_ary.find { |i| i.id == id }
if @info.nil?
raise Error.new "Plugin with ID #{id} not found"
end
@js_path = File.join info.dir, "index.js"
@storage_path = File.join info.dir, "storage.json"
unless File.exists? @js_path
raise Error.new "Plugin script not found at #{@js_path}"
end
@rt = Duktape::Runtime.new do |sbx|
sbx.push_global_object
sbx.push_pointer @storage_path.as(Void*)
path = sbx.require_pointer(-1).as String
sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "storage_path"
def_helper_functions sbx
end
eval File.read @js_path
end
macro check_fields(ary)
{% for field in ary %}
unless json[{{field}}]?
raise "Field `{{field.id}}` is missing from the function outputs"
end
{% end %}
end
def list_chapters(query : String)
json = eval_json "listChapters('#{query}')"
begin
check_fields ["title", "chapters"]
ary = json["chapters"].as_a
ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
end
title = obj["title"]?
raise "Field `title` missing from `listChapters` outputs" if title.nil?
end
rescue e
raise Error.new e.message
end
json
end
def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')"
begin
check_fields ["title", "pages"]
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
end
rescue e
raise Error.new e.message
end
json
end
def next_page
json = eval_json "nextPage()"
return if json.size == 0
begin
check_fields ["filename", "url"]
rescue e
raise Error.new e.message
end
json
end
private def eval(str)
@rt.eval str
rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message
rescue e : Duktape::Error
raise Error.new e.message
end
private def eval_json(str)
JSON.parse eval(str).as String
end
private def def_helper_functions(sbx)
sbx.push_object
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
headers = HTTP::Headers.new
if env.get_top == 2
env.enum 1, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.get url, headers
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "get"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
body = env.require_string 1
headers = HTTP::Headers.new
if env.get_top == 3
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.post url, headers, body
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "post"
sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
selector = env.require_string 1
myhtml = Myhtml::Parser.new html
ary = myhtml.css(selector).map(&.to_html).to_a
ary_idx = env.push_array
ary.each_with_index do |str, i|
env.push_string str
env.put_prop_index ary_idx, i.to_u32
end
env.call_success
end
sbx.put_prop_string -2, "css"
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
str = XML.parse(html).inner_text
env.push_string str
env.call_success
end
sbx.put_prop_string -2, "text"
sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
name = env.require_string 1
begin
attr = XML.parse(html).first_element_child.not_nil![name]
env.push_string attr
rescue
env.push_undefined
end
env.call_success
end
sbx.put_prop_string -2, "attribute"
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
msg = env.require_string 0
env.call_success
raise PluginException.new msg
end
sbx.put_prop_string -2, "raise"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
key = env.require_string 0
env.get_global_string "storage_path"
storage_path = env.require_string -1
env.pop
storage = Storage.new storage_path
if env.get_top == 2
val = env.require_string 1
storage[key] = val
storage.save
else
val = storage[key]?
if val
env.push_string val
else
env.push_undefined
end
end
env.call_success
end
sbx.put_prop_string -2, "storage"
sbx.put_prop_string -2, "mango"
end
end

View File

@@ -1,318 +0,0 @@
require "sqlite3"
require "./util/*"
class Queue
abstract class Downloader
property stopped = false
@library_path : String = Config.current.library_path
@downloading = false
def initialize
@queue = Queue.default
@queue << self
spawn do
loop do
sleep 1.second
next if @stopped || @downloading
begin
job = pop
next if job.nil?
download job
rescue e
Logger.error e
@downloading = false
end
end
end
end
abstract def pop : Job?
private abstract def download(job : Job)
end
enum JobStatus
Pending # 0
Downloading # 1
Error # 2
Completed # 3
MissingPages # 4
end
struct Job
property id : String
property manga_id : String
property title : String
property manga_title : String
property status : JobStatus
property status_message : String = ""
property pages : Int32 = 0
property success_count : Int32 = 0
property fail_count : Int32 = 0
property time : Time
property plugin_id : String?
property plugin_chapter_id : String?
def parse_query_result(res : DB::ResultSet)
@id = res.read String
@manga_id = res.read String
@title = res.read String
@manga_title = res.read String
status = res.read Int32
@status_message = res.read String
@pages = res.read Int32
@success_count = res.read Int32
@fail_count = res.read Int32
time = res.read Int64
@status = JobStatus.new status
@time = Time.unix_ms time
ary = @id.split("-")
if ary.size == 2
@plugin_id = ary[0]
@plugin_chapter_id = ary[1]
end
end
# Raises if the result set does not contain the correct set of columns
def self.from_query_result(res : DB::ResultSet)
job = Job.allocate
job.parse_query_result res
job
end
def initialize(@id, @manga_id, @title, @manga_title, @status, @time,
@plugin_id = nil)
end
def to_json(json)
json.object do
{% for name in ["id", "manga_id", "title", "manga_title",
"status_message"] %}
json.field {{name}}, @{{name.id}}
{% end %}
{% for name in ["pages", "success_count", "fail_count"] %}
json.field {{name}} do
json.number @{{name.id}}
end
{% end %}
json.field "status", @status.to_s
json.field "time" do
json.number @time.to_unix_ms
end
json.field "plugin_id", @plugin_id if @plugin_id
end
end
end
getter path : String
@downloaders = [] of Downloader
@paused = false
use_default
def initialize(db_path : String? = nil)
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
dir = File.dirname @path
unless Dir.exists? dir
Logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
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
# Push an array of jobs into the queue, and return the number of jobs
# inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job))
start_count = self.count
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)
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
def reset(job : Job)
self.reset job.id
end
# Reset all failed tasks (missing pages and error)
def reset
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)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end
end
def delete(job : Job)
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)
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
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
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)
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
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)
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)
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)
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)
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
def <<(downloader : Downloader)
@downloaders << downloader
end
def pause
@downloaders.each { |d| d.stopped = true }
@paused = true
end
def resume
@downloaders.each { |d| d.stopped = false }
@paused = false
end
def paused?
@paused
end
end

View File

@@ -1,151 +0,0 @@
module Rename
alias VHash = Hash(String, String)
abstract class Base(T)
@ary = [] of T
def push(var)
@ary.push var
end
abstract def render(hash : VHash)
end
class Variable < Base(String)
property id : String
def initialize(@id)
end
def render(hash : VHash)
hash[@id]? || ""
end
end
class Pattern < Base(Variable)
def render(hash : VHash)
@ary.each do |v|
if hash.has_key? v.id
return v.render hash
end
end
""
end
end
class Group < Base(Pattern | String)
def render(hash : VHash)
return "" if @ary.select(&.is_a? Pattern)
.any? &.as(Pattern).render(hash).empty?
@ary.map do |e|
if e.is_a? Pattern
e.render hash
else
e
end
end.join
end
end
class Rule < Base(Group | String | Pattern)
ESCAPE = ['/']
def initialize(str : String)
parse! str
rescue e
raise "Failed to parse rename rule #{str}. Error: #{e}"
end
private def parse!(str : String)
chars = [] of Char
pattern : Pattern? = nil
group : Group? = nil
str.each_char_with_index do |char, i|
if ['[', ']', '{', '}', '|'].includes?(char) && !chars.empty?
string = chars.join
if !pattern.nil?
pattern.push Variable.new string.strip
elsif !group.nil?
group.push string
else
@ary.push string
end
chars = [] of Char
end
case char
when '['
if !group.nil? || !pattern.nil?
raise "nested groups are not allowed"
end
group = Group.new
when ']'
if group.nil?
raise "unmatched ] at position #{i}"
end
if !pattern.nil?
raise "patterns (`{}`) should be closed before closing the " \
"group (`[]`)"
end
@ary.push group
group = nil
when '{'
if !pattern.nil?
raise "nested patterns are not allowed"
end
pattern = Pattern.new
when '}'
if pattern.nil?
raise "unmatched } at position #{i}"
end
if !group.nil?
group.push pattern
else
@ary.push pattern
end
pattern = nil
when '|'
if pattern.nil?
chars.push char
end
else
if ESCAPE.includes? char
raise "the character #{char} at position #{i} is not allowed"
end
chars.push char
end
end
unless chars.empty?
@ary.push chars.join
end
if !pattern.nil?
raise "unclosed pattern {"
end
if !group.nil?
raise "unclosed group ["
end
end
def render(hash : VHash)
str = @ary.map do |e|
if e.is_a? String
e
else
e.render hash
end
end.join.strip
post_process str
end
# Post-processes the generated file/folder name
# - Handles the rare case where the string is `..`
# - Removes trailing spaces and periods
# - Replace illegal characters with `_`
private def post_process(str)
return "_" if str == ".."
str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
end
end
end

View File

@@ -1,11 +1,13 @@
struct AdminRouter require "./router"
def initialize
class AdminRouter < Router
def setup
get "/admin" do |env| get "/admin" do |env|
layout "admin" layout "admin"
end end
get "/admin/user" do |env| get "/admin/user" do |env|
users = Storage.default.list_users users = @context.storage.list_users
username = get_username env username = get_username env
layout "user" layout "user"
end end
@@ -30,15 +32,29 @@ struct AdminRouter
# would not contain `admin` # would not contain `admin`
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
Storage.default.new_user username, password, admin if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
redirect env, "/admin/user" @context.storage.new_user username, password, admin
env.redirect "/admin/user"
rescue e rescue e
Logger.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"error" => e.message}) query: hash_to_query({"error" => e.message})
redirect env, redirect_url.to_s env.redirect redirect_url.to_s
end end
post "/admin/user/edit/:original_username" do |env| post "/admin/user/edit/:original_username" do |env|
@@ -49,21 +65,38 @@ struct AdminRouter
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"] original_username = env.params.url["original_username"]
Storage.default.update_user \ if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
if password.size != 0
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
@context.storage.update_user \
original_username, username, password, admin original_username, username, password, admin
redirect env, "/admin/user" env.redirect "/admin/user"
rescue e rescue e
Logger.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \ query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message}) "admin" => admin, "error" => e.message})
redirect env, redirect_url.to_s env.redirect redirect_url.to_s
end end
get "/admin/downloads" do |env| get "/admin/downloads" do |env|
mangadex_base_url = Config.current.mangadex["base_url"] base_url = @context.config.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
end end

View File

@@ -1,184 +1,16 @@
require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa"
struct APIRouter class APIRouter < Router
@@api_json : String? def setup
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?",
"margin" => "number",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.object "tagsResult", {
"success" => "boolean",
"tags" => "$strAry?",
"error" => "string?",
}
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| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
title = Library.default.get_title tid title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -188,102 +20,46 @@ struct APIRouter
send_img env, img send_img env, img
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
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 = Library.default.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
Logger.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| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = Library.default.get_title tid title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 404 env.response.status_code = 500
e.message e.message
end end
end end
Koa.describe "Returns the entire library with all titles and entries" get "/api/book" do |env|
Koa.response 200, ref: "$library" send_json env, @context.library.to_json
get "/api/library" do |env|
send_json env, Library.default.to_json
end end
Koa.describe "Triggers a library scan"
Koa.tag "admin"
Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
Library.default.scan @context.library.scan
ms = (Time.utc - start).total_milliseconds ms = (Time.utc - start).total_milliseconds
send_json env, { send_json env, {
"milliseconds" => ms, "milliseconds" => ms,
"titles" => Library.default.titles.size, "titles" => @context.library.titles.size,
}.to_json }.to_json
end end
Koa.describe "Returns the thumbnail generation progress between 0 and 1" post "/api/admin/user/delete/:username" do |env|
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 begin
username = env.params.url["username"] username = env.params.url["username"]
Storage.default.delete_user username @context.storage.delete_user username
rescue e rescue e
Logger.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -293,36 +69,25 @@ struct APIRouter
end end
end end
Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD post "/api/progress/:title/:page" do |env|
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 begin
username = get_username env username = get_username env
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (@context.library.get_title env.params.url["title"])
.not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["eid"]? entry_id = env.params.query["entry"]?
if !entry_id.nil? if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil! entry = title.get_entry(entry_id).not_nil!
raise "incorrect page value" if page < 0 || page > entry.pages raise "incorrect page value" if page < 0 || page > entry.pages
entry.save_progress username, page title.save_progress username, entry.title, page
elsif page == 0 elsif page == 0
title.unread_all username title.unread_all username
else else
title.read_all username title.read_all username
end end
rescue e rescue e
Logger.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -332,47 +97,12 @@ struct APIRouter
end end
end end
Koa.describe "Updates the reading progress of multiple entries in a title" post "/api/admin/display_name/:title/:name" do |env|
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 begin
username = get_username env title = (@context.library.get_title env.params.url["title"])
title = (Library.default.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
Logger.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 = (Library.default.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["eid"]? entry = env.params.query["entry"]?
if entry.nil? if entry.nil?
title.set_display_name name title.set_display_name name
else else
@@ -380,7 +110,7 @@ struct APIRouter
title.set_display_name eobj.not_nil!.title, name title.set_display_name eobj.not_nil!.title, name
end end
rescue e rescue e
Logger.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -390,77 +120,48 @@ struct APIRouter
end end
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| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
id = env.params.url["id"] id = env.params.url["id"]
api = MangaDex::API.default api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
manga = api.get_manga id manga = api.get_manga id
send_json env, manga.to_info_json send_json env, manga.to_info_json
rescue e rescue e
Logger.error e @context.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
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| post "/api/admin/mangadex/download" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter| jobs = chapters.map { |chapter|
Queue::Job.new( MangaDex::Job.new(
chapter["id"].as_s, chapter["id"].as_s,
chapter["manga_id"].as_s, chapter["manga_id"].as_s,
chapter["full_title"].as_s, chapter["full_title"].as_s,
chapter["manga_title"].as_s, chapter["manga_title"].as_s,
Queue::JobStatus::Pending, MangaDex::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i Time.unix chapter["time"].as_s.to_i
) )
} }
inserted_count = Queue.default.push jobs inserted_count = @context.queue.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
}.to_json }.to_json
rescue e rescue e
Logger.error e @context.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
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" => Queue.default.get_all,
"paused" => Queue.default.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| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = Queue.default.get_all jobs = @context.queue.get_all
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => jobs,
"paused" => Queue.default.paused?, "paused" => @context.queue.paused?,
"success" => true, "success" => true,
}.to_json }.to_json
rescue e rescue e
@@ -471,19 +172,6 @@ struct APIRouter
end end
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| post "/api/admin/mangadex/queue/:action" do |env|
begin begin
action = env.params.url["action"] action = env.params.url["action"]
@@ -491,20 +179,20 @@ struct APIRouter
case action case action
when "delete" when "delete"
if id.nil? if id.nil?
Queue.default.delete_status Queue::JobStatus::Completed @context.queue.delete_status MangaDex::JobStatus::Completed
else else
Queue.default.delete id @context.queue.delete id
end end
when "retry" when "retry"
if id.nil? if id.nil?
Queue.default.reset @context.queue.reset
else else
Queue.default.reset id @context.queue.reset id
end end
when "pause" when "pause"
Queue.default.pause @context.queue.pause
when "resume" when "resume"
Queue.default.resume @context.queue.resume
else else
raise "Unknown queue action #{action}" raise "Unknown queue action #{action}"
end end
@@ -518,22 +206,6 @@ struct APIRouter
end end
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| post "/api/admin/upload/:target" do |env|
begin begin
target = env.params.url["target"] target = env.params.url["target"]
@@ -548,17 +220,17 @@ struct APIRouter
case target case target
when "cover" when "cover"
title_id = env.params.query["tid"] title_id = env.params.query["title"]
entry_id = env.params.query["eid"]? entry_id = env.params.query["entry"]?
title = Library.default.get_title(title_id).not_nil! title = @context.library.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \ unless ["image/jpeg", "image/png"].includes? \
MIME.from_filename? filename MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG" raise "The uploaded image must be either JPEG or PNG"
end end
ext = File.extname filename ext = File.extname filename
upload = Upload.new Config.current.upload_path upload = Upload.new @context.config.upload_path, @context.logger
url = upload.path_to_url upload.save "img", ext, part.body url = upload.path_to_url upload.save "img", ext, part.body
if url.nil? if url.nil?
@@ -587,187 +259,5 @@ struct APIRouter
}.to_json }.to_json
end end
end end
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.query["query"].as String
plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query
chapters = json["chapters"]
title = json["title"]
send_json env, {
"success" => true,
"chapters" => chapters,
"title" => title,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
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
chapters = env.params.json["chapters"].as Array(JSON::Any)
manga_title = env.params.json["title"].as String
jobs = chapters.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{ch["id"]}",
"", # manga_id
ch["title"].as_s,
manga_title,
Queue::JobStatus::Pending,
Time.utc
)
}
inserted_count = Queue.default.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.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 = Library.default.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,
"margin" => Config.current.page_margin,
}.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 = (Library.default.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
Logger.error e
env.response.status_code = 404
end
end
Koa.describe "Gets the tags of a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$tagsResult"
get "/api/tags/:tid" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tags = title.tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
put "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.add_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a tag from a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.delete_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
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
end end

View File

@@ -1,17 +1,19 @@
struct MainRouter require "./router"
def initialize
class MainRouter < Router
def setup
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url render "src/views/login.ecr"
render "src/views/login.html.ecr"
end end
get "/logout" do |env| get "/logout" do |env|
begin begin
env.session.delete_string "token" cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value
rescue e rescue e
Logger.error "Error when attempting to log out: #{e}" @context.error "Error when attempting to log out: #{e}"
ensure ensure
redirect env, "/login" env.redirect "/login"
end end
end end
@@ -19,142 +21,48 @@ struct MainRouter
begin begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
token = Storage.default.verify_user(username, password).not_nil! token = @context.storage.verify_user(username, password).not_nil!
env.session.string "token", token cookie = HTTP::Cookie.new "token", token
cookie.expires = Time.local.shift years: 1
callback = env.session.string? "callback" env.response.cookies << cookie
if callback env.redirect "/"
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/"
end
rescue rescue
redirect env, "/login" env.redirect "/login"
end end
end end
get "/library" do |env| get "/" do |env|
begin begin
titles = @context.library.titles
username = get_username env username = get_username env
percentage = titles.map &.load_percetage username
sort_opt = SortOptions.from_info_json Library.default.dir, username use_dotdotdot = !@context.config.disable_ellipsis_truncation
get_sort_opt layout "index"
titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
layout "library"
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/book/:title" do |env| get "/book/:title" do |env|
begin begin
title = (Library.default.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env username = get_username env
percentage = title.entries.map { |e|
sort_opt = SortOptions.from_info_json title.dir, username title.load_percetage username, e.title
get_sort_opt }
use_dotdotdot = !@context.config.disable_ellipsis_truncation
entries = title.sorted_entries username, sort_opt
percentage = title.load_percentage_for_all_entries username, sort_opt
title_percentage = title.titles.map &.load_percentage username
layout "title" layout "title"
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 500
end
end
get "/download" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/download/plugins" do |env|
begin
id = env.params.query["plugin"]?
plugins = Plugin.list
plugin = nil
if id
plugin = Plugin.new id
elsif !plugins.empty?
plugin = Plugin.new plugins[0][:id]
end
layout "plugin-download"
rescue e
Logger.error e
env.response.status_code = 500
end
end
get "/" do |env|
begin
username = get_username env
continue_reading = Library.default
.get_continue_reading_entries username
recently_added = Library.default.get_recently_added_entries username
start_reading = Library.default.get_start_reading_titles username
titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0
layout "home"
rescue e
Logger.error e
env.response.status_code = 500
end
end
get "/tags/:tag" do |env|
begin
username = get_username env
tag = env.params.url["tag"]
sort_opt = SortOptions.new
get_sort_opt
title_ids = Storage.default.get_tag_titles tag
raise "Tag #{tag} not found" if title_ids.empty?
titles = title_ids.map { |id| Library.default.get_title id }
.select Title
titles = sort_titles titles, sort_opt, username
percentage = titles.map &.load_percentage username
layout "tag"
rescue e
Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/tags" do |env| get "/download" do |env|
tags = Storage.default.list_tags.map do |tag| base_url = @context.config.mangadex["base_url"]
{ layout "download"
tag: tag,
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
count: Storage.default.get_tag_titles(tag).size,
}
end
# Sort by :count reversly, and then sort by :tag
tags.sort! do |a, b|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
end
layout "tags"
end
get "/api" do |env|
render "src/views/api.html.ecr"
end end
end end
end end

View File

@@ -1,18 +0,0 @@
struct OPDSRouter
def initialize
get "/opds" do |env|
titles = Library.default.titles
render_xml "src/views/opds/index.xml.ecr"
end
get "/opds/book/:title_id" do |env|
begin
title = Library.default.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.xml.ecr"
rescue e
Logger.error e
env.response.status_code = 404
end
end
end
end

View File

@@ -1,51 +1,59 @@
struct ReaderRouter require "./router"
def initialize
class ReaderRouter < Router
def setup
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
username = get_username env title = (@context.library.get_title env.params.url["title"]).not_nil!
title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg
# load progress # load progress
page_idx = [1, entry.load_progress username].max username = get_username env
page = title.load_progress username, entry.title
# 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
# start from page 1 if the user has finished reading the entry env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
page_idx = 1 if entry.finished? username
redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/reader/:title/:entry/:page" do |env| get "/reader/:title/:entry/:page" do |env|
begin begin
base_url = Config.current.base_url title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
page_idx = env.params.url["page"].to_i page = env.params.url["page"].to_i
if page_idx > entry.pages || page_idx <= 0 raise "" if page > entry.pages || page <= 0
raise "Page #{page_idx} not found."
# save progress
username = get_username env
title.save_progress username, entry.title, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"/api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx|
"/reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "/book/#{title.id}"
next_entry = title.next_entry entry
unless next_page > entry.pages
next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
end end
exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username
unless next_entry.nil? unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
end end
render "src/views/reader.html.ecr" render "src/views/reader.ecr"
rescue e rescue e
Logger.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end

6
src/routes/router.cr Normal file
View File

@@ -0,0 +1,6 @@
require "../context"
class Router
def initialize(@context : Context)
end
end

View File

@@ -1,12 +1,11 @@
require "kemal" require "kemal"
require "kemal-session" require "./context"
require "./library/*"
require "./handlers/*" require "./handlers/*"
require "./util/*" require "./util"
require "./routes/*" require "./routes/*"
class Server class Server
def initialize def initialize(@context : Context)
error 403 do |env| error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}" message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message" layout "message"
@@ -15,45 +14,34 @@ class Server
message = "HTTP 404: Mango cannot find the page #{env.request.path}" message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message" layout "message"
end end
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
{% if flag?(:release) %} MainRouter.new(@context).setup
error 500 do |env| AdminRouter.new(@context).setup
message = "HTTP 500: Internal server error. Please try again later." ReaderRouter.new(@context).setup
layout "message" APIRouter.new(@context).setup
end
{% end %}
MainRouter.new
AdminRouter.new
ReaderRouter.new
APIRouter.new
OPDSRouter.new
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new @context.logger
add_handler AuthHandler.new add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new Config.current.upload_path add_handler UploadHandler.new @context.config.upload_path
{% if flag?(:release) %} {% if flag?(:release) %}
# when building for relase, embed the static files in binary # when building for relase, embed the static files in binary
Logger.debug "We are in release mode. Using embedded static files." @context.debug "We are in release mode. Using embedded static files."
serve_static false serve_static false
add_handler StaticHandler.new add_handler StaticHandler.new
{% end %} {% end %}
Kemal::Session.config do |c|
c.timeout = 365.days
c.secret = Config.current.session_secret
c.cookie_name = "mango-sessid-#{Config.current.port}"
c.path = Config.current.base_url
end
end end
def start def start
Logger.debug "Starting Kemal server" @context.debug "Starting Kemal server"
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
Kemal.config.port = Config.current.port Kemal.config.port = @context.config.port
Kemal.run Kemal.run
end end
end end

View File

@@ -2,7 +2,7 @@ require "sqlite3"
require "crypto/bcrypt" require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util/*" require "./util"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -13,161 +13,76 @@ def verify_password(hash, pw)
end end
class Storage class Storage
@path : String def initialize(@path : String, @logger : Logger)
@db : DB::Database? dir = File.dirname path
@insert_ids = [] of IDTuple
alias IDTuple = NamedTuple(path: String,
id: String,
is_title: Bool)
use_default
def initialize(db_path : String? = nil, init_user = true, *,
@auto_close = true)
@path = db_path || Config.current.db_path
dir = File.dirname @path
unless Dir.exists? dir unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \ @logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do DB.open "sqlite3://#{path}" do |db|
DB.open "sqlite3://#{@path}" do |db| begin
begin # We create the `ids` table first. even if the uses has an
# v0.18.0 # early version installed and has the `user` table only,
db.exec "create table tags (id text, tag text, unique (id, tag))" # we will still be able to create `ids`
db.exec "create index tags_id_idx on tags (id)" db.exec "create table ids" \
db.exec "create index tags_tag_idx on tags (tag)" "(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)"
# v0.15.0 db.exec "create table users" \
db.exec "create table thumbnails " \ "(username text, password text, token text, admin integer)"
"(id text, data blob, filename text, " \ rescue e
"mime text, size integer)" unless e.message.not_nil!.ends_with? "already exists"
db.exec "create unique index tn_index on thumbnails (id)" @logger.fatal "Error when checking tables in DB: #{e}"
raise e
# v0.1.1
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)"
# v0.1.0
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 end
else
# Verifies that the default username in config is valid @logger.debug "Creating DB file at #{@path}"
if Config.current.disable_login db.exec "create unique index username_idx on users (username)"
username = Config.current.default_username db.exec "create unique index token_idx on users (token)"
unless username_exists username random_pw = random_str
raise "Default username #{username} does not exist" hash = hash_password random_pw
end db.exec "insert into users values (?, ?, ?, ?)",
end "admin", hash, nil, 1
end @logger.log "Initial user created. You can log in with " \
unless @auto_close "#{{"username" => "admin", "password" => random_pw}}"
@db = DB.open "sqlite3://#{@path}"
end end
end end
end end
macro init_admin
random_pw = random_str
hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)",
"admin", hash, nil, 1
Logger.log "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}"
end
private def get_db(&block : DB::Database ->)
if @db.nil?
DB.open "sqlite3://#{@path}" do |db|
yield db
end
else
yield @db.not_nil!
end
end
def username_exists(username)
exists = false
MainFiber.run do
get_db do |db|
exists = db.query_one("select count(*) from users where " \
"username = (?)", username, as: Int32) > 0
end
end
exists
end
def username_is_admin(username)
is_admin = false
MainFiber.run do
get_db do |db|
is_admin = db.query_one("select admin from users where " \
"username = (?)", username, as: Int32) > 0
end
end
is_admin
end
def verify_user(username, password) def verify_user(username, password)
out_token = nil DB.open "sqlite3://#{@path}" do |db|
MainFiber.run do begin
get_db do |db| hash, token = db.query_one "select password, token from " \
begin "users where username = (?)",
hash, token = db.query_one "select password, token from " \ username, as: {String, String?}
"users where username = (?)", unless verify_password hash, password
username, as: {String, String?} @logger.debug "Password does not match the hash"
unless verify_password hash, password return nil
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 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
end end
out_token
end end
def verify_token(token) def verify_token(token)
username = nil username = nil
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| begin
begin username = db.query_one "select username from users where " \
username = db.query_one "select username from users where " \ "token = (?)", token, as: String
"token = (?)", token, as: String rescue e
rescue e @logger.debug "Unable to verify token"
Logger.debug "Unable to verify token"
end
end end
end end
username username
@@ -175,14 +90,12 @@ class Storage
def verify_admin(token) def verify_admin(token)
is_admin = false is_admin = false
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| begin
begin is_admin = db.query_one "select admin from users where " \
is_admin = db.query_one "select admin from users where " \ "token = (?)", token, as: Bool
"token = (?)", token, as: Bool rescue e
rescue e @logger.debug "Unable to verify user as admin"
Logger.debug "Unable to verify user as admin"
end
end end
end end
is_admin is_admin
@@ -190,12 +103,10 @@ class Storage
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| db.query "select username, admin from users" do |rs|
db.query "select username, admin from users" do |rs| rs.each do
rs.each do results << {rs.read(String), rs.read(Bool)}
results << {rs.read(String), rs.read(Bool)}
end
end end
end end
end end
@@ -203,219 +114,58 @@ class Storage
end end
def new_user(username, password, admin) def new_user(username, password, admin)
validate_username username
validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| hash = hash_password password
hash = hash_password password db.exec "insert into users values (?, ?, ?, ?)",
db.exec "insert into users values (?, ?, ?, ?)", username, hash, nil, admin
username, hash, nil, admin
end
end end
end end
def update_user(original_username, username, password, admin) def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username DB.open "sqlite3://#{@path}" do |db|
validate_password password unless password.empty? if password.size == 0
MainFiber.run do db.exec "update users set username = (?), admin = (?) " \
get_db do |db| "where username = (?)",
if password.empty? username, admin, original_username
db.exec "update users set username = (?), admin = (?) " \ else
"where username = (?)", hash = hash_password password
username, admin, original_username db.exec "update users set username = (?), admin = (?)," \
else "password = (?) where username = (?)",
hash = hash_password password username, admin, hash, original_username
db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",
username, admin, hash, original_username
end
end end
end end
end end
def delete_user(username) def delete_user(username)
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| db.exec "delete from users where username = (?)", username
db.exec "delete from users where username = (?)", username
end
end end
end end
def logout(token) def logout(token)
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| begin
begin db.exec "update users set token = (?) where token = (?)", nil, token
db.exec "update users set token = (?) where token = (?)", nil, token rescue
rescue
end
end end
end end
end end
def get_id(path, is_title) def get_id(path, is_title)
id = nil id = random_str
MainFiber.run do DB.open "sqlite3://#{@path}" do |db|
get_db do |db| begin
id = db.query_one? "select id from ids where path = (?)", path, id = db.query_one "select id from ids where path = (?)", path,
as: {String} as: {String}
rescue
db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
end end
end end
id id
end end
def insert_id(tp : IDTuple)
@insert_ids << tp
end
def bulk_insert_ids
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
img
end
def get_title_tags(id : String) : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def get_tag_titles(tag : String) : Array(String)
tids = [] of String
MainFiber.run do
get_db do |db|
db.query "select id from tags where tag = (?)", tag do |rs|
rs.each do
tids << rs.read String
end
end
end
end
tids
end
def list_tags : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select distinct tag from tags" do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def add_tag(id : String, tag : String)
err = nil
MainFiber.run do
begin
get_db do |db|
db.exec "insert into tags values (?, ?)", id, tag
end
rescue e
err = e
end
end
raise err.not_nil! if err
end
def delete_tag(id : String, tag : String)
MainFiber.run do
get_db do |db|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
end
end
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
# Delete dangling tags
trash_tags_count = db.query_one "select count(*) from tags " \
"where id not in " \
"(select id from ids)", as: Int32
if trash_tags_count > 0
db.exec "delete from tags where id not in (select id from ids)"
Logger.info "#{trash_tags_count} dangling tags deleted"
end
end
Logger.info "DB optimization finished"
end
end
def close
MainFiber.run do
unless @db.nil?
@db.not_nil!.close
end
end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.string self json.string self
end end

View File

@@ -1,10 +1,10 @@
require "./util/*" require "./util"
class Upload class Upload
def initialize(@dir : String) def initialize(@dir : String, @logger : Logger)
unless Dir.exists? @dir unless Dir.exists? @dir
Logger.info "The uploads directory #{@dir} does not exist. " \ @logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
end end
@@ -19,7 +19,7 @@ class Upload
file_path = File.join full_dir, filename file_path = File.join full_dir, filename
unless Dir.exists? full_dir unless Dir.exists? full_dir
Logger.debug "creating directory #{full_dir}" @logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir Dir.mkdir_p full_dir
end end
@@ -50,7 +50,7 @@ class Upload
end end
if ary.empty? if ary.empty?
Logger.warn "File #{path} is not in the upload directory #{@dir}" @logger.warn "File #{path} is not in the upload directory #{@dir}"
return return
end end

101
src/util.cr Normal file
View File

@@ -0,0 +1,101 @@
require "big"
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
macro layout(name)
begin
cookie = env.request.cookies.find { |c| c.name == "token" }
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
end
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.ecr", "src/views/layout.ecr"
end
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
false
end
def is_numeric(str)
/^\d+/.match(str) != nil
end
def split_by_alphanumeric(str)
arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" }
end
arr
end
def compare_alphanumerically(c, d)
is_c_bigger = c.size <=> d.size
if c.size > d.size
d += [nil] * (c.size - d.size)
elsif c.size < d.size
c += [nil] * (d.size - c.size)
end
c.zip(d) do |a, b|
return -1 if a.nil?
return 1 if b.nil?
if is_numeric(a) && is_numeric(b)
compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0
else
compare = a <=> b
return compare if compare != 0
end
end
is_c_bigger
end
def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
def validate_zip(path : String) : Exception?
file = Zip::File.new path
file.close
return
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
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

View File

@@ -1,94 +0,0 @@
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 "-", ""
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end
def register_mime_types
{
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar",
}.each do |k, v|
MIME.register k, v
end
end
struct Int
def or(other : Int)
if self == 0
other
else
self
end
end
end
struct Nil
def or(other : Int)
other
end
end
macro use_default
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
end
class String
def alphanumeric_underscore?
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
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary = titles
case opt.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

View File

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

View File

@@ -1,109 +0,0 @@
# Web related helper functions/macros
macro layout(name)
base_url = Config.current.base_url
begin
is_admin = false
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
if Config.current.disable_login
is_admin = Storage.default.
username_is_admin Config.current.default_username
end
if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
Logger.error message
page = "Error"
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
begin
token = env.session.string "token"
(Storage.default.verify_token token).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
else
raise e
end
end
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def send_attachment(env, path)
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.any? { |prefix| env.request.path.starts_with? prefix }
end
def requesting_static_file(env)
request_path_startswith env, STATIC_DIRS
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
macro get_sort_opt
sort_method = env.params.query["sort"]?
if sort_method
is_ascending = true
ascend = env.params.query["ascend"]?
if ascend && ascend.to_i? == 0
is_ascending = false
end
sort_opt = SortOptions.new sort_method, is_ascending
end
end
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

18
src/views/admin.ecr Normal file
View File

@@ -0,0 +1,18 @@
<ul class="uk-list uk-list-large uk-list-divider">
<li data-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>
</li>
<li data-url="/admin/downloads">Download Manager</li>
</ul>
<hr class="uk-divider-icon">
<a class="uk-button uk-button-danger" href="/logout">Log Out</a>
<% content_for "script" do %>
<script src="/js/admin.js"></script>
<% end %>

View File

@@ -1,36 +0,0 @@
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li>
<a class="uk-link-reset" @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>
</a>
</li>
<li>
<a class="uk-link-reset" @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>
</a>
</li>
<li>
<span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
<option>Dark</option>
<option>Light</option>
<option>System</option>
</select>
</li>
</ul>
<hr class="uk-divider-icon">
<p class="uk-text-meta">Version: v<%= MANGO_VERSION %></p>
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/admin.js"></script>
<% end %>

View File

@@ -1,14 +0,0 @@
<!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>

View File

@@ -1,87 +0,0 @@
<% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
<% grouped_count = item[:grouped_count] %>
<% if grouped_count == 1 %>
<% item = item[:entry] %>
<% else %>
<% item = item[:entry].book %>
<% end %>
<% else %>
<% grouped_count = 1 %>
<% end %>
<div class="item"
<% if item.is_a? Entry %>
id="<%= item.id %>"
<% end %>>
<div class="acard
<% if item.is_a? Entry && item.err_msg.nil? %>
<%= "is_entry" %>
<% end %>
"
<% if item.is_a? Entry %>
<% if item.err_msg %>
onclick="location='<%= base_url %>reader/<%= item.book.id %>/<%= item.id %>'"
<% else %>
data-encoded-path="<%= item.encoded_path %>"
data-pages="<%= item.pages %>"
data-progress="<%= (progress * 100).round(1) %>"
data-encoded-book-title="<%= item.book.encoded_display_name %>"
data-encoded-title="<%= item.encoded_display_name %>"
data-book-id="<%= item.book.id %>"
data-id="<%= item.id %>"
<% end %>
<% else %>
onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>>
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true, selecting: false}" :class="{selected: selected}" @count.window="selecting = $event.detail.count > 0"
<% 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-height-1-1 uk-width-1-1" x-show="selecting" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')"></div>
<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">
<% unless progress < 0 || progress > 100 || progress.nan? %>
<div class="uk-card-badge label"><%= (progress * 100).round(1) %>%</div>
<% end %>
<h3 class="uk-card-title break-word
<% if page == "home" && item.is_a? Entry %>
<%= "uk-margin-remove-bottom" %>
<% end %>
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
</h3>
<% if page == "home" && item.is_a? Entry %>
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
<% end %>
<% if item.is_a? Entry %>
<% if item.err_msg %>
<p class="uk-text-meta uk-margin-remove-bottom">Error <span uk-icon="info"></span></p>
<div uk-dropdown><%= item.err_msg %></div>
<% else %>
<p class="uk-text-meta"><%= item.pages %> pages</p>
<% end %>
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.content_label %></p>
<% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,3 +0,0 @@
<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>

View File

@@ -1,32 +0,0 @@
<div id="modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
&nbsp;
<% unless page == "home" %>
<% if is_admin %>
<a id="modal-edit-btn" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
<% end %>
<a id="modal-download-btn" class="uk-icon-button" uk-icon="icon:download"></a>
</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">
<p>Read</p>
<p uk-margin>
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
<a id="continue-btn" class="uk-button uk-button-primary"></a>
</p>
<p>Progress</p>
<p uk-margin>
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
</p>
</div>
</div>
</div>

View File

@@ -1,18 +0,0 @@
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script>
</head>

View File

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

View File

@@ -1,12 +0,0 @@
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)">
<p class="uk-text-meta" @selectstart.prevent>
<span style="position:relative; bottom:3px; margin-right:5px;">Tags: </span>
<template x-for="tag in tags" :key="tag">
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" x-show="isAdmin" @click="rm($event)" :id="`${tag}-rm`"><span uk-icon="close" style="margin-right: 5px; position: relative; bottom: 1.5px;"></span></a><a class="uk-link-reset" x-text="tag" :href="`<%= base_url %>tags/${encodeURIComponent(tag)}`"></a>
</span>
</template>
<a class="uk-link-reset" style="position:relative; bottom:3px;" :uk-icon="inputShown ? 'close' : 'plus'" @click="toggleInput($nextTick)" x-show="isAdmin"></a>
</p>
<input id="tag-input" class="uk-input" type="text" placeholder="Type in a new tag and hit enter" x-model="newTag" @keydown="keydown($event)" x-show="inputShown">
</div>

View File

@@ -0,0 +1,32 @@
<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>
</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>Actions</th>
</tr>
</thead>
</table>
<% content_for "script" do %>
<script>
var baseURL = "<%= base_url %>".replace(/\/$/, "");
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="/js/alert.js"></script>
<script src="/js/download-manager.js"></script>
<% end %>

View File

@@ -1,69 +0,0 @@
<div x-data="component()" x-init="init()">
<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 @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<% content_for "script" do %>
<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>
<% end %>

83
src/views/download.ecr Normal file
View File

@@ -0,0 +1,83 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
</div>
<div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
</div>
</div>
<div class"uk-grid-small" uk-grid hidden id="manga-details">
<div class="uk-width-1-4@s">
<img id="cover">
</div>
<div class="uk-width-1-4@s">
<p id="title"></p>
<p id="artist"></p>
<p id="author"></p>
</div>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
<div class="uk-margin">
<label class="uk-form-label" for="lang-select">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="group-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="chapter-range">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
</div>
</div>
<div id="selection-controls" class="uk-margin" hidden>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p id="filter-notification" hidden></p>
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
</table>
<% content_for "script" do %>
<script>
var baseURL = "<%= base_url %>".replace(/\/$/, "");
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="/js/alert.js"></script>
<script src="/js/download.js"></script>
<% end %>

View File

@@ -1,83 +0,0 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
</div>
<div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
</div>
</div>
<div class"uk-grid-small" uk-grid hidden id="manga-details">
<div class="uk-width-1-4@s">
<img id="cover">
</div>
<div class="uk-width-1-4@s">
<p id="title"></p>
<p id="artist"></p>
<p id="author"></p>
</div>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
<div class="uk-margin">
<label class="uk-form-label" for="lang-select">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="group-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="chapter-range">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
</div>
</div>
<div id="selection-controls" class="uk-margin" hidden>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p id="filter-notification" hidden></p>
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
</table>
<% content_for "script" do %>
<script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %>

View File

@@ -1,83 +0,0 @@
<%- if new_user && empty_library -%>
<div class="uk-container uk-text-center">
<i class="fas fa-plus" style="font-size: 80px;"></i>
<h2>Add your first manga</h2>
<p style="margin-bottom: 40px;">We can't find any files yet. Add some to your library and they'll appear here.</p>
<dl class="uk-description-list">
<dt style="font-weight: 500;">Current library path</dt>
<dd><code><%= Config.current.library_path %></code></dd>
<dt style="font-weight: 500;">Want to change your library path?</dt>
<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 %> minutes for the library scan to complete
<% if is_admin %>
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
<% end %>.
</dd>
</dl>
</div>
<%- elsif new_user && empty_library == false -%>
<div class="uk-container uk-text-center">
<i class="fas fa-book-open" style="font-size: 80px;"></i>
<h2>Read your first manga</h2>
<p>Once you start reading, Mango will remember where you left off
and show your entries here.</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- elsif new_user == false && empty_library == false -%>
<%- if continue_reading.empty? && recently_added.empty? -%>
<div class="uk-container uk-text-center">
<img src="<%= base_url %>img/banner.png" style="max-width: 400px; padding: 0 20px;">
<p>A self-hosted manga server and reader</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- end -%>
<%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2>
<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] %>
<%= render_component "card" %>
<%- end -%>
</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 class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- recently_added.each do |ra| -%>
<% item = ra %>
<% progress = ra[:percentage] %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%= render_component "entry-modal" %>
<%- end -%>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<% end %>

51
src/views/index.ecr Normal file
View File

@@ -0,0 +1,51 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="name-up">▲ Name</option>
<option id="name-down">▼ Name</option>
<option id="date-up">▲ Date Modified</option>
<option id="date-down">▼ Date Modified</option>
<option id="progress-up">▲ Progress</option>
<option id="progress-down">▼ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<%- end -%>
<h3 class="uk-card-title break-word" uk-tooltip="<%= t.display_name.gsub("\"", "&quot;") %>" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<% content_for "script" do %>
<% if use_dotdotdot %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script>
<% end %>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>

75
src/views/layout.ecr Normal file
View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="/css/mango.css" />
<script defer src="/js/fontawesome.min.js"></script>
<script defer src="/js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
<hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li>
</ul>
</div>
</div>
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
</div>
</div>
<script>
setTheme(getTheme());
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
</html>

View File

@@ -1,91 +0,0 @@
<!DOCTYPE html>
<html>
<%= render_component "head" %>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent">
<a href="#">Download</a>
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</li>
<% end %>
<hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li>
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</div>
</li>
<% end %>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
</div>
<div class="uk-section uk-section-small">
</div>
<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>
setTheme();
const base_url = "<%= base_url %>";
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
</html>

View File

@@ -1,30 +0,0 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<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" %>
<% end %>
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

43
src/views/login.ecr Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head>
<body>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1">
<div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
<div class="uk-width-1-1@m">
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
<h3 class="uk-card-title uk-text-center">Log In</h3>
<form action="/login" method="post">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
setTheme(getTheme());
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body>
</html>

View File

@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html>
<% page = "Login" %>
<%= render_component "head" %>
<body>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1">
<div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
<div class="uk-width-1-1@m">
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
<h3 class="uk-card-title uk-text-center">Log In</h3>
<form action="<%= base_url %>login" method="post">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
setTheme();
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:index</id>
<link rel="self" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title>Library</title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
</feed>

View File

@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:<%= title.id %></id>
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title><%= HTML.escape(title.display_name) %></title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% title.titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
<% title.entries.each do |e| %>
<% 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/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>

Some files were not shown because too many files have changed in this diff Show More