Compare commits

..

121 Commits

Author SHA1 Message Date
Alex Ling f7b8e2d852 Bump version to v0.17.1 2020-12-27 09:46:14 +00:00
Alex Ling 946017c8bd Fix function redeclaration 2020-12-27 09:42:06 +00:00
Alex Ling ec5256dabd Improve batch mark UX (#97) 2020-12-27 09:42:06 +00:00
Alex Ling 4e707076a1 By default use the system theme setting (#111) 2020-12-27 09:42:06 +00:00
Alex Ling 66a3cc268b Merge branch 'master' into dev 2020-12-26 09:34:23 +00:00
Alex Ling 96949905b9 Cache entry display names
This improves the title page load time (#116)
2020-12-26 09:32:03 +00:00
Alex Ling 30c0199039 Merge branch 'dev' 2020-12-26 07:33:04 +00:00
Alex Ling 7a7cb78f82 Check bool environment variables are "1" or "true" 2020-12-26 07:11:10 +00:00
Alex Ling 8931ba8c43 Bump version to 0.17.0 2020-12-26 04:02:58 +00:00
Alex Ling d50981c151 Fix typos 2020-12-26 04:01:57 +00:00
Alex Ling df4deb1415 Allow proxy with authentication 2020-12-22 02:32:07 +00:00
Alex Ling aa5e999ed4 Allow users to disable SSL verification 2020-12-21 06:04:26 +00:00
Alex Ling 84d4b0c529 Switch to redoc and update API description 2020-12-21 06:04:26 +00:00
Alex Ling d3e5691478 Add overall description of the API 2020-12-14 15:20:50 +00:00
Alex Ling 1000b02ae0 Exclude /src/routes/api.cr from line width check 2020-12-14 14:59:18 +00:00
Alex Ling 1f795889a9 Move the entry download route to API 2020-12-14 13:03:23 +00:00
Alex Ling d33b45233a Use the correct verbs in the API 2020-12-14 12:49:56 +00:00
Alex Ling 4f6df5b9a3 Rename bulk-progress to bulk_progress 2020-12-14 11:54:25 +00:00
Alex Ling 341b586cb3 Add API documentation 2020-12-14 11:09:38 +00:00
Alex Ling 9dcc9665ce Cancel a download job when deleted from web UI 2020-12-12 16:15:16 +00:00
Alex Ling 1cd90926df Bind boolean attributes 2020-12-11 10:22:08 +00:00
Alex Ling ac1ff61e6d Move theme.js to common.js
This reduces the number of JS files to include when loading
2020-12-11 10:11:39 +00:00
Alex Ling 6ea41f79e9 Simplify the showControl calls on reader page 2020-12-11 09:47:32 +00:00
Alex Ling dad02a2a30 Move getProp and setProp to common.js 2020-12-11 09:46:56 +00:00
Alex Ling 280490fb36 Rewrite the download manager page 2020-12-11 07:46:47 +00:00
Alex Ling 455315a362 Upgrade to Crystal 0.35.1 2020-12-11 07:46:47 +00:00
Alex Ling df51406638 Use $GITHUB_ENV [skip ci] 2020-11-24 13:43:54 +08:00
Alex Ling 531d42ef18 [skip ci] enable set-env
https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
2020-11-24 13:38:15 +08:00
Alex Ling 2645e8cd05 Merge branch 'dev' 2020-11-24 05:31:06 +00:00
Alex Ling b2dc44a919 Reverse J and K for page navigation 2020-11-24 05:09:06 +00:00
Alex Ling c8db397a3b Bump version to v0.16.0 2020-11-24 04:30:47 +00:00
Alex Ling 6384d4b77a Log "DB optimization finished" in the info level 2020-11-24 04:05:07 +00:00
Alex Ling 1039732d87 Log the full file path in error messages (#123) 2020-11-24 04:03:53 +00:00
Alex Ling 011123f690 Allow keyboard navigation on reader page (#124) 2020-11-24 03:57:38 +00:00
Alex Ling e602a35b0c Merge branch 'dev' 2020-11-02 16:32:08 +00:00
Alex Ling 7792d3426e Bump version to v0.15.1 2020-11-01 09:22:05 +00:00
Alex Ling b59c8f85ad Fix scroller issues in continuous reader (#121) 2020-10-31 04:29:46 +00:00
Alex Ling 18834ac28e Set thumbnail size and mimetype 2020-10-29 04:06:44 +00:00
Alex Ling bf68e32ac8 Merge branch 'dev' 2020-10-25 07:57:26 +00:00
Alex Ling 54eb041fe4 Update README 2020-10-25 07:29:19 +00:00
Alex Ling 57d8c100f9 Bump version to v0.15.0 2020-10-25 07:22:38 +00:00
Alex Ling 56d973b99d Get progress when page loads and when post 2020-10-25 07:21:08 +00:00
Alex Ling 670e5cdf6a Better logging when optimizing DB 2020-10-25 07:09:37 +00:00
Alex Ling 1b35392f9c Remove unnecessary properties 2020-10-25 07:09:21 +00:00
Alex Ling c4e1ffe023 Trigger thumbnail generation from the admin page 2020-10-25 05:41:27 +00:00
Alex Ling 44f4959477 Finish thumbnail generation and DB optimization
(#93)
2020-10-24 04:13:11 +00:00
Alex Ling 0582b57d60 Add config options for optimization tasks 2020-10-24 03:50:26 +00:00
Alex Ling 83d96fd2a1 Add the route to serve thumbnails 2020-10-23 12:30:47 +00:00
Alex Ling 8ac89c420c Add helper methods for thumbnail generation 2020-10-23 12:30:29 +00:00
Alex Ling 968c2f4ad5 Update DB to save thumbnails 2020-10-23 12:29:20 +00:00
Alex Ling ad940f30d5 Update image_size.cr to 0.4.0 for better err msg 2020-10-23 12:21:05 +00:00
Alex Ling 308ad4e063 Only truncate visible titles to improve load time 2020-10-20 14:36:56 +00:00
Alex Ling 4d709b7eb5 Update default config in README 2020-10-18 12:53:43 +00:00
Alex Ling 5760ad924e Bump version to v0.14.0 2020-10-18 12:22:26 +00:00
Alex Ling fff171c8c9 Bump version to v0.13.0 2020-10-18 11:39:24 +00:00
Alex Ling 44ff566a1d Merge branch 'feature/paged-reader' into dev 2020-10-15 11:52:15 +00:00
Alex Ling 853f422964 Configurable read timeout (#108) 2020-10-15 11:51:04 +00:00
Alex Ling 3bb0917374 Allow /manga/<id> URL for MangaDex 2020-10-15 11:38:22 +00:00
Alex Ling a86f0d0f34 Add paged reading mode 2020-10-09 10:09:42 +00:00
Alex Ling 16a9d7fc2e Merge pull request #110 from XavierSchiller/master
[arm64] Fix Wrong libgc.so location when building Image
2020-09-27 20:07:44 +08:00
Xavier ee2b4abc85 Fix Wrong libgc.so location when building Image.
The Repo Maintainer was using the location of libgc.so from the armhf package, however, according to:
https://debian.pkgs.org/9/debian-main-arm64/libgc-dev_7.4.2-8_arm64.deb.html and
https://packages.ubuntu.com/focal/arm64/libgc-dev/filelist
it exists under /usr/lib/aarch64-linux-gnu/
2020-09-27 14:35:43 +05:30
Alex Ling a6c2799521 Bump version to v0.12.3 2020-09-22 08:55:29 +00:00
Alex Ling 2370e4d2c6 Add browserstack as a sponsor 2020-09-22 08:54:20 +00:00
Alex Ling 32b0384ea0 Clearer gulpfile 2020-09-22 08:46:53 +00:00
Alex Ling 50d4ffdb7b Use babel and polyfill.io 2020-09-22 07:40:47 +00:00
Alex Ling 96463641f9 Update progress on last page (#105) 2020-09-21 04:35:23 +00:00
Alex Ling ddbba5d596 Bump version to v0.12.2 2020-09-17 16:08:52 +00:00
Alex Ling 2a04f4531e Bound the page number in the reader route
fixes #104
2020-09-17 16:06:01 +00:00
Alex Ling a5b6fb781f Bump version to v0.12.1 2020-09-17 13:32:00 +00:00
Alex Ling 8dfdab9d73 Respect the base URL in direct download link (#103) 2020-09-17 13:29:52 +00:00
Alex Ling 3a95270dfb Don't copy unused UIKit files 2020-09-17 13:25:35 +00:00
Alex Ling 2960ca54df Move fontawesome to NPM 2020-09-17 13:20:24 +00:00
Alex Ling f5fe3c6b1c Use image_size.cr v0.2.0 2020-09-16 15:40:01 +00:00
Alex Ling a612cc15fb Bump version to v0.12.0 2020-09-12 14:35:56 +00:00
Alex Ling c9c0818069 Add inline documentation to reader.js 2020-09-12 14:32:29 +00:00
Alex Ling 2f8efc382f Clean up 2020-09-12 14:05:15 +00:00
Alex Ling a0fb1880bd Update Dockerfile [skip ci] 2020-09-12 13:59:32 +00:00
Alex Ling a408f14425 Add .dockerignore 2020-09-12 13:59:14 +00:00
Alex Ling 243b6c8927 Typo fix [skip ci] 2020-09-12 07:13:15 +00:00
Alex Ling ff3a44d017 Update ARM dockerfiles to use image_size.cr 2020-09-12 07:03:30 +00:00
Alex Ling 67ef1f7112 DRY when listing archive entries 2020-09-12 06:58:03 +00:00
Alex Ling 5d7b8a1ef9 Skip error entries in OPDS feed 2020-09-12 06:57:42 +00:00
Alex Ling a68f3eea95 Allow hyphens in username (#99) 2020-09-12 05:29:25 +00:00
Alex Ling 220fc42bf2 Add system dependencies for image_size.cr 2020-09-11 17:02:45 +00:00
Alex Ling a45e6ea3da Rewrite web reader 2020-09-11 17:00:42 +00:00
Alex Ling 88394d4636 Expose page ratios through API 2020-09-11 17:00:28 +00:00
Alex Ling ef1ab940f5 Fix GitHub tags of dependencies in the Dockerfiles
[skip ci]
2020-08-16 16:45:57 +00:00
Alex Ling 97a1c408d8 Bump version to v0.11.0 2020-08-16 12:44:02 +00:00
Alex Ling abbf77df13 Merge branch 'master' into dev 2020-08-10 14:32:53 +00:00
Alex Ling 3b4021f680 Workflow retry hack
I got random "Invalid memory access" when running `crystal build`.
This is probably a compiler or LLVM bug.
We use this temporary hack to retry until they fix it.
2020-08-10 13:14:05 +00:00
Alex Ling 68b1923cb6 Clear title ID at the end of scans
This minimizes the chance of getting an unexpected empty home page
2020-08-10 11:45:50 +00:00
Alex Ling 3cdd4b29a5 Add back to top button to all pages (#95) 2020-08-10 11:42:23 +00:00
Alex Ling af84c0f6de Fix typo 2020-08-08 17:04:42 +08:00
Alex Ling 85a65f84d0 Remove unnecessary "require" statements 2020-08-06 18:10:13 +00:00
Alex Ling 5027a911cd Respect the *_PROXY environment variables (#94) 2020-08-06 17:01:53 +00:00
Alex Ling ac63bf7599 Add sponsors [skip ci] 2020-08-06 12:49:43 +08:00
Alex Ling 30b0e0b8fb Pin down mythml and duktape versions in Dockerfile
[skip ci]
2020-08-05 12:00:49 +00:00
Alex Ling ddda058d8d Fix spec 2020-08-05 09:59:52 +00:00
Alex Ling 46db25e8e0 Fix wildcard in workflow 2020-08-05 09:50:46 +00:00
Alex Ling c07f421322 Fix CLI tool not exiting 2020-08-05 09:48:31 +00:00
Alex Ling 99a77966ad Add arm64v8 to Makefile and rename object files 2020-08-05 09:48:03 +00:00
Alex Ling d00b917575 Build the object file in Action 2020-08-04 17:24:36 +00:00
Alex Ling 4fd8334c37 Name the object file 2020-08-04 17:24:13 +00:00
Alex Ling 3aa4630558 Use Crystal 0.34.0 2020-08-04 17:23:19 +00:00
Alex Ling cde5af7066 Remove interactive prompt for easier use in docker 2020-08-04 12:57:40 +00:00
Alex Ling eb528e1726 Add the arm32v7 target to Makefile 2020-08-04 11:50:07 +00:00
Alex Ling 5e01cc38fe Fix downloaders 2020-08-04 11:36:36 +00:00
Alex Ling 9a787ccbc3 Formatting 2020-08-04 11:36:24 +00:00
Alex Ling 8a83c0df4e ARM support (#25, #78) 2020-08-04 11:00:33 +00:00
Alex Ling 87dea01917 Add ASCII banner, because we can :) 2020-08-02 17:52:52 +00:00
Alex Ling 586ee4f0ba Bump version to v0.10.0 2020-08-02 12:33:31 +00:00
Alex Ling 53f3387e1a Rephrase the plugin part in README 2020-08-02 12:32:14 +00:00
Alex Ling be5d1918aa Add offset to the sticky bar 2020-08-02 12:29:49 +00:00
Alex Ling df2cc0ffa9 Display nested titles and entries separately 2020-08-02 10:43:46 +00:00
Alex Ling b8cfc3a201 Remove unnecessary ids from HTML 2020-08-02 10:43:24 +00:00
Alex Ling 8dc60ac2ea Add select all button to the selection bar 2020-08-02 09:28:31 +00:00
Alex Ling 1719335d02 Add "Start Reading" section to home page (#92) 2020-08-01 15:17:18 +00:00
Alex Ling 0cd46abc66 Finish batch marking (#75) 2020-07-30 11:39:23 +00:00
Alex Ling e4fd7c58ee Add multi-select for cards in web interface 2020-07-30 08:32:00 +00:00
Alex Ling d4abee52db Fix .uk-card-media-top width 2020-07-30 08:29:41 +00:00
Alex Ling d29c94e898 Use Alpine.js 2020-07-30 08:28:54 +00:00
63 changed files with 2290 additions and 818 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
lib
+13 -4
View File
@@ -12,20 +12,29 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.34.0-alpine image: crystallang/crystal:0.35.1-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 run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
- name: Build - name: Build
run: make static run: make static || make static
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload artifact - name: Upload binary
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: mango name: mango
path: 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
+1 -1
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 ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10}) run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
- name: Publish to Dockerhub - name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master uses: elgohr/Publish-Docker-Github-Action@master
with: with:
+3 -4
View File
@@ -1,11 +1,10 @@
FROM crystallang/crystal:0.34.0-alpine AS builder FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
COPY package*.json . RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \ RUN make static || make static
&& make static
FROM library/alpine FROM library/alpine
+14
View File
@@ -0,0 +1,14 @@
FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+14
View File
@@ -0,0 +1,14 @@
FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+8 -2
View File
@@ -12,10 +12,10 @@ setup: libs
yarn gulp dev yarn gulp dev
build: libs build: libs
crystal build src/mango.cr --release --progress crystal build src/mango.cr --release --progress --error-trace
static: uglify | libs static: uglify | libs
crystal build src/mango.cr --release --progress --static crystal build src/mango.cr --release --progress --static --error-trace
libs: libs:
shards install --production shards install --production
@@ -31,6 +31,12 @@ check:
./bin/ameba ./bin/ameba
./dev/linewidth.sh ./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
+16 -5
View File
@@ -12,8 +12,9 @@ Mango is a self-hosted manga server and reader. Its features include
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- 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
- [Plugins](https://github.com/hkalexling/mango-plugins) support - Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app - 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
@@ -51,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.9.0 Mango - Manga Server and Web Reader. Version 0.17.1
Usage: Usage:
@@ -76,22 +77,27 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
--- ---
port: 9000 port: 9000
base_url: / base_url: /
session_secret: mango-session-secret
library_path: ~/mango/library library_path: ~/mango/library
db_path: ~/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 upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
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}' chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}' manga_rename_rule: '{title}'
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
### Library Structure ### Library Structure
@@ -139,8 +145,13 @@ 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 interest in code contributions. Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/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) [![](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)
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \ [ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \ && echo "The above lines exceed the 80 characters limit" \
|| exit 0 || exit 0
+45 -31
View File
@@ -1,15 +1,43 @@
const gulp = require('gulp'); const gulp = require('gulp');
const minify = require("gulp-babel-minify"); const babel = require('gulp-babel');
const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css'); const minifyCss = require('gulp-minify-css');
const less = require('gulp-less'); const less = require('gulp-less');
gulp.task('copy-uikit-js', () => { // Copy libraries from node_moduels to public/js
return gulp.src('node_modules/uikit/dist/js/*.min.js') gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js')); .pipe(gulp.dest('public/js'));
}); });
gulp.task('minify-js', () => { // Copy UIKit SVG icons to public/img
return gulp.src('public/js/*.js') 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 builtIns: false
@@ -17,40 +45,26 @@ gulp.task('minify-js', () => {
.pipe(gulp.dest('dist/js')); .pipe(gulp.dest('dist/js'));
}); });
gulp.task('less', () => { // Minify CSS and output to dist
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
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'));
}); });
gulp.task('copy-uikit-icons', () => { // Copy static files (includeing images) to dist
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('img', () => {
return gulp.src('public/img/*')
.pipe(gulp.dest('dist/img'));
});
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp.src('public/*.*') return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
base: 'public'
})
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
gulp.task('default', gulp.parallel( // Set up the public folder for development
gulp.series('copy-uikit-js', 'minify-js'), gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
gulp.series('less', 'minify-css'),
gulp.series('copy-uikit-icons', 'img'),
'copy-files'
));
gulp.task('dev', gulp.parallel( // Set up the dist folder for deployment
'copy-uikit-js', 'less', 'copy-uikit-icons' gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
));
// Default task
gulp.task('default', gulp.series('dev', 'deploy'));
+22 -19
View File
@@ -1,21 +1,24 @@
{ {
"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": {
"gulp": "^4.0.2", "@babel/preset-env": "^7.11.5",
"gulp-babel-minify": "^0.5.1", "gulp": "^4.0.2",
"gulp-less": "^4.0.1", "gulp-babel": "^8.0.0",
"gulp-minify-css": "^1.2.4", "gulp-babel-minify": "^0.5.1",
"less": "^3.11.3" "gulp-less": "^4.0.1",
}, "gulp-minify-css": "^1.2.4",
"scripts": { "less": "^3.11.3"
"uglify": "gulp" },
}, "scripts": {
"dependencies": { "uglify": "gulp"
"uikit": "^3.5.4" },
} "dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "^3.5.4"
}
} }
+30
View File
@@ -7,6 +7,7 @@
} }
.uk-card-media-top { .uk-card-media-top {
width: 100%;
height: 250px; height: 250px;
} }
@@ -122,3 +123,32 @@ td>.uk-dropdown {
.uk-light .uk-description-list>dt { .uk-light .uk-description-list>dt {
color: #555; 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);
}
+60 -32
View File
@@ -1,40 +1,68 @@
let scanning = false;
const scan = () => {
scanning = true;
$('#scan-status > div').removeAttr('hidden');
$('#scan-status > span').attr('hidden', '');
const color = $('#scan').css('color');
$('#scan').css('color', 'gray');
$.post(base_url + 'api/admin/scan', (data) => {
const ms = data.milliseconds;
const titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
$('#scan-status > span').removeAttr('hidden');
$('#scan').css('color', color);
$('#scan-status > div').attr('hidden', '');
scanning = false;
});
}
String.prototype.capitalize = function() {
return this.charAt(0).toUpperCase() + this.slice(1);
}
$(() => { $(() => {
$('li').click((e) => {
const url = $(e.currentTarget).attr('data-url');
if (url) {
$(location).attr('href', url);
}
});
const setting = loadThemeSetting(); const setting = loadThemeSetting();
$('#theme-select').val(setting.capitalize()); $('#theme-select').val(capitalize(setting));
$('#theme-select').change((e) => { $('#theme-select').change((e) => {
const newSetting = $(e.currentTarget).val().toLowerCase(); const newSetting = $(e.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
}); });
getProgress();
setInterval(getProgress, 5000);
}); });
/**
* Capitalize String
*
* @function capitalize
* @param {string} str - The string to be capitalized
* @return {string} The capitalized string
*/
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
/**
* Get the thumbnail generation progress from the API
*
* @function getProgress
*/
const getProgress = () => {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
setProp('progress', data.progress);
const generating = data.progress > 0
setProp('generating', generating);
});
};
/**
* Trigger the thumbnail generation
*
* @function generateThumbnails
*/
const generateThumbnails = () => {
setProp('generating', true);
setProp('progress', 0.0);
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(getProgress);
};
/**
* Trigger the scan
*
* @function scan
*/
const scan = () => {
setProp('scanning', true);
setProp('scanMs', -1);
setProp('scanTitles', 0);
$.post(`${base_url}api/admin/scan`)
.then(data => {
setProp('scanMs', data.milliseconds);
setProp('scanTitles', data.titles);
})
.always(() => {
setProp('scanning', false);
});
}
+147
View File
@@ -0,0 +1,147 @@
/**
* --- Alpine helper functions
*/
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
* @param {string} selector - The jQuery selector to the root element
*/
const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @param {string} selector - The jQuery selector to the root element
* @return {*} The data property
*/
const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key];
};
/**
* --- Theme related functions
* Note: In the comments below we treat "theme" and "theme setting"
* differently. A theme can have only two values, either "dark" or
* "light", while a theme setting can have the third value "system".
*/
/**
* Check if the system setting prefers dark theme.
* from https://flaviocopes.com/javascript-detect-dark-mode/
*
* @function preferDarkMode
* @return {bool}
*/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
* Check whether a given string represents a valid theme setting
*
* @function validThemeSetting
* @param {string} theme - The string representing the theme setting
* @return {bool}
*/
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
/**
* Load theme setting from local storage, or use 'light'
*
* @function loadThemeSetting
* @return {string} A theme setting ('dark', 'light', or 'system')
*/
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = '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');
});
}
});
+22 -13
View File
@@ -1,17 +1,26 @@
const truncate = () => { /**
$('.uk-card-title').each((i, e) => { * Truncate a .uk-card-title element
$(e).dotdotdot({ *
truncate: 'letter', * @function truncate
watch: true, * @param {object} e - The title element to truncate
callback: (truncated) => { */
if (truncated) { const truncate = (e) => {
$(e).attr('uk-tooltip', $(e).attr('data-title')); $(e).dotdotdot({
} else { truncate: 'letter',
$(e).removeAttr('uk-tooltip'); watch: true,
} callback: (truncated) => {
if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
} }
}); }
}); });
}; };
truncate(); $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});
+87 -108
View File
@@ -1,28 +1,42 @@
$(() => { /**
$('input.uk-checkbox').each((i, e) => { * Get the current queue and update the view
$(e).change(() => { *
loadConfig(); * @function load
*/
const load = () => {
try {
setProp('loading', true);
} catch {}
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
setProp('jobs', data.jobs);
setProp('paused', data.paused);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
setProp('loading', false);
}); });
});
loadConfig();
load();
const intervalMS = 5000;
setTimeout(() => {
setInterval(() => {
if (globalConfig.autoRefresh !== true) return;
load();
}, intervalMS);
}, intervalMS);
});
var globalConfig = {};
var loading = false;
const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
}; };
const remove = (id) => {
var url = base_url + 'api/admin/mangadex/queue/delete'; /**
* Perform an action on either a specific job or the entire queue
*
* @function jobAction
* @param {string} action - The action to perform. Should be either 'delete' or 'retry'
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
*/
const jobAction = (action, id) => {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({ url += '?' + $.param({
id: id id: id
@@ -35,42 +49,24 @@ const remove = (id) => {
}) })
.done(data => { .done(data => {
if (!data.success && data.error) { if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`); alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return; return;
} }
load(); load();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const refresh = (id) => {
var url = base_url + 'api/admin/mangadex/queue/retry';
if (id !== undefined)
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to restart download job. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
/**
* Pause/resume the download
*
* @function toggle
*/
const toggle = () => { const toggle = () => {
$('#pause-resume-btn').attr('disabled', ''); setProp('toggling', true);
const paused = $('#pause-resume-btn').text() === 'Resume download'; const action = getProp('paused') ? 'resume' : 'pause';
const action = paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`; const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -82,64 +78,47 @@ const toggle = () => {
}) })
.always(() => { .always(() => {
load(); load();
$('#pause-resume-btn').removeAttr('disabled'); setProp('toggling', false);
}); });
}; };
const load = () => {
if (loading) return;
loading = true;
console.log('fetching');
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
console.log(data);
const btnText = data.paused ? "Resume download" : "Pause download";
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending';
if (obj.status === 'Completed')
cls += 'label-success';
if (obj.status === 'Error')
cls += 'label-danger';
if (obj.status === 'MissingPages')
cls += 'label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : ''; /**
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`; * Get the uk-label class name for a given job status
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>` : ''; * @function statusClass
return `<tr id="chapter-${obj.id}"> * @param {string} status - The job status
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td> * @return {string} The class name string
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td> */
<td>${obj.success_count}/${obj.pages}</td> const statusClass = status => {
<td>${moment(obj.time).fromNow()}</td> let cls = 'label ';
<td>${statusSpan} ${dropdown}</td> switch (status) {
<td>${obj.plugin_id || ""}</td> case 'Pending':
<td> cls += 'label-pending';
<a onclick="remove('${obj.id}')" uk-icon="trash"></a> break;
${retryBtn} case 'Completed':
</td> cls += 'label-success';
</tr>`; break;
}); case 'Error':
cls += 'label-danger';
const tbody = `<tbody>${rows.join('')}</tbody>`; break;
$('tbody').remove(); case 'MissingPages':
$('table').append(tbody); cls += 'label-warning';
}) break;
.fail((jqXHR, status) => { }
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); return cls;
})
.always(() => {
loading = false;
});
}; };
$(() => {
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
ws.onmessage = event => {
const data = JSON.parse(event.data);
setProp('jobs', data.jobs);
setProp('paused', data.paused);
};
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
});
+1 -1
View File
@@ -95,7 +95,7 @@ const search = () => {
try { try {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path); const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} catch (e) { } catch (e) {
int_id = parseInt(input); int_id = parseInt(input);
-5
View File
File diff suppressed because one or more lines are too long
+6 -7
View File
@@ -33,14 +33,13 @@ const search = () => {
if (searching) if (searching)
return; return;
const query = $('#search-input').val(); const query = $.param({
query: $('#search-input').val(),
plugin: pid
});
$.ajax({ $.ajax({
type: 'POST', type: 'GET',
url: base_url + 'api/admin/plugin/list', url: `${base_url}api/admin/plugin/list?${query}`,
data: JSON.stringify({
query: query,
plugin: pid
}),
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
}) })
+279 -70
View File
@@ -1,64 +1,149 @@
$(function() { let lastSavedPage = page;
function bind() { let items = [];
var controller = new ScrollMagic.Controller(); let longPages = false;
// replace history on scroll $(() => {
$('img').each(function(idx) { getPages();
var scene = new ScrollMagic.Scene({
triggerElement: $(this).get(),
triggerHook: 'onEnter',
reverse: true
})
.addTo(controller)
.on('enter', function(event) {
current = $(event.target.triggerElement()).attr('id');
replaceHistory(current);
})
.on('leave', function(event) {
var prev = $(event.target.triggerElement()).prev();
current = $(prev).attr('id');
replaceHistory(current);
});
});
// poor man's infinite scroll $('#page-select').change(() => {
var scene = new ScrollMagic.Scene({ const p = parseInt($('#page-select').val());
triggerElement: $('.next-url').get(), toPage(p);
triggerHook: 'onEnter', });
offset: -500
}) $('#mode-select').change(() => {
.addTo(controller) const mode = $('#mode-select').val();
.on('enter', function() { const curIdx = parseInt($('#page-select').val());
var nextURL = $('.next-url').attr('href');
$('.next-url').remove(); updateMode(mode, curIdx);
if (!nextURL) { });
console.log('No .next-url found. Reached end of page'); });
var lastURL = $('img').last().attr('id');
// load the reader URL for the last page to update reading progrss to 100% $(window).resize(() => {
$.get(lastURL); const mode = getProp('mode');
$('#next-btn').removeAttr('hidden'); if (mode === 'continuous') return;
return;
} const wideScreen = $(window).width() > $(window).height();
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) { const propMode = wideScreen ? 'height' : 'width';
if (status === 'error') console.log(xhr.statusText); setProp('mode', propMode);
if (status === 'success') { });
console.log(nextURL + ' loaded');
// new page loaded to #hidden, we now append it /**
$('.uk-section > .uk-container').append($('#hidden .uk-container').children()); * Update the reader mode
$('#hidden').empty(); *
bind(); * @function updateMode
} * @param {string} mode - The mode. Can be one of the followings:
}); * {'continuous', 'paged', 'height', 'width'}
}); * @param {number} targetPage - The one-based index of the target page
*/
const updateMode = (mode, targetPage) => {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
} }
bind(); setProp('mode', propMode);
});
$('#page-select').change(function() {
jumpTo(parseInt($('#page-select').val()));
});
function showControl(idx) { if (mode === 'continuous') {
waitForPage(items.length, () => {
setupScroller();
});
}
waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
});
};
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
});
const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
const storedMode = localStorage.getItem('mode') || 'continuous';
setProp('mode', storedMode);
updateMode(storedMode, page);
$('#mode-select').val(storedMode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
};
/**
* Check if a page exists every 100ms. If so, invoke the callback function.
*
* @function waitForPage
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {string} idx - One-based index of the current page
*/
const showControl = (idx) => {
const pageCount = $('#page-select > option').length; const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText); $('#progress-label').text(progressText);
@@ -66,19 +151,143 @@ function showControl(idx) {
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
} }
function jumpTo(page) { /**
var ary = window.location.pathname.split('/'); * Redirect to a URL
ary[ary.length - 1] = page; *
ary.shift(); // remove leading `/` * @function redirect
ary.unshift(window.location.origin); * @param {string} url - The target URL
window.location.replace(ary.join('/')); */
} const redirect = (url) => {
function replaceHistory(url) {
history.replaceState(null, "", url);
console.log('reading ' + url);
}
function redirect(url) {
window.location.replace(url); window.location.replace(url);
} }
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const replaceHistory = (idx) => {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
}
});
});
};
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @function saveProgress
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
const saveProgress = (idx, cb) => {
idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 ||
longPages ||
idx === 1 || idx === items.length
) {
lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
/**
* Mark progress to 100% and redirect to the next entry
* Used as the onclick handler for the "Next Entry" button
*
* @function nextEntry
* @param {string} nextUrl - URL of the next entry
*/
const nextEntry = (nextUrl) => {
saveProgress(items.length, () => {
redirect(nextUrl);
});
};
/**
* Show the next or the previous page
*
* @function flipPage
* @param {bool} isNext - Whether we are going to the next page
*/
const flipPage = (isNext) => {
const curItem = getProp('curItem');
const idx = parseInt(curItem.id);
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
toPage(newIdx);
if (isNext)
setProp('flipAnimation', 'right');
else
setProp('flipAnimation', 'left');
setTimeout(() => {
setProp('flipAnimation', null);
}, 500);
replaceHistory(newIdx);
saveProgress(newIdx);
};
/**
* Handle the global keydown events
*
* @function keyHandler
* @param {event} event - The $event object
*/
const keyHandler = (event) => {
const mode = getProp('mode');
if (mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
flipPage(true);
};
-5
View File
File diff suppressed because one or more lines are too long
-72
View File
@@ -1,72 +0,0 @@
// https://flaviocopes.com/javascript-detect-dark-mode/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
// dark / light / system
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light';
return str;
};
// dark / light
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light';
localStorage.setItem('theme', setting);
};
// when toggled, Auto will be changed to light or dark
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+84 -14
View File
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`); $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
} }
@@ -63,18 +63,27 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `${base_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) => {
if (data.success) { $.ajax({
location.reload(); method: 'PUT',
} else { url: url,
error = data.error; dataType: 'json'
alert('danger', error); })
} .done(data => {
}); if (data.success) {
location.reload();
} else {
error = data.error;
alert('danger', error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}; };
const renameSubmit = (name, eid) => { const renameSubmit = (name, eid) => {
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
} }
const query = $.param({ const query = $.param({
entry: eid eid: eid
}); });
let url = `${base_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: 'POST', type: 'PUT',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
@@ -131,6 +140,7 @@ 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);
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
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['entry'] = eid; queryObj['eid'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`; const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url); console.log(url);
@@ -182,3 +192,63 @@ const setupUpload = (eid) => {
} }
}); });
}; };
const deselectAll = () => {
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
data['selected'] = false;
});
$('#select-bar')[0].__x.$data['count'] = 0;
};
const selectAll = () => {
let count = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled']) {
data['selected'] = true;
count++;
}
});
$('#select-bar')[0].__x.$data['count'] = count;
};
const selectedIDs = () => {
const ary = [];
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item');
ary.push($(item).attr('id'));
}
});
return ary;
};
const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({
type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
ids: ids
})
})
.done(data => {
if (data.error) {
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
deselectAll();
});
};
+16 -11
View File
@@ -1,11 +1,16 @@
function remove(username) { const remove = (username) => {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) { $.ajax({
if (data.success) { url: `${base_url}api/admin/user/delete/${username}`,
location.reload(); type: 'DELETE',
} dataType: 'json'
else { })
error = data.error; .done(data => {
alert('danger', error); if (data.success)
} location.reload();
}); else
} alert('danger', data.error);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
+32 -16
View File
@@ -1,54 +1,70 @@
version: 1.0 version: 2.0
shards: shards:
ameba: ameba:
github: crystal-ameba/ameba git: https://github.com/crystal-ameba/ameba.git
version: 0.12.1 version: 0.12.1
archive: archive:
github: hkalexling/archive.cr git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0 version: 0.4.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system git: https://github.com/schovi/baked_file_system.git
version: 0.9.8 version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim: clim:
github: at-grandpa/clim git: https://github.com/at-grandpa/clim.git
version: 0.12.0 version: 0.12.0
db: db:
github: crystal-lang/crystal-db git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0 version: 0.9.0
duktape: duktape:
github: jessedoyle/duktape.cr git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0 version: 0.20.0
exception_page: exception_page:
github: crystal-loot/exception_page git: https://github.com/crystal-loot/exception_page.git
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:
github: kemalcr/kemal git: https://github.com/kemalcr/kemal.git
version: 0.26.1 version: 0.27.0
kemal-session: kemal-session:
github: kemalcr/kemal-session git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1 version: 0.12.1
kilt: kilt:
github: jeromegn/kilt git: https://github.com/jeromegn/kilt.git
version: 0.4.0 version: 0.4.0
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
myhtml: myhtml:
github: kostya/myhtml git: https://github.com/kostya/myhtml.git
version: 1.5.1 version: 1.5.1
open_api:
git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
radix: radix:
github: luislavena/radix git: https://github.com/luislavena/radix.git
version: 0.3.9 version: 0.3.9
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.16.0
+9 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.9.0 version: 0.17.1
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -8,7 +8,7 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.34.0 crystal: 0.35.1
license: MIT license: MIT
@@ -21,6 +21,7 @@ dependencies:
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: archive:
github: hkalexling/archive.cr github: hkalexling/archive.cr
ameba: ameba:
@@ -32,3 +33,9 @@ dependencies:
version: ~> 0.20.0 version: ~> 0.20.0
myhtml: myhtml:
github: kostya/myhtml github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
+1
View File
@@ -2,6 +2,7 @@ require "spec"
require "../src/queue" require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
+11 -11
View File
@@ -1,13 +1,13 @@
require "zip" require "compress/zip"
require "archive" require "archive"
# A unified class to handle all supported archive formats. It uses the ::Zip # A unified class to handle all supported archive formats. It uses the
# module in crystal standard library if the target file is a zip archive. # Compress::Zip module in crystal standard library if the target file is
# Otherwise it uses `archive.cr`. # a zip archive. Otherwise it uses `archive.cr`.
class ArchiveFile class ArchiveFile
def initialize(@filename : String) def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Zip::File.new filename @archive_file = Compress::Zip::File.new filename
else else
@archive_file = Archive::File.new filename @archive_file = Archive::File.new filename
end end
@@ -20,16 +20,16 @@ class ArchiveFile
end end
def close def close
if @archive_file.is_a? Zip::File if @archive_file.is_a? Compress::Zip::File
@archive_file.as(Zip::File).close @archive_file.as(Compress::Zip::File).close
end end
end end
# Lists all file entries # Lists all file entries
def entries def entries
ary = [] of Zip::File::Entry | Archive::Entry ary = [] of Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e| @archive_file.entries.map do |e|
if (e.is_a? Zip::File::Entry && e.file?) || if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?) (e.is_a? Archive::Entry && e.info.file?)
ary.push e ary.push e
end end
@@ -37,8 +37,8 @@ class ArchiveFile
ary ary
end end
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes? def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Zip::File::Entry if e.is_a? Compress::Zip::File::Entry
data = nil data = nil
e.open do |io| e.open do |io|
slice = Bytes.new e.uncompressed_size slice = Bytes.new e.uncompressed_size
+6 -8
View File
@@ -11,13 +11,15 @@ class Config
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
@[YAML::Field(key: "scan_interval_minutes")] property scan_interval_minutes : Int32 = 5
property scan_interval : Int32 = 5 property thumbnail_generation_interval_hours : Int32 = 24
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 plugin_path : String = File.expand_path "~/mango/plugins",
home: true home: true
property download_timeout_seconds : Int32 = 30
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -52,12 +54,8 @@ class Config
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. " \
" Do you want mango to dump the default config there? [Y/n]" "Dumping the default config there."
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate default = self.allocate
default.path = path default.path = path
default.fill_defaults default.fill_defaults
+1 -1
View File
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
slice = Bytes.new file.size slice = Bytes.new file.size
file.read slice file.read slice
return send_file env, slice, file.mime_type return send_file env, slice, MIME.from_filename file.path
end end
call_next env call_next env
end end
+64 -8
View File
@@ -1,3 +1,5 @@
require "image_size"
class Entry class Entry
property zip_path : String, book : Title, title : String, property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String, size : String, pages : Int32, id : String, encoded_path : String,
@@ -45,8 +47,7 @@ class Entry
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["zip_path", "title", "size", "id", {% for str in ["zip_path", "title", "size", "id"] %}
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "title_id", @book.id json.field "title_id", @book.id
@@ -67,7 +68,7 @@ class Entry
def cover_url def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg return "#{Config.current.base_url}img/icon.png" if @err_msg
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1" url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
TitleInfo.new @book.dir do |info| TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]? info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
@@ -77,11 +78,9 @@ class Entry
url url
end end
def read_page(page_num) private def sorted_archive_entries
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
ArchiveFile.open @zip_path do |file| ArchiveFile.open @zip_path do |file|
page = file.entries entries = file.entries
.select { |e| .select { |e|
SUPPORTED_IMG_TYPES.includes? \ SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
@@ -89,7 +88,15 @@ class Entry
.sort { |a, b| .sort { |a, b|
compare_numerically a.filename, b.filename compare_numerically a.filename, b.filename
} }
.[page_num - 1] yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page data = file.read_entry page
if data if data
img = Image.new data, MIME.from_filename(page.filename), page.filename, img = Image.new data, MIME.from_filename(page.filename), page.filename,
@@ -99,6 +106,26 @@ class Entry
img img
end 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) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
@@ -179,4 +206,33 @@ class Entry
def started?(username) def started?(username)
load_progress(username) > 0 load_progress(username) > 0
end 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 end
+129 -41
View File
@@ -1,5 +1,5 @@
class Library class Library
property dir : String, title_ids : Array(String), scan_interval : Int32, property dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
use_default use_default
@@ -8,20 +8,48 @@ class Library
register_mime_types register_mime_types
@dir = Config.current.library_path @dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@title_hash = {} of String => Title @title_hash = {} of String => Title
return scan if @scan_interval < 1 @entries_count = 0
spawn do @thumbnails_count = 0
loop do
start = Time.local scan_interval = Config.current.scan_interval_minutes
scan if scan_interval < 1
ms = (Time.local - start).total_milliseconds scan
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms" else
sleep @scan_interval * 60 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 end
end end
@@ -30,6 +58,41 @@ class Library
@title_ids.map { |tid| self.get_title!(tid) } @title_ids.map { |tid| self.get_title!(tid) }
end end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
def deep_titles def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten titles + titles.map { |t| t.deep_titles }.flatten
end end
@@ -57,7 +120,6 @@ class Library
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
@title_ids.clear
storage = Storage.new auto_close: false storage = Storage.new auto_close: false
@@ -68,6 +130,7 @@ class Library
.map { |path| Title.new path, "", storage, self } .map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
@@ -83,7 +146,7 @@ class Library
cr_entries = deep_titles cr_entries = deep_titles
.map { |t| t.get_last_read_entry username } .map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s # Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0..11] .select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
.map { |e| .map { |e|
# Get the last read time of the entry. If it hasn't been started, get # Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry # the last read time of the previous entry
@@ -143,41 +206,66 @@ class Library
end end
end end
recently_added[0..11] recently_added[0...ENTRIES_IN_HOME_SECTIONS]
end end
def sorted_titles(username, opt : SortOptions? = nil) def get_start_reading_titles(username)
if opt.nil? # Here we are not using `deep_titles` as it may cause unexpected behaviors
opt = SortOptions.from_info_json @dir, username # For example, consider the following nested titles:
else # - One Puch Man
TitleInfo.new @dir do |info| # - Vol. 1
info.sort_by[username] = opt.to_tuple # - Vol. 2
info.save # 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
end end
# This is a hack to bypass a compiler bug entries.each do |e|
ary = titles unless e.get_thumbnail
e.generate_thumbnail
case opt.not_nil!.method # Sleep after each generation to minimize the impact on disk IO
when .time_modified? # and CPU
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \ sleep 0.5.seconds
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 end
else @thumbnails_count += 1
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 end
Logger.info "Thumbnail generation finished"
ary.reverse! unless opt.not_nil!.ascend
ary
end end
end end
+32 -7
View File
@@ -3,7 +3,8 @@ require "../archive"
class Title class Title
property dir : String, parent_id : String, title_ids : Array(String), property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time encoded_title : String, mtime : Time,
entry_display_name_cache : Hash(String, String)?
def initialize(@dir : String, @parent_id, storage, def initialize(@dir : String, @parent_id, storage,
@library : Library) @library : Library)
@@ -56,7 +57,7 @@ class Title
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %} {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "display_name", display_name json.field "display_name", display_name
@@ -129,13 +130,17 @@ class Title
end end
def display_name(entry_name) def display_name(entry_name)
dn = entry_name unless @entry_display_name_cache
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]? @entry_display_name_cache = info.entry_display_name
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end end
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 dn
end end
@@ -355,4 +360,24 @@ class Title
return zip if title_ids.empty? return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
end 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 end
+10
View File
@@ -57,6 +57,16 @@ struct Image
def initialize(@data, @mime, @filename, @size) def initialize(@data, @mime, @filename, @size)
end 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 end
class TitleInfo class TitleInfo
+6 -4
View File
@@ -26,9 +26,9 @@ class Logger
{% end %} {% end %}
@log = Log.for("") @log = Log.for("")
@backend = Log::IOBackend.new @backend = Log::IOBackend.new
@backend.formatter = ->(entry : Log::Entry, io : IO) do
format_proc = ->(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,12 +45,14 @@ class Logger
io << entry.message io << entry.message
end end
Log.builder.bind "*", @@severity, @backend @backend.formatter = Log::Formatter.new &format_proc
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, nil @backend.write Log::Entry.new "", Log::Severity::None, msg,
Log::Metadata.empty, nil
end end
def self.log(msg) def self.log(msg)
+34
View File
@@ -0,0 +1,34 @@
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
# This is a temporary workaround that forces the relevant code to run in the
# main fiber
class MainFiber
@@channel = Channel(-> Nil).new
@@done = Channel(Bool).new
@@main_fiber = Fiber.current
def self.start_and_block
loop do
if proc = @@channel.receive
begin
proc.call
ensure
@@done.send true
end
end
Fiber.yield
end
end
def self.run(&block : -> Nil)
if @@main_fiber == Fiber.current
block.call
else
@@channel.send block
until @@done.receive
Fiber.yield
end
end
end
end
-1
View File
@@ -1,4 +1,3 @@
require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename" require "../rename"
+23 -10
View File
@@ -1,12 +1,12 @@
require "./api" require "./api"
require "zip" require "compress/zip"
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 : Zip::Writer property writer : Compress::Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning) def initialize(@url, @filename, @writer, @tries_remaning)
@@ -27,14 +27,16 @@ module MangaDex
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
DB.open "sqlite3://#{@queue.path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@queue.path}" do |db|
db.query_one "select * from queue where id not like '%-%' " \ begin
"and (status = 0 or status = 1) " \ db.query_one "select * from queue where id not like '%-%' " \
"order by time limit 1" do |res| "and (status = 0 or status = 1) " \
job = Queue::Job.from_query_result res "order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end end
rescue
end end
end end
job job
@@ -67,7 +69,7 @@ module MangaDex
# 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 = Zip::Writer.new zip_path writer = Compress::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
@@ -89,6 +91,7 @@ module MangaDex
end end
channel.send page_job channel.send page_job
break unless @queue.exists? job
end end
end end
@@ -96,6 +99,9 @@ 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
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \ Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}" "#{page_job.url}"
page_jobs << page_job page_jobs << page_job
@@ -108,6 +114,13 @@ module MangaDex
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"
+31 -6
View File
@@ -1,12 +1,29 @@
require "./config" require "./config"
require "./queue" require "./queue"
require "./server" require "./server"
require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "./plugin/*"
MANGO_VERSION = "0.9.0" MANGO_VERSION = "0.17.1"
# From http://www.network-science.de/ascii/
BANNER = %{
_| _|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
}
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
macro common_option macro common_option
option "-c PATH", "--config=PATH", type: String, option "-c PATH", "--config=PATH", type: String,
@@ -22,20 +39,28 @@ end
class CLI < Clim class CLI < Clim
main do main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" desc DESCRIPTION
usage "mango [sub_command] [options]" usage "mango [sub_command] [options]"
help short: "-h" help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v" version "Version #{MANGO_VERSION}", short: "-v"
common_option common_option
run do |opts| 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 Config.load(opts.config).set_current
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
# empty ARGV so it won't be passed to Kemal spawn do
ARGV.clear Server.new.start
server = Server.new end
server.start
MainFiber.start_and_block
end end
sub "admin" do sub "admin" do
+18 -8
View File
@@ -8,14 +8,16 @@ class Plugin
def pop : Queue::Job? def pop : Queue::Job?
job = nil job = nil
DB.open "sqlite3://#{@queue.path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@queue.path}" do |db|
db.query_one "select * from queue where id like '%-%' " \ begin
"and (status = 0 or status = 1) " \ db.query_one "select * from queue where id like '%-%' " \
"order by time limit 1" do |res| "and (status = 0 or status = 1) " \
job = Queue::Job.from_query_result res "order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end end
rescue
end end
end end
job job
@@ -51,7 +53,7 @@ class Plugin
end end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
writer = Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
rescue e rescue e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@@ -64,6 +66,8 @@ class Plugin
fail_count = 0 fail_count = 0
while page = plugin.next_page while page = plugin.next_page
break unless @queue.exists? job
fn = process_filename page["filename"].as_s fn = process_filename page["filename"].as_s
url = page["url"].as_s url = page["url"].as_s
headers = HTTP::Headers.new headers = HTTP::Headers.new
@@ -107,6 +111,12 @@ class Plugin
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
return
end
Logger.debug "Download completed. #{fail_count}/#{pages} failed" Logger.debug "Download completed. #{fail_count}/#{pages} failed"
writer.close writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path, filename = File.join File.dirname(zip_path), File.basename(zip_path,
-1
View File
@@ -1,6 +1,5 @@
require "duktape/runtime" require "duktape/runtime"
require "myhtml" require "myhtml"
require "http"
require "xml" require "xml"
class Plugin class Plugin
+102 -59
View File
@@ -119,22 +119,24 @@ class Queue
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@path}" do |db|
db.exec "create table if not exists queue " \ begin
"(id text, manga_id text, title text, manga_title " \ db.exec "create table if not exists queue " \
"text, status integer, status_message text, " \ "(id text, manga_id text, title text, manga_title " \
"pages integer, success_count integer, " \ "text, status integer, status_message text, " \
"fail_count integer, time integer)" "pages integer, success_count integer, " \
db.exec "create unique index if not exists id_idx " \ "fail_count integer, time integer)"
"on queue (id)" db.exec "create unique index if not exists id_idx " \
db.exec "create index if not exists manga_id_idx " \ "on queue (id)"
"on queue (manga_id)" db.exec "create index if not exists manga_id_idx " \
db.exec "create index if not exists status_idx " \ "on queue (manga_id)"
"on queue (status)" db.exec "create index if not exists status_idx " \
rescue e "on queue (status)"
Logger.error "Error when checking tables in DB: #{e}" rescue e
raise e Logger.error "Error when checking tables in DB: #{e}"
raise e
end
end end
end end
end end
@@ -143,23 +145,27 @@ class Queue
# inserted. Any job already exists in the queue will be ignored. # inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job)) def push(jobs : Array(Job))
start_count = self.count start_count = self.count
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
jobs.each do |job| DB.open "sqlite3://#{@path}" do |db|
db.exec "insert or ignore into queue values " \ 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.id, job.manga_id, job.title, job.manga_title,
job.success_count, job.fail_count, job.time.to_unix_ms job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
end
end end
end end
self.count - start_count self.count - start_count
end end
def reset(id : String) def reset(id : String)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = 0, status_message = '', " \ DB.open "sqlite3://#{@path}" do |db|
"pages = 0, success_count = 0, fail_count = 0 " \ db.exec "update queue set status = 0, status_message = '', " \
"where id = (?)", id "pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
end
end end
end end
@@ -169,16 +175,20 @@ class Queue
# Reset all failed tasks (missing pages and error) # Reset all failed tasks (missing pages and error)
def reset def reset
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = 0, status_message = '', " \ DB.open "sqlite3://#{@path}" do |db|
"pages = 0, success_count = 0, fail_count = 0 " \ db.exec "update queue set status = 0, status_message = '', " \
"where status = 2 or status = 4" "pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end end
end end
def delete(id : String) def delete(id : String)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "delete from queue where id = (?)", id DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end end
end end
@@ -186,72 +196,105 @@ class Queue
self.delete job.id self.delete job.id
end 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) def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "delete from queue where status = (?)", status.to_i DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
end
end end
end end
def count_status(status : JobStatus) def count_status(status : JobStatus)
num = 0 num = 0
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
num = db.query_one "select count(*) from queue where " \ DB.open "sqlite3://#{@path}" do |db|
"status = (?)", status.to_i, as: Int32 num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
end
end end
num num
end end
def count def count
num = 0 num = 0
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
num = db.query_one "select count(*) from queue", as: Int32 DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
end
end end
num num
end end
def set_status(status : JobStatus, job : Job) def set_status(status : JobStatus, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status = (?) where id = (?)", DB.open "sqlite3://#{@path}" do |db|
status.to_i, job.id db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
end
end end
end end
def get_all def get_all
jobs = [] of Job jobs = [] of Job
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
jobs = db.query_all "select * from queue order by time" do |rs| DB.open "sqlite3://#{@path}" do |db|
Job.from_query_result rs jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs
end
end end
end end
jobs jobs
end end
def add_success(job : Job) def add_success(job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set success_count = success_count + 1 " \ DB.open "sqlite3://#{@path}" do |db|
"where id = (?)", job.id db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
end
end end
end end
def add_fail(job : Job) def add_fail(job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set fail_count = fail_count + 1 " \ DB.open "sqlite3://#{@path}" do |db|
"where id = (?)", job.id db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
end
end end
end end
def set_pages(pages : Int32, job : Job) def set_pages(pages : Int32, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set pages = (?), success_count = 0, " \ DB.open "sqlite3://#{@path}" do |db|
"fail_count = 0 where id = (?)", pages, job.id db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id
end
end end
end end
def add_message(msg : String, job : Job) def add_message(msg : String, job : Job)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
db.exec "update queue set status_message = " \ DB.open "sqlite3://#{@path}" do |db|
"status_message || (?) || (?) where id = (?)", db.exec "update queue set status_message = " \
"\n", msg, job.id "status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
end end
end end
+400 -15
View File
@@ -1,9 +1,171 @@
require "./router" require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa"
class APIRouter < Router class APIRouter < Router
@@api_json : String?
API_VERSION = "0.1.0"
macro s(fields)
{
{% for field in fields %}
{{field}} => "string",
{% end %}
}
end
def initialize def initialize
Koa.init "Mango API", version: API_VERSION, desc: <<-MD
# A Word of Caution
This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
# Authentication
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
# Terminologies
- Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
- Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
- Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
MD
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
Koa.global_tag "admin", desc: <<-MD
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
MD
Koa.binary "binary", desc: "A binary file"
Koa.array "entryAry", "$entry", desc: "An array of entries"
Koa.array "titleAry", "$title", desc: "An array of titles"
Koa.array "strAry", "string", desc: "An array of strings"
entry_schema = {
"pages" => "integer",
"mtime" => "integer",
}.merge s %w(zip_path title size id title_id display_name cover_url)
Koa.object "entry", entry_schema, desc: "An entry in a book"
title_schema = {
"mtime" => "integer",
"entries" => "$entryAry",
"titles" => "$titleAry",
"parents" => "$strAry",
}.merge s %w(dir title id display_name cover_url)
Koa.object "title", title_schema,
desc: "A manga title (a collection of entries and sub-titles)"
Koa.object "library", {
"dir" => "string",
"titles" => "$titleAry",
}, desc: "A library containing a list of top-level titles"
Koa.object "scanResult", {
"milliseconds" => "integer",
"titles" => "integer",
}
Koa.object "progressResult", {
"progress" => "number",
}
Koa.object "result", {
"success" => "boolean",
"error" => "string?",
}
mc_schema = {
"groups" => "object",
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
Koa.array "chapterAry", "$mangadexChapter"
mm_schema = {
"chapers" => "$chapterAry",
}.merge s %w(id title description author artist cover_url)
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
Koa.object "chaptersObj", {
"chapters" => "$chapterAry",
}
Koa.object "successFailCount", {
"success" => "integer",
"fail" => "integer",
}
job_schema = {
"pages" => "integer",
"success_count" => "integer",
"fail_count" => "integer",
"time" => "integer",
}.merge s %w(id manga_id title manga_title status_message status)
Koa.object "job", job_schema, desc: "A download job in the queue"
Koa.array "jobAry", "$job"
Koa.object "jobs", {
"success" => "boolean",
"paused" => "boolean",
"jobs" => "$jobAry",
}
Koa.object "binaryUpload", {
"file" => "$binary",
}
Koa.object "pluginListBody", {
"plugin" => "string",
"query" => "string",
}
Koa.object "pluginChapter", {
"id" => "string",
"title" => "string",
}
Koa.array "pluginChapterAry", "$pluginChapter"
Koa.object "pluginList", {
"success" => "boolean",
"chapters" => "$pluginChapterAry?",
"title" => "string?",
"error" => "string?",
}
Koa.object "pluginDownload", {
"plugin" => "string",
"title" => "string",
"chapters" => "$pluginChapterAry",
}
Koa.object "dimension", {
"width" => "integer",
"height" => "integer",
}
Koa.array "dimensionAry", "$dimension"
Koa.object "dimensionResult", {
"success" => "boolean",
"dimensions" => "$dimensionAry?",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
Koa.response 200, ref: "$binary", media_type: "image/*"
Koa.response 500, "Page not found or not readable"
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -26,6 +188,37 @@ class APIRouter < Router
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 = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
img = entry.get_thumbnail || entry.read_page 1
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
Koa.describe "Returns the book with title `tid`"
Koa.path "tid", desc: "Title ID"
Koa.response 200, ref: "$title"
Koa.response 404, "Title not found"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
@@ -35,15 +228,20 @@ class APIRouter < Router
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 500 env.response.status_code = 404
e.message e.message
end end
end end
get "/api/book" do |env| Koa.describe "Returns the entire library with all titles and entries"
Koa.response 200, ref: "$library"
get "/api/library" do |env|
send_json env, @context.library.to_json send_json env, @context.library.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
@context.library.scan @context.library.scan
@@ -54,7 +252,27 @@ class APIRouter < Router
}.to_json }.to_json
end end
post "/api/admin/user/delete/:username" do |env| Koa.describe "Returns the thumbnail generation progress between 0 and 1"
Koa.tag "admin"
Koa.response 200, ref: "$progressResult"
get "/api/admin/thumbnail_progress" do |env|
send_json env, {
"progress" => Library.default.thumbnail_generation_progress,
}.to_json
end
Koa.describe "Triggers a thumbnail generation"
Koa.tag "admin"
post "/api/admin/generate_thumbnails" do |env|
spawn do
Library.default.generate_thumbnails
end
end
Koa.describe "Deletes a user with `username`"
Koa.tag "admin"
Koa.response 200, ref: "$result"
delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username @context.storage.delete_user username
@@ -69,13 +287,24 @@ class APIRouter < Router
end end
end end
post "/api/progress/:title/:page" do |env| Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
When `eid` is provided, sets the reading progress of the entry to `page`.
When `eid` is omitted, updates the progress of the entire title. Specifically:
- if `page` is 0, marks the entire title as unread
- otherwise, marks the entire title as read
MD
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "page", desc: "The new page number indicating the progress"
Koa.response 200, ref: "$result"
put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["tid"]).not_nil!
.not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
if !entry_id.nil? if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil! entry = title.get_entry(entry_id).not_nil!
@@ -97,12 +326,47 @@ class APIRouter < Router
end end
end end
post "/api/admin/display_name/:title/:name" do |env| Koa.describe "Updates the reading progress of multiple entries in a title"
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
Koa.path "tid", desc: "Title ID"
Koa.body ref: "$ids", desc: "An array of entry IDs"
Koa.response 200, ref: "$result"
put "/api/bulk_progress/:action/:tid" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) username = get_username env
title = (@context.library.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s
unless action.in? ["read", "unread"]
raise "Unknow action #{action}"
end
title.bulk_progress action, ids, username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
Koa.describe "Sets the display name of a title or an entry", <<-MD
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
MD
Koa.tag "admin"
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "name", desc: "The new display name"
Koa.response 200, ref: "$result"
put "/api/admin/display_name/:tid/:name" do |env|
begin
title = (@context.library.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["entry"]? entry = env.params.query["eid"]?
if entry.nil? if entry.nil?
title.set_display_name name title.set_display_name name
else else
@@ -120,6 +384,12 @@ class APIRouter < Router
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"]
@@ -132,6 +402,12 @@ class APIRouter < Router
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 }
@@ -156,6 +432,23 @@ class APIRouter < Router
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" => @context.queue.get_all,
"paused" => @context.queue.paused?,
}.to_json)
sleep interval.seconds
end
end
Koa.describe "Returns the current download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tag "admin"
Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = @context.queue.get_all jobs = @context.queue.get_all
@@ -172,6 +465,19 @@ class APIRouter < Router
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"]
@@ -206,6 +512,22 @@ class APIRouter < Router
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"]
@@ -220,8 +542,8 @@ class APIRouter < Router
case target case target
when "cover" when "cover"
title_id = env.params.query["title"] title_id = env.params.query["tid"]
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil! title = @context.library.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \ unless SUPPORTED_IMG_TYPES.includes? \
@@ -260,10 +582,14 @@ class APIRouter < Router
end end
end end
post "/api/admin/plugin/list" do |env| Koa.describe "Lists the chapters in a title from a plugin"
Koa.tag "admin"
Koa.body ref: "$pluginListBody"
Koa.response 200, ref: "$pluginList"
get "/api/admin/plugin/list" do |env|
begin begin
query = env.params.json["query"].as String query = env.params.query["query"].as String
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query json = plugin.list_chapters query
chapters = json["chapters"] chapters = json["chapters"]
@@ -282,6 +608,10 @@ class APIRouter < Router
end end
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| post "/api/admin/plugin/download" do |env|
begin begin
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.json["plugin"].as String
@@ -310,5 +640,60 @@ class APIRouter < Router
}.to_json }.to_json
end end
end end
Koa.describe "Returns the image dimensions of all pages in an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$dimensionResult"
get "/api/dimensions/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
sizes = entry.page_dimensions
send_json env, {
"success" => true,
"dimensions" => sizes,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Downloads an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$binary"
Koa.response 404, "Entry not found"
get "/api/download/:tid/:eid" do |env|
begin
title = (@context.library.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
doc = Koa.generate
@@api_json = doc.to_json if doc
get "/openapi.json" do |env|
if @@api_json
send_json env, @@api_json
else
env.response.status_code = 404
end
end
end end
end end
+5
View File
@@ -103,6 +103,7 @@ class MainRouter < Router
continue_reading = @context continue_reading = @context
.library.get_continue_reading_entries username .library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username recently_added = @context.library.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username
titles = @context.library.titles titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0 empty_library = titles.size == 0
@@ -112,5 +113,9 @@ class MainRouter < Router
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/api" do |env|
render "src/views/api.html.ecr"
end
end end
end end
-12
View File
@@ -16,17 +16,5 @@ class OPDSRouter < Router
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/opds/download/:title/:entry" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
end end
end end
+5 -21
View File
@@ -12,11 +12,7 @@ class ReaderRouter < Router
next layout "reader-error" if entry.err_msg next layout "reader-error" if entry.err_msg
# load progress # load progress
page = entry.load_progress username page = [1, entry.load_progress username].max
# 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 # start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username page = 1 if entry.finished? username
@@ -32,29 +28,17 @@ class ReaderRouter < Router
begin begin
base_url = Config.current.base_url base_url = Config.current.base_url
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.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 = env.params.url["page"].to_i page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
entry.save_progress username, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx|
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil? unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end end
+176 -94
View File
@@ -32,38 +32,42 @@ class Storage
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
begin DB.open "sqlite3://#{@path}" do |db|
# We create the `ids` table first. even if the uses has an begin
# early version installed and has the `user` table only, db.exec "create table thumbnails " \
# we will still be able to create `ids` "(id text, data blob, filename text, " \
db.exec "create table ids" \ "mime text, size integer)"
"(path text, id text, is_title integer)" db.exec "create unique index tn_index on thumbnails (id)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \ db.exec "create table ids" \
"(username text, password text, token text, admin integer)" "(path text, id text, is_title integer)"
rescue e db.exec "create unique index path_idx on ids (path)"
unless e.message.not_nil!.ends_with? "already exists" db.exec "create unique index id_idx on ids (id)"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e 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
# 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
end unless @auto_close
unless @auto_close @db = DB.open "sqlite3://#{@path}"
@db = DB.open "sqlite3://#{@path}" end
end end
end end
@@ -87,37 +91,45 @@ class Storage
end end
def verify_user(username, password) def verify_user(username, password)
get_db do |db| out_token = nil
begin MainFiber.run do
hash, token = db.query_one "select password, token from " \ get_db do |db|
"users where username = (?)", begin
username, as: {String, String?} hash, token = db.query_one "select password, token from " \
unless verify_password hash, password "users where username = (?)",
Logger.debug "Password does not match the hash" username, as: {String, String?}
return nil unless verify_password hash, password
Logger.debug "Password does not match the hash"
next
end
Logger.debug "User #{username} verified"
if token
out_token = token
next
end
token = random_str
Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)",
token, username
out_token = token
rescue e
Logger.error "Error when verifying user #{username}: #{e}"
end 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
get_db do |db| MainFiber.run do
begin get_db do |db|
username = db.query_one "select username from users where " \ begin
"token = (?)", token, as: String username = db.query_one "select username from users where " \
rescue e "token = (?)", token, as: String
Logger.debug "Unable to verify token" rescue e
Logger.debug "Unable to verify token"
end
end end
end end
username username
@@ -125,12 +137,14 @@ class Storage
def verify_admin(token) def verify_admin(token)
is_admin = false is_admin = false
get_db do |db| MainFiber.run do
begin get_db do |db|
is_admin = db.query_one "select admin from users where " \ begin
"token = (?)", token, as: Bool is_admin = db.query_one "select admin from users where " \
rescue e "token = (?)", token, as: Bool
Logger.debug "Unable to verify user as admin" rescue e
Logger.debug "Unable to verify user as admin"
end
end end
end end
is_admin is_admin
@@ -138,10 +152,12 @@ class Storage
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
get_db do |db| MainFiber.run do
db.query "select username, admin from users" do |rs| get_db do |db|
rs.each do db.query "select username, admin from users" do |rs|
results << {rs.read(String), rs.read(Bool)} rs.each do
results << {rs.read(String), rs.read(Bool)}
end
end end
end end
end end
@@ -152,10 +168,12 @@ class Storage
validate_username username validate_username username
validate_password password validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
get_db do |db| MainFiber.run do
hash = hash_password password get_db do |db|
db.exec "insert into users values (?, ?, ?, ?)", hash = hash_password password
username, hash, nil, admin db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
end
end end
end end
@@ -163,40 +181,48 @@ class Storage
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username validate_username username
validate_password password unless password.empty? validate_password password unless password.empty?
get_db do |db| MainFiber.run do
if password.empty? get_db do |db|
db.exec "update users set username = (?), admin = (?) " \ if password.empty?
"where username = (?)", db.exec "update users set username = (?), admin = (?) " \
username, admin, original_username "where username = (?)",
else username, admin, original_username
hash = hash_password password else
db.exec "update users set username = (?), admin = (?)," \ hash = hash_password password
"password = (?) where username = (?)", db.exec "update users set username = (?), admin = (?)," \
username, admin, hash, original_username "password = (?) where username = (?)",
username, admin, hash, original_username
end
end end
end end
end end
def delete_user(username) def delete_user(username)
get_db do |db| MainFiber.run do
db.exec "delete from users where username = (?)", username get_db do |db|
db.exec "delete from users where username = (?)", username
end
end end
end end
def logout(token) def logout(token)
get_db do |db| MainFiber.run do
begin get_db do |db|
db.exec "update users set token = (?) where token = (?)", nil, token begin
rescue db.exec "update users set token = (?) where token = (?)", nil, token
rescue
end
end end
end end
end end
def get_id(path, is_title) def get_id(path, is_title)
id = nil id = nil
get_db do |db| MainFiber.run do
id = db.query_one? "select id from ids where path = (?)", path, get_db do |db|
as: {String} id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
end
end end
id id
end end
@@ -206,20 +232,76 @@ class Storage
end end
def bulk_insert_ids def bulk_insert_ids
get_db do |db| MainFiber.run do
db.transaction do |tx| get_db do |db|
@insert_ids.each do |tp| db.transaction do |tx|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path], @insert_ids.each do |tp|
tp[:id], tp[:is_title] ? 1 : 0 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 end
end end
@insert_ids.clear img
end
def optimize
MainFiber.run do
Logger.info "Starting DB optimization"
get_db do |db|
trash_ids = [] of String
db.query "select path, id from ids" do |rs|
rs.each do
path = rs.read String
trash_ids << rs.read String unless File.exists? path
end
end
# Delete dangling IDs
db.exec "delete from ids where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
if trash_ids.size > 0
# Delete dangling thumbnails
trash_thumbnails_count = db.query_one "select count(*) from " \
"thumbnails where id not in " \
"(select id from ids)", as: Int32
if trash_thumbnails_count > 0
db.exec "delete from thumbnails where id not in (select id from ids)"
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
end
end
Logger.info "DB optimization finished"
end
end end
def close def close
unless @db.nil? MainFiber.run do
@db.not_nil!.close unless @db.nil?
@db.not_nil!.close
end
end end
end end
+43
View File
@@ -0,0 +1,43 @@
require "http_proxy"
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
# environment variables
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Setting proxy"
previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri
yield client, path
end
end
end
end
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
return if no_proxy &&
no_proxy.split(",").any? &.== uri.hostname
case uri.scheme
when "http"
env_to_proxy "http_proxy"
when "https"
env_to_proxy "https_proxy"
else
nil
end
end
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
val = ENV[key.downcase]? || ENV[key.upcase]?
return if val.nil?
begin
uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
username: uri.user, password: uri.password
rescue
nil
end
end
+10 -3
View File
@@ -1,6 +1,7 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads" ENTRIES_IN_HOME_SECTIONS = 8
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"] UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
@@ -60,3 +61,9 @@ class String
self.chars.all? { |c| c.alphanumeric? || c == '_' } self.chars.all? { |c| c.alphanumeric? || c == '_' }
end end
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
+3 -3
View File
@@ -2,9 +2,9 @@ def validate_username(username)
if username.size < 3 if username.size < 3
raise "Username should contain at least 3 characters" raise "Username should contain at least 3 characters"
end end
if (username =~ /^[A-Za-z0-9_]+$/).nil? if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
raise "Username should contain alphanumeric characters " \ raise "Username can only contain alphanumeric characters, " \
"and underscores only" "underscores, and hyphens"
end end
end end
+17
View File
@@ -81,3 +81,20 @@ macro get_sort_opt
sort_opt = SortOptions.new sort_method, is_ascending sort_opt = SortOptions.new sort_method, is_ascending
end end
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
+14 -8
View File
@@ -1,11 +1,17 @@
<ul class="uk-list uk-list-large uk-list-divider"> <ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}">
<li data-url="<%= base_url %>admin/user">User Managerment</li> <li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li>
<li onclick="if(!scanning){scan()}"> <li :class="{'nopointer' : scanning}" @click="scan()">
<span id="scan">Scan Library Files</span> <span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<span id="scan-status" class="uk-align-right"> <div class="uk-align-right">
<div uk-spinner hidden></div> <div uk-spinner x-show="scanning"></div>
<span hidden></span> <span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</span> </div>
</li>
<li :class="{'nopointer' : generating}" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
</li> </li>
<li class="nopointer"> <li class="nopointer">
<span>Theme</span> <span>Theme</span>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango API Documentation</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<redoc spec-url="/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>
+12 -3
View File
@@ -35,12 +35,21 @@
onclick="location='<%= base_url %>book/<%= item.id %>'" onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>> <% end %>>
<div class="uk-card uk-card-default"> <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"
<div class="uk-card-media-top"> <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img 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 %> <% if item.is_a? Entry && item.err_msg %>
class="grayscale" class="grayscale"
<% end %>> <% 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>
<div class="uk-card-body"> <div class="uk-card-body">
@@ -0,0 +1,3 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
+5 -1
View File
@@ -7,8 +7,12 @@
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" /> <link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico"> <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/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script> <script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script> <script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script>
</head> </head>
+61 -25
View File
@@ -1,32 +1,68 @@
<div class="uk-margin"> <div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
<div id="actions" class="uk-margin"> <div class="uk-margin">
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></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>
<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>Plugin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div> </div>
<table class="uk-table uk-table-striped uk-overflow-auto">
<thead>
<tr>
<th>Chapter</th>
<th>Manga</th>
<th>Progress</th>
<th>Time</th>
<th>Status</th>
<th>Plugin</th>
<th>Actions</th>
</tr>
</thead>
</table>
<% content_for "script" do %> <% 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/moment.js/2.24.0/moment.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
+15 -5
View File
@@ -11,7 +11,7 @@
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd> <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> <dt style="font-weight: 500;">Can't see your files yet?</dt>
<dd> <dd>
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete You must wait <%= Config.current.scan_interval_minutes %> minutes for the library scan to complete
<% if is_admin %> <% if is_admin %>
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a> , or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
<% end %>. <% end %>.
@@ -41,7 +41,7 @@
<%- unless continue_reading.empty? -%> <%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2> <h2 class="uk-title home-headings">Continue Reading</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- continue_reading.each do |cr| -%> <%- continue_reading.each do |cr| -%>
<% item = cr[:entry] %> <% item = cr[:entry] %>
<% progress = cr[:percentage] %> <% progress = cr[:percentage] %>
@@ -50,9 +50,20 @@
</div> </div>
<%- end -%> <%- 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? -%> <%- unless recently_added.empty? -%>
<h2 class="uk-title home-headings">Recently Added</h2> <h2 class="uk-title home-headings">Recently Added</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- recently_added.each do |ra| -%> <%- recently_added.each do |ra| -%>
<% item = ra %> <% item = ra %>
<% progress = ra[:percentage] %> <% progress = ra[:percentage] %>
@@ -66,8 +77,7 @@
<%- end -%> <%- end -%>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<% end %> <% end %>
+4 -1
View File
@@ -67,10 +67,13 @@
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small">
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small" id="main-section">
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<div id="alert"></div> <div id="alert"></div>
<%= content %> <%= 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>
</div> </div>
<script> <script>
+2 -3
View File
@@ -16,7 +16,7 @@
<%= render_component "sort-form" %> <%= render_component "sort-form" %>
</div> </div>
</div> </div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %> <% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %> <% progress = percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
@@ -24,8 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="<%= base_url %>js/sort-items.js"></script>
<% end %> <% end %>
+12 -12
View File
@@ -21,18 +21,18 @@
<% end %> <% end %>
<% title.entries.each do |e| %> <% title.entries.each do |e| %>
<entry> <% next if e.err_msg %>
<title><%= HTML.escape(e.display_name) %></title> <entry>
<id>urn:mango:<%= e.id %></id> <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" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="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 %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
</entry> </entry>
<% end %> <% end %>
</feed>
</feed>
+85 -21
View File
@@ -3,24 +3,70 @@
<%= render_component "head" %> <%= render_component "head" %>
<body> <body style="position:relative;">
<div class="uk-section uk-section-default uk-section-small reader-bg"> <div class="uk-section uk-section-default uk-section-small reader-bg"
<div class="uk-container uk-container-small"> id="root"
<%- urls.each_with_index do |url, i| -%> :style="mode === 'continuous' ? '' : 'padding:0'"
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);"> x-data="{
<%- end -%> loading: true,
<%- if next_url -%> mode: 'continuous', // can be 'continuous', 'height' or 'width'
<a class="next-url" href="<%= next_url %>"></a> msg: 'Loading the web reader. Please wait...',
<%- end -%> alertClass: 'uk-alert-primary',
</div> items: [],
<%- if next_entry_url -%> curItem: {},
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button> flipAnimation: null
<%- else -%> }">
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div id="hidden" hidden></div> <div @keydown.window.debounce="keyHandler($event)"></div>
<div class="uk-container uk-container-small">
<div id="alert"></div>
<div x-show="loading">
<div :class="alertClass" x-show="msg" uk-alert>
<p x-text="msg"></p>
</div>
</div>
</div>
<div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items">
<img
uk-img
class="uk-align-center"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
:onclick="`showControl('${item.id}')`"
/>
</template>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
`" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
</div>
</div>
</div>
<div id="modal-sections" class="uk-flex-top" uk-modal> <div id="modal-sections" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
@@ -34,7 +80,7 @@
<p id="progress-label"></p> <p id="progress-label"></p>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to page</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="page-select" class="uk-select"> <select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
@@ -43,20 +89,38 @@
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls">
<select id="mode-select" class="uk-select">
<option value="continuous">Continuous</option>
<option value="paged">Paged</option>
</select>
</div>
</div>
</div> </div>
<div class="uk-modal-footer uk-text-right"> <div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button> <button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>" const base_url = "<%= base_url %>";
const page = <%= page %>;
const tid = "<%= title.id %>";
const eid = "<%= entry.id %>";
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script> <script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script> <script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script> <script src="<%= base_url %>js/reader.js"></script>
</body> </body>
<style>
img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; }
</style>
</html> </html>
+24 -3
View File
@@ -1,4 +1,23 @@
<div> <div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++; $dispatch('count', {count: count})" @remove.window="count--; $dispatch('count', {count: count})" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid>
<div>
<p x-text="count + ' items selected'" style="color:orange"></p>
</div>
<div class="uk-text-center" id="select-bar-controls">
<a class="uk-icon uk-margin-right" uk-tooltip="title: Mark selected as read" href="" @click.prevent="bulkProgress('read', $el)">
<i class="fas fa-check-circle"></i>
</a>
<a class="uk-icon" uk-tooltip="title: Mark selected as unread" href="" @click.prevent="bulkProgress('unread', $el)">
<i class="fas fa-times-circle"></i>
</a>
</div>
<div class="uk-text-right">
<a @click="selectAll()" uk-tooltip="title: Select all"><i class="fas fa-check-double uk-margin-small-right"></i></a>
<a @click="deselectAll();" uk-tooltip="title: Deselect all"><i class="fas fa-times"></i></a>
</div>
</div>
</div>
<h2 class=uk-title><span><%= title.display_name %></span> <h2 class=uk-title><span><%= title.display_name %></span>
&nbsp; &nbsp;
<% if is_admin %> <% if is_admin %>
@@ -32,11 +51,14 @@
<%= render_component "sort-form" %> <%= render_component "sort-form" %>
</div> </div>
</div> </div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% title.titles.each_with_index do |item, i| %> <% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %> <% progress = title_percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
<% end %> <% end %>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% entries.each_with_index do |item, i| %> <% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %> <% progress = percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
@@ -95,8 +117,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>