mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c0199039 | |||
| 7a7cb78f82 | |||
| 8931ba8c43 | |||
| d50981c151 | |||
| df4deb1415 | |||
| aa5e999ed4 | |||
| 84d4b0c529 | |||
| d3e5691478 | |||
| 1000b02ae0 | |||
| 1f795889a9 | |||
| d33b45233a | |||
| 4f6df5b9a3 | |||
| 341b586cb3 | |||
| 9dcc9665ce | |||
| 1cd90926df | |||
| ac1ff61e6d | |||
| 6ea41f79e9 | |||
| dad02a2a30 | |||
| 280490fb36 | |||
| 455315a362 | |||
| df51406638 | |||
| 531d42ef18 | |||
| 2645e8cd05 | |||
| b2dc44a919 | |||
| c8db397a3b | |||
| 6384d4b77a | |||
| 1039732d87 | |||
| 011123f690 | |||
| e602a35b0c | |||
| 7792d3426e | |||
| b59c8f85ad | |||
| 18834ac28e | |||
| bf68e32ac8 | |||
| 54eb041fe4 | |||
| 57d8c100f9 | |||
| 56d973b99d | |||
| 670e5cdf6a | |||
| 1b35392f9c | |||
| c4e1ffe023 | |||
| 44f4959477 | |||
| 0582b57d60 | |||
| 83d96fd2a1 | |||
| 8ac89c420c | |||
| 968c2f4ad5 | |||
| ad940f30d5 | |||
| 308ad4e063 | |||
| 4d709b7eb5 | |||
| 5760ad924e | |||
| fff171c8c9 | |||
| 44ff566a1d | |||
| 853f422964 | |||
| 3bb0917374 | |||
| a86f0d0f34 | |||
| 16a9d7fc2e | |||
| ee2b4abc85 | |||
| a6c2799521 | |||
| 2370e4d2c6 | |||
| 32b0384ea0 | |||
| 50d4ffdb7b | |||
| 96463641f9 | |||
| ddbba5d596 | |||
| 2a04f4531e | |||
| a5b6fb781f | |||
| 8dfdab9d73 | |||
| 3a95270dfb | |||
| 2960ca54df | |||
| f5fe3c6b1c | |||
| a612cc15fb | |||
| c9c0818069 | |||
| 2f8efc382f | |||
| a0fb1880bd | |||
| a408f14425 | |||
| 243b6c8927 | |||
| ff3a44d017 | |||
| 67ef1f7112 | |||
| 5d7b8a1ef9 | |||
| a68f3eea95 | |||
| 220fc42bf2 | |||
| a45e6ea3da | |||
| 88394d4636 | |||
| ef1ab940f5 | |||
| 97a1c408d8 | |||
| abbf77df13 | |||
| 3b4021f680 | |||
| 68b1923cb6 | |||
| 3cdd4b29a5 | |||
| af84c0f6de | |||
| 85a65f84d0 | |||
| 5027a911cd | |||
| ac63bf7599 | |||
| 30b0e0b8fb | |||
| ddda058d8d | |||
| 46db25e8e0 | |||
| c07f421322 | |||
| 99a77966ad | |||
| d00b917575 | |||
| 4fd8334c37 | |||
| 3aa4630558 | |||
| cde5af7066 | |||
| eb528e1726 | |||
| 5e01cc38fe | |||
| 9a787ccbc3 | |||
| 8a83c0df4e | |||
| 87dea01917 | |||
| 586ee4f0ba | |||
| 53f3387e1a | |||
| be5d1918aa | |||
| df2cc0ffa9 | |||
| b8cfc3a201 | |||
| 8dc60ac2ea | |||
| 1719335d02 | |||
| 0cd46abc66 | |||
| e4fd7c58ee | |||
| d4abee52db | |||
| d29c94e898 | |||
| 1c19a91ee2 | |||
| 7eb5c253e9 | |||
| 22a660aabf | |||
| 6e9466c9d2 | |||
| ab34fb260c | |||
| 0e9a659828 | |||
| 361d37d742 | |||
| c6adb4ee18 | |||
| 8349fb68a4 | |||
| 0e1e4de528 | |||
| b47788a85a | |||
| f7004549b8 | |||
| 8d99400c5f | |||
| ce59acae7a | |||
| 37c5911a23 | |||
| 8694b4beaf | |||
| 3b315ad880 | |||
| 33107670ce | |||
| f116e2f1d0 | |||
| ebf6221876 | |||
| 2a910335af | |||
| 9ea26474b4 | |||
| df8a6ee6da | |||
| 70ea1711ce | |||
| 2773c1e67f | |||
| dcfd1c8765 | |||
| 10b6047df8 | |||
| 8de735a2ca | |||
| 6c2350c9c7 | |||
| a994c43857 | |||
| 7e4532fb14 | |||
| d184d6fba5 | |||
| 92f5a90629 | |||
| 2a36804e8d | |||
| 87b6e79952 | |||
| b75a838e14 | |||
| ae7c72ab85 | |||
| 5cee68d76c | |||
| f444496915 | |||
| a812e3ed46 | |||
| 1be089b53e | |||
| a7f4e161de | |||
| ba31eb0071 | |||
| 192474c950 | |||
| 87b72fbd30 | |||
| 6acfa02314 | |||
| bdba7bdd13 | |||
| 1b244c68b8 | |||
| 281f626e8c | |||
| 5be4f51d7e | |||
| cd7782ba1e | |||
| 6d97bc083c | |||
| ff4b1be9ae | |||
| ba16c3db2f | |||
| 69b06a8352 | |||
| 687788767f | |||
| 94a1e63963 | |||
| 360913ee78 | |||
| ea366f263a | |||
| 0d11cb59e9 | |||
| 2208f90d8e | |||
| 07100121ef | |||
| a0e550569e | |||
| bbbe2e0588 | |||
| 9d31b24e8c | |||
| 38ba324fa9 | |||
| c00016fa19 | |||
| 4d5a305d1b | |||
| f9ca52ee2f | |||
| f6c393545c |
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
lib
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
open_collective: mango
|
||||||
patreon: hkalexling
|
patreon: hkalexling
|
||||||
ko_fi: hkalexling
|
ko_fi: hkalexling
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ mango
|
|||||||
.env
|
.env
|
||||||
*.md
|
*.md
|
||||||
public/css/uikit.css
|
public/css/uikit.css
|
||||||
|
public/img/*.svg
|
||||||
|
public/js/*.min.js
|
||||||
|
|||||||
+3
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -7,11 +7,15 @@ uglify:
|
|||||||
yarn
|
yarn
|
||||||
yarn uglify
|
yarn uglify
|
||||||
|
|
||||||
|
setup: libs
|
||||||
|
yarn
|
||||||
|
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
|
||||||
@@ -27,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
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +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
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -50,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.7.2
|
Mango - Manga Server and Web Reader. Version 0.17.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -75,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
|
||||||
@@ -138,8 +145,13 @@ Mobile UI:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
|
||||||
|
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
|
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
||||||
|
|
||||||
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+49
-15
@@ -1,36 +1,70 @@
|
|||||||
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('minify-js', () => {
|
// Copy libraries from node_moduels to public/js
|
||||||
return gulp.src('public/js/*.js')
|
gulp.task('copy-js', () => {
|
||||||
.pipe(minify({
|
return gulp.src([
|
||||||
removeConsole: true
|
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
||||||
}))
|
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
||||||
.pipe(gulp.dest('dist/js'));
|
'node_modules/uikit/dist/js/uikit.min.js',
|
||||||
|
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
||||||
|
])
|
||||||
|
.pipe(gulp.dest('public/js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Copy UIKit SVG icons to public/img
|
||||||
|
gulp.task('copy-uikit-icons', () => {
|
||||||
|
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||||
|
.pipe(gulp.dest('public/img'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compile less
|
||||||
gulp.task('less', () => {
|
gulp.task('less', () => {
|
||||||
return gulp.src('src/assets/*.less')
|
return gulp.src('public/css/*.less')
|
||||||
.pipe(less())
|
.pipe(less())
|
||||||
.pipe(gulp.dest('public/css'));
|
.pipe(gulp.dest('public/css'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transpile and minify JS files and output to dist
|
||||||
|
gulp.task('babel', () => {
|
||||||
|
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
|
||||||
|
.pipe(babel({
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: '>0.25%, not dead, ios>=9'
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.pipe(minify({
|
||||||
|
removeConsole: true,
|
||||||
|
builtIns: false
|
||||||
|
}))
|
||||||
|
.pipe(gulp.dest('dist/js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minify CSS and output to dist
|
||||||
gulp.task('minify-css', () => {
|
gulp.task('minify-css', () => {
|
||||||
return gulp.src('public/css/*.css')
|
return gulp.src('public/css/*.css')
|
||||||
.pipe(minifyCss())
|
.pipe(minifyCss())
|
||||||
.pipe(gulp.dest('dist/css'));
|
.pipe(gulp.dest('dist/css'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('img', () => {
|
// Copy static files (includeing images) to dist
|
||||||
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('minify-js', gulp.series('less', 'minify-css'), 'img', 'copy-files'));
|
// Set up the public folder for development
|
||||||
|
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
||||||
|
|
||||||
|
// Set up the dist folder for deployment
|
||||||
|
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||||
|
|
||||||
|
// Default task
|
||||||
|
gulp.task('default', gulp.series('dev', 'deploy'));
|
||||||
|
|||||||
+22
-19
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-39
@@ -1,74 +1,154 @@
|
|||||||
.uk-alert-close {
|
.uk-alert-close {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-card-body {
|
.uk-card-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-card-media-top {
|
.uk-card-media-top {
|
||||||
height: 250px;
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
.uk-card-media-top {
|
.uk-card-media-top {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uk-card-media-top>img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.uk-card-media-top > img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.uk-card-title {
|
.uk-card-title {
|
||||||
height: 3em;
|
max-height: 3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acard:hover {
|
.acard:hover {
|
||||||
text-decoration: none;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.uk-list li {
|
|
||||||
cursor: pointer;
|
.uk-list li:not(.nopointer) {
|
||||||
}
|
cursor: pointer;
|
||||||
.reader-bg {
|
|
||||||
background-color: black;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#scan-status {
|
#scan-status {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-bg {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
.break-word {
|
.break-word {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
.uk-logo > img {
|
|
||||||
height: 90px;
|
.uk-logo>img {
|
||||||
width: 90px;
|
height: 90px;
|
||||||
|
width: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uk-search {
|
.uk-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selecting {
|
#selectable .ui-selecting {
|
||||||
background: #EEE6B9;
|
background: #EEE6B9;
|
||||||
}
|
}
|
||||||
|
|
||||||
#selectable .ui-selected {
|
#selectable .ui-selected {
|
||||||
background: #F4E487;
|
background: #F4E487;
|
||||||
}
|
}
|
||||||
#selectable .ui-selecting.dark {
|
|
||||||
background: #5E5731;
|
.uk-light #selectable .ui-selecting {
|
||||||
|
background: #5E5731;
|
||||||
}
|
}
|
||||||
#selectable .ui-selected.dark {
|
|
||||||
background: #9D9252;
|
.uk-light #selectable .ui-selected {
|
||||||
|
background: #9D9252;
|
||||||
}
|
}
|
||||||
td > .uk-dropdown {
|
|
||||||
white-space: pre-line;
|
td>.uk-dropdown {
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
#edit-modal .uk-grid > div {
|
|
||||||
height: 300px;
|
#edit-modal .uk-grid>div {
|
||||||
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal #cover {
|
#edit-modal #cover {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal #cover-upload {
|
#edit-modal #cover-upload {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#edit-modal .uk-modal-body .uk-inline {
|
#edit-modal .uk-modal-body .uk-inline {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item .uk-card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grayscale {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light .uk-navbar-dropdown,
|
||||||
|
.uk-light .uk-modal-header,
|
||||||
|
.uk-light .uk-modal-body,
|
||||||
|
.uk-light .uk-modal-footer {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light .uk-dropdown {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light .uk-navbar-dropdown,
|
||||||
|
.uk-light .uk-dropdown {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light .uk-nav-header,
|
||||||
|
.uk-light .uk-description-list>dt {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#select-bar-controls a {
|
||||||
|
transform: scale(1.5, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#select-bar-controls a:hover {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-section {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#totop-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100vh;
|
||||||
|
right: 2em;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#totop-wrapper a {
|
||||||
|
position: fixed;
|
||||||
|
position: sticky;
|
||||||
|
top: calc(100vh - 5em);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@import "node_modules/uikit/src/less/uikit.theme.less";
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: @label-padding-vertical @label-padding-horizontal;
|
||||||
|
background: @label-background;
|
||||||
|
line-height: @label-line-height;
|
||||||
|
font-size: @label-font-size;
|
||||||
|
color: @label-color;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
.hook-label;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-success {
|
||||||
|
background-color: @label-success-background;
|
||||||
|
color: @label-success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-warning {
|
||||||
|
background-color: @label-warning-background;
|
||||||
|
color: @label-warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-danger {
|
||||||
|
background-color: @label-danger-background;
|
||||||
|
color: @label-danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-pending {
|
||||||
|
background-color: @global-secondary-background;
|
||||||
|
color: @global-inverse-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@internal-divider-icon-image: "../img/divider-icon.svg";
|
||||||
|
@internal-form-select-image: "../img/form-select.svg";
|
||||||
|
@internal-form-datalist-image: "../img/form-datalist.svg";
|
||||||
|
@internal-form-radio-image: "../img/form-radio.svg";
|
||||||
|
@internal-form-checkbox-image: "../img/form-checkbox.svg";
|
||||||
|
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
|
||||||
|
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
|
||||||
|
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
|
||||||
|
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||||
|
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||||
|
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
+88
-23
@@ -1,25 +1,90 @@
|
|||||||
var scanning = false;
|
$(() => {
|
||||||
function scan() {
|
const setting = loadThemeSetting();
|
||||||
scanning = true;
|
$('#theme-select').val(capitalize(setting));
|
||||||
$('#scan-status > div').removeAttr('hidden');
|
$('#theme-select').change((e) => {
|
||||||
$('#scan-status > span').attr('hidden', '');
|
const newSetting = $(e.currentTarget).val().toLowerCase();
|
||||||
var color = $('#scan').css('color');
|
saveThemeSetting(newSetting);
|
||||||
$('#scan').css('color', 'gray');
|
setTheme();
|
||||||
$.post(base_url + 'api/admin/scan', function (data) {
|
|
||||||
var ms = data.milliseconds;
|
|
||||||
var 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$(function() {
|
|
||||||
$('li').click(function() {
|
|
||||||
url = $(this).attr('data-url');
|
|
||||||
if (url) {
|
|
||||||
$(location).attr('href', url);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getProgress();
|
||||||
|
setInterval(getProgress, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize String
|
||||||
|
*
|
||||||
|
* @function capitalize
|
||||||
|
* @param {string} str - The string to be capitalized
|
||||||
|
* @return {string} The capitalized string
|
||||||
|
*/
|
||||||
|
const capitalize = (str) => {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an alpine.js property
|
||||||
|
*
|
||||||
|
* @function setProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {*} prop - The data property
|
||||||
|
*/
|
||||||
|
const setProp = (key, prop) => {
|
||||||
|
$('#root').get(0).__x.$data[key] = prop;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an alpine.js property
|
||||||
|
*
|
||||||
|
* @function getProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @return {*} The data property
|
||||||
|
*/
|
||||||
|
const getProp = (key) => {
|
||||||
|
return $('#root').get(0).__x.$data[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the thumbnail generation progress from the API
|
||||||
|
*
|
||||||
|
* @function getProgress
|
||||||
|
*/
|
||||||
|
const getProgress = () => {
|
||||||
|
$.get(`${base_url}api/admin/thumbnail_progress`)
|
||||||
|
.then(data => {
|
||||||
|
setProp('progress', data.progress);
|
||||||
|
const generating = data.progress > 0
|
||||||
|
setProp('generating', generating);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the thumbnail generation
|
||||||
|
*
|
||||||
|
* @function generateThumbnails
|
||||||
|
*/
|
||||||
|
const generateThumbnails = () => {
|
||||||
|
setProp('generating', true);
|
||||||
|
setProp('progress', 0.0);
|
||||||
|
$.post(`${base_url}api/admin/generate_thumbnails`)
|
||||||
|
.then(getProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the scan
|
||||||
|
*
|
||||||
|
* @function scan
|
||||||
|
*/
|
||||||
|
const scan = () => {
|
||||||
|
setProp('scanning', true);
|
||||||
|
setProp('scanMs', -1);
|
||||||
|
setProp('scanTitles', 0);
|
||||||
|
$.post(`${base_url}api/admin/scan`)
|
||||||
|
.then(data => {
|
||||||
|
setProp('scanMs', data.milliseconds);
|
||||||
|
setProp('scanTitles', data.titles);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
setProp('scanning', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* --- Alpine helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an alpine.js property
|
||||||
|
*
|
||||||
|
* @function setProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {*} prop - The data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
*/
|
||||||
|
const setProp = (key, prop, selector = '#root') => {
|
||||||
|
$(selector).get(0).__x.$data[key] = prop;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an alpine.js property
|
||||||
|
*
|
||||||
|
* @function getProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
* @return {*} The data property
|
||||||
|
*/
|
||||||
|
const getProp = (key, selector = '#root') => {
|
||||||
|
return $(selector).get(0).__x.$data[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --- Theme related functions
|
||||||
|
* Note: In the comments below we treat "theme" and "theme setting"
|
||||||
|
* differently. A theme can have only two values, either "dark" or
|
||||||
|
* "light", while a theme setting can have the third value "system".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the system setting prefers dark theme.
|
||||||
|
* from https://flaviocopes.com/javascript-detect-dark-mode/
|
||||||
|
*
|
||||||
|
* @function preferDarkMode
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const preferDarkMode = () => {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given string represents a valid theme setting
|
||||||
|
*
|
||||||
|
* @function validThemeSetting
|
||||||
|
* @param {string} theme - The string representing the theme setting
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const validThemeSetting = (theme) => {
|
||||||
|
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load theme setting from local storage, or use 'light'
|
||||||
|
*
|
||||||
|
* @function loadThemeSetting
|
||||||
|
* @return {string} A theme setting ('dark', 'light', or 'system')
|
||||||
|
*/
|
||||||
|
const loadThemeSetting = () => {
|
||||||
|
let str = localStorage.getItem('theme');
|
||||||
|
if (!str || !validThemeSetting(str)) str = 'light';
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current theme (not theme setting)
|
||||||
|
*
|
||||||
|
* @function loadTheme
|
||||||
|
* @return {string} The current theme to use ('dark' or 'light')
|
||||||
|
*/
|
||||||
|
const loadTheme = () => {
|
||||||
|
let setting = loadThemeSetting();
|
||||||
|
if (setting === 'system') {
|
||||||
|
setting = preferDarkMode() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a theme setting
|
||||||
|
*
|
||||||
|
* @function saveThemeSetting
|
||||||
|
* @param {string} setting - A theme setting
|
||||||
|
*/
|
||||||
|
const saveThemeSetting = setting => {
|
||||||
|
if (!validThemeSetting(setting)) setting = 'light';
|
||||||
|
localStorage.setItem('theme', setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the current theme. When the current theme setting is 'system', it
|
||||||
|
* will be changed to either 'light' or 'dark'
|
||||||
|
*
|
||||||
|
* @function toggleTheme
|
||||||
|
*/
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = loadTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
saveThemeSetting(newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a theme, or load a theme and then apply it
|
||||||
|
*
|
||||||
|
* @function setTheme
|
||||||
|
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
|
||||||
|
* `loadTheme` to get a theme and apply it.
|
||||||
|
*/
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (!theme) theme = loadTheme();
|
||||||
|
if (theme === 'dark') {
|
||||||
|
$('html').css('background', 'rgb(20, 20, 20)');
|
||||||
|
$('body').addClass('uk-light');
|
||||||
|
$('.uk-card').addClass('uk-card-secondary');
|
||||||
|
$('.uk-card').removeClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').addClass('dark');
|
||||||
|
} else {
|
||||||
|
$('html').css('background', '');
|
||||||
|
$('body').removeClass('uk-light');
|
||||||
|
$('.uk-card').removeClass('uk-card-secondary');
|
||||||
|
$('.uk-card').addClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').removeClass('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// do it before document is ready to prevent the initial flash of white on
|
||||||
|
// most pages
|
||||||
|
setTheme();
|
||||||
|
$(() => {
|
||||||
|
// hack for the reader page
|
||||||
|
setTheme();
|
||||||
|
|
||||||
|
// on system dark mode setting change
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', event => {
|
||||||
|
if (loadThemeSetting() === 'system')
|
||||||
|
setTheme(event.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+22
-14
@@ -1,18 +1,26 @@
|
|||||||
const truncate = () => {
|
/**
|
||||||
$('.acard .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({
|
||||||
}
|
truncate: 'letter',
|
||||||
else {
|
watch: true,
|
||||||
$(e).removeAttr('uk-tooltip');
|
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
-107
@@ -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,63 +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><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td>
|
* @return {string} The class name string
|
||||||
<td><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>
|
case 'Pending':
|
||||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
cls += 'label-pending';
|
||||||
${retryBtn}
|
break;
|
||||||
</td>
|
case 'Completed':
|
||||||
</tr>`;
|
cls += 'label-success';
|
||||||
});
|
break;
|
||||||
|
case 'Error':
|
||||||
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
cls += 'label-danger';
|
||||||
$('tbody').remove();
|
break;
|
||||||
$('table').append(tbody);
|
case 'MissingPages':
|
||||||
})
|
cls += 'label-warning';
|
||||||
.fail((jqXHR, status) => {
|
break;
|
||||||
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');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
+43
-46
@@ -32,42 +32,41 @@ const download = () => {
|
|||||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
console.log(ids);
|
console.log(ids);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: base_url + 'api/admin/mangadex/download',
|
url: base_url + 'api/admin/mangadex/download',
|
||||||
data: JSON.stringify({chapters: chapters}),
|
data: JSON.stringify({
|
||||||
contentType: "application/json",
|
chapters: chapters
|
||||||
dataType: 'json'
|
}),
|
||||||
})
|
contentType: "application/json",
|
||||||
.done(data => {
|
dataType: 'json'
|
||||||
console.log(data);
|
})
|
||||||
if (data.error) {
|
.done(data => {
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
console.log(data);
|
||||||
return;
|
if (data.error) {
|
||||||
}
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
const successCount = parseInt(data.success);
|
return;
|
||||||
const failCount = parseInt(data.fail);
|
}
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
const successCount = parseInt(data.success);
|
||||||
window.location.href = base_url + 'admin/downloads';
|
const failCount = parseInt(data.fail);
|
||||||
|
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||||
|
window.location.href = base_url + 'admin/downloads';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
});
|
});
|
||||||
styleModal();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
styleModal();
|
|
||||||
};
|
};
|
||||||
const toggleSpinner = () => {
|
const toggleSpinner = () => {
|
||||||
var attr = $('#spinner').attr('hidden');
|
var attr = $('#spinner').attr('hidden');
|
||||||
if (attr) {
|
if (attr) {
|
||||||
$('#spinner').removeAttr('hidden');
|
$('#spinner').removeAttr('hidden');
|
||||||
$('#search-btn').attr('hidden', '');
|
$('#search-btn').attr('hidden', '');
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$('#search-btn').removeAttr('hidden');
|
$('#search-btn').removeAttr('hidden');
|
||||||
$('#spinner').attr('hidden', '');
|
$('#spinner').attr('hidden', '');
|
||||||
}
|
}
|
||||||
@@ -96,10 +95,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +137,12 @@ const search = () => {
|
|||||||
const comp = (a, b) => {
|
const comp = (a, b) => {
|
||||||
var ai;
|
var ai;
|
||||||
var bi;
|
var bi;
|
||||||
try {ai = parseFloat(a);} catch(e) {}
|
try {
|
||||||
try {bi = parseFloat(b);} catch(e) {}
|
ai = parseFloat(a);
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
bi = parseFloat(b);
|
||||||
|
} catch (e) {}
|
||||||
if (typeof ai === 'undefined') return -1;
|
if (typeof ai === 'undefined') return -1;
|
||||||
if (typeof bi === 'undefined') return 1;
|
if (typeof bi === 'undefined') return 1;
|
||||||
if (ai < bi) return 1;
|
if (ai < bi) return 1;
|
||||||
@@ -176,8 +178,7 @@ const parseRange = str => {
|
|||||||
if (!matches) {
|
if (!matches) {
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
// e.g., <= 30
|
||||||
num = parseInt(matches[2]);
|
num = parseInt(matches[2]);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
@@ -194,8 +195,7 @@ const parseRange = str => {
|
|||||||
case '>=':
|
case '>=':
|
||||||
return [num, null];
|
return [num, null];
|
||||||
}
|
}
|
||||||
}
|
} else if (typeof matches[3] !== 'undefined') {
|
||||||
else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
// a single number
|
||||||
num = parseInt(matches[3]);
|
num = parseInt(matches[3]);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
@@ -203,8 +203,7 @@ const parseRange = str => {
|
|||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
return [num, num];
|
return [num, num];
|
||||||
}
|
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
// e.g., 10 - 23
|
||||||
num = parseInt(matches[4]);
|
num = parseInt(matches[4]);
|
||||||
const n2 = parseInt(matches[5]);
|
const n2 = parseInt(matches[5]);
|
||||||
@@ -213,8 +212,7 @@ const parseRange = str => {
|
|||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
return [num, n2];
|
return [num, n2];
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// empty or space only
|
// empty or space only
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
@@ -280,8 +278,7 @@ const buildTable = () => {
|
|||||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||||
}).join(' | ');
|
}).join(' | ');
|
||||||
const dark = getTheme() === 'dark' ? 'dark' : '';
|
return `<tr class="ui-widget-content">
|
||||||
return `<tr class="ui-widget-content ${dark}">
|
|
||||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||||
<td>${chp.title}</td>
|
<td>${chp.title}</td>
|
||||||
<td>${chp.language}</td>
|
<td>${chp.language}</td>
|
||||||
@@ -302,7 +299,7 @@ const buildTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unescapeHTML = (str) => {
|
const unescapeHTML = (str) => {
|
||||||
var elt = document.createElement("span");
|
var elt = document.createElement("span");
|
||||||
elt.innerHTML = str;
|
elt.innerHTML = str;
|
||||||
return elt.innerText;
|
return elt.innerText;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,141 @@
|
|||||||
|
const loadPlugin = id => {
|
||||||
|
localStorage.setItem('plugin', id);
|
||||||
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
|
const newURL = `${url}?${$.param({
|
||||||
|
plugin: id
|
||||||
|
})}`;
|
||||||
|
window.location.href = newURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
var storedID = localStorage.getItem('plugin');
|
||||||
|
if (storedID && storedID !== pid) {
|
||||||
|
loadPlugin(storedID);
|
||||||
|
} else {
|
||||||
|
$('#controls').removeAttr('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#search-input').keypress(event => {
|
||||||
|
if (event.which === 13) {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#plugin-select').val(pid);
|
||||||
|
$('#plugin-select').change(() => {
|
||||||
|
const id = $('#plugin-select').val();
|
||||||
|
loadPlugin(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mangaTitle = "";
|
||||||
|
let searching = false;
|
||||||
|
const search = () => {
|
||||||
|
if (searching)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const query = $.param({
|
||||||
|
query: $('#search-input').val(),
|
||||||
|
plugin: pid
|
||||||
|
});
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Search failed. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mangaTitle = data.title;
|
||||||
|
$('#title-text').text(data.title);
|
||||||
|
buildTable(data.chapters);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTable = (chapters) => {
|
||||||
|
$('#table').attr('hidden', '');
|
||||||
|
$('table').empty();
|
||||||
|
|
||||||
|
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||||
|
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||||
|
$('table').append(thead);
|
||||||
|
|
||||||
|
const rows = chapters.map(ch => {
|
||||||
|
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||||
|
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||||
|
});
|
||||||
|
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||||
|
$('table').append(tbody);
|
||||||
|
|
||||||
|
$('#selectable').selectable({
|
||||||
|
filter: 'tr'
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#table table').tablesorter();
|
||||||
|
$('#table').removeAttr('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unselect = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
const selected = $('tbody > tr.ui-selected');
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
$('#download-btn').attr('hidden', '');
|
||||||
|
$('#download-spinner').removeAttr('hidden');
|
||||||
|
const chapters = selected.map((i, e) => {
|
||||||
|
return {
|
||||||
|
id: $(e).attr('data-id'),
|
||||||
|
title: $(e).attr('data-title')
|
||||||
|
}
|
||||||
|
}).get();
|
||||||
|
console.log(chapters);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: base_url + 'api/admin/plugin/download',
|
||||||
|
data: JSON.stringify({
|
||||||
|
plugin: pid,
|
||||||
|
chapters: chapters,
|
||||||
|
title: mangaTitle
|
||||||
|
}),
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||||
|
window.location.href = base_url + 'admin/downloads';
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
$('#download-spinner').attr('hidden', '');
|
||||||
|
$('#download-btn').removeAttr('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
+281
-69
@@ -1,81 +1,293 @@
|
|||||||
$(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(){
|
if (mode === 'continuous') {
|
||||||
jumpTo(parseInt($('#page-select').val()));
|
waitForPage(items.length, () => {
|
||||||
});
|
setupScroller();
|
||||||
function showControl(idx) {
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
$('#page-select').val(idx);
|
$('#page-select').val(idx);
|
||||||
UIkit.modal($('#modal-sections')).show();
|
UIkit.modal($('#modal-sections')).show();
|
||||||
styleModal();
|
|
||||||
}
|
}
|
||||||
function jumpTo(page) {
|
|
||||||
var ary = window.location.pathname.split('/');
|
/**
|
||||||
ary[ary.length - 1] = page;
|
* Redirect to a URL
|
||||||
ary.shift(); // remove leading `/`
|
*
|
||||||
ary.unshift(window.location.origin);
|
* @function redirect
|
||||||
window.location.replace(ary.join('/'));
|
* @param {string} url - The target URL
|
||||||
}
|
*/
|
||||||
function replaceHistory(url) {
|
const redirect = (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);
|
||||||
|
};
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
+7
-115
@@ -1,123 +1,15 @@
|
|||||||
$(() => {
|
$(() => {
|
||||||
const sortItems = () => {
|
$('#sort-select').change(() => {
|
||||||
const sort = $('#sort-select').find(':selected').attr('id');
|
const sort = $('#sort-select').find(':selected').attr('id');
|
||||||
const ary = sort.split('-');
|
const ary = sort.split('-');
|
||||||
const by = ary[0];
|
const by = ary[0];
|
||||||
const dir = ary[1];
|
const dir = ary[1];
|
||||||
|
|
||||||
let items = $('.item');
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
items.remove();
|
const newURL = `${url}?${$.param({
|
||||||
|
sort: by,
|
||||||
const ctxAry = [];
|
ascend: dir === 'up' ? 1 : 0
|
||||||
const keyRange = {};
|
})}`;
|
||||||
if (by === 'auto') {
|
window.location.href = newURL;
|
||||||
// intelligent sorting
|
|
||||||
items.each((i, item) => {
|
|
||||||
const name = $(item).find('.uk-card-title').text();
|
|
||||||
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
|
|
||||||
|
|
||||||
const numbers = {};
|
|
||||||
let match = regex.exec(name);
|
|
||||||
while (match) {
|
|
||||||
const key = match[1];
|
|
||||||
const num = parseFloat(match[2]);
|
|
||||||
numbers[key] = num;
|
|
||||||
|
|
||||||
if (!keyRange[key]) {
|
|
||||||
keyRange[key] = [num, num, 1];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
keyRange[key][2] += 1;
|
|
||||||
if (num < keyRange[key][0]) {
|
|
||||||
keyRange[key][0] = num;
|
|
||||||
}
|
|
||||||
else if (num > keyRange[key][1]) {
|
|
||||||
keyRange[key][1] = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match = regex.exec(name);
|
|
||||||
}
|
|
||||||
ctxAry.push({index: i, numbers: numbers});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(keyRange);
|
|
||||||
|
|
||||||
const sortedKeys = Object.keys(keyRange).filter(k => {
|
|
||||||
return keyRange[k][2] >= items.length / 2;
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedKeys.sort((a, b) => {
|
|
||||||
// sort by frequency of the key first
|
|
||||||
if (keyRange[a][2] !== keyRange[b][2]) {
|
|
||||||
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
|
|
||||||
}
|
|
||||||
// then sort by range of the key
|
|
||||||
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(sortedKeys);
|
|
||||||
|
|
||||||
ctxAry.sort((a, b) => {
|
|
||||||
for (let i = 0; i < sortedKeys.length; i++) {
|
|
||||||
const key = sortedKeys[i];
|
|
||||||
|
|
||||||
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
|
|
||||||
continue;
|
|
||||||
if (a.numbers[key] === undefined)
|
|
||||||
return 1;
|
|
||||||
if (b.numbers[key] === undefined)
|
|
||||||
return -1;
|
|
||||||
if (a.numbers[key] === b.numbers[key])
|
|
||||||
continue;
|
|
||||||
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedItems = [];
|
|
||||||
ctxAry.forEach(ctx => {
|
|
||||||
sortedItems.push(items[ctx.index]);
|
|
||||||
});
|
|
||||||
items = sortedItems;
|
|
||||||
|
|
||||||
if (dir === 'down') {
|
|
||||||
items.reverse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
items.sort((a, b) => {
|
|
||||||
var res;
|
|
||||||
if (by === 'name')
|
|
||||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
|
||||||
else if (by === 'date')
|
|
||||||
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
|
|
||||||
else if (by === 'progress') {
|
|
||||||
const ap = parseFloat($(a).attr('data-progress'));
|
|
||||||
const bp = parseFloat($(b).attr('data-progress'));
|
|
||||||
if (ap === bp)
|
|
||||||
// if progress is the same, we compare by name
|
|
||||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
|
||||||
else
|
|
||||||
res = ap > bp;
|
|
||||||
}
|
|
||||||
if (dir === 'up')
|
|
||||||
return res ? 1 : -1;
|
|
||||||
else
|
|
||||||
return !res ? 1 : -1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$('#item-container').append(items);
|
|
||||||
};
|
|
||||||
|
|
||||||
$('#sort-select').change(() => {
|
|
||||||
sortItems();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($('option#auto-up').length > 0)
|
|
||||||
$('option#auto-up').attr('selected', '');
|
|
||||||
else
|
|
||||||
$('option#name-up').attr('selected', '');
|
|
||||||
|
|
||||||
sortItems();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
const getTheme = () => {
|
|
||||||
var theme = localStorage.getItem('theme');
|
|
||||||
if (!theme) theme = 'light';
|
|
||||||
return theme;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTheme = theme => {
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const theme = getTheme();
|
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
|
||||||
setTheme(newTheme);
|
|
||||||
saveTheme(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTheme = themeStr => {
|
|
||||||
if (themeStr === 'dark') {
|
|
||||||
$('html').css('background', 'rgb(20, 20, 20)');
|
|
||||||
$('body').addClass('uk-light');
|
|
||||||
$('.uk-card').addClass('uk-card-secondary');
|
|
||||||
$('.uk-card').removeClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').addClass('dark');
|
|
||||||
} else {
|
|
||||||
$('html').css('background', '');
|
|
||||||
$('body').removeClass('uk-light');
|
|
||||||
$('.uk-card').removeClass('uk-card-secondary');
|
|
||||||
$('.uk-card').addClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').removeClass('dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const styleModal = () => {
|
|
||||||
const color = getTheme() === 'dark' ? '#222' : '';
|
|
||||||
$('.uk-modal-header').css('background', color);
|
|
||||||
$('.uk-modal-body').css('background', color);
|
|
||||||
$('.uk-modal-footer').css('background', color);
|
|
||||||
};
|
|
||||||
|
|
||||||
// do it before document is ready to prevent the initial flash of white on
|
|
||||||
// most pages
|
|
||||||
setTheme(getTheme());
|
|
||||||
|
|
||||||
$(() => {
|
|
||||||
// hack for the reader page
|
|
||||||
setTheme(getTheme());
|
|
||||||
});
|
|
||||||
+105
-19
@@ -1,3 +1,24 @@
|
|||||||
|
$(() => {
|
||||||
|
setupAcard();
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupAcard = () => {
|
||||||
|
$('.acard.is_entry').click((e) => {
|
||||||
|
if ($(e.target).hasClass('no-modal')) return;
|
||||||
|
const card = $(e.target).closest('.acard');
|
||||||
|
|
||||||
|
showModal(
|
||||||
|
$(card).attr('data-encoded-path'),
|
||||||
|
parseInt($(card).attr('data-pages')),
|
||||||
|
parseFloat($(card).attr('data-progress')),
|
||||||
|
$(card).attr('data-encoded-book-title'),
|
||||||
|
$(card).attr('data-encoded-title'),
|
||||||
|
$(card).attr('data-book-id'),
|
||||||
|
$(card).attr('data-id')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
|
||||||
const zipPath = decodeURIComponent(encodedPath);
|
const zipPath = decodeURIComponent(encodedPath);
|
||||||
const title = decodeURIComponent(encodedeTitle);
|
const title = decodeURIComponent(encodedeTitle);
|
||||||
@@ -15,9 +36,6 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
$('#continue-btn').text('Continue from ' + percentage + '%');
|
$('#continue-btn').text('Continue from ' + percentage + '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#modal-title-link').text(title);
|
|
||||||
$('#modal-title-link').attr('href', `${base_url}book/${titleID}`);
|
|
||||||
|
|
||||||
$('#modal-entry-title').find('span').text(entry);
|
$('#modal-entry-title').find('span').text(entry);
|
||||||
$('#modal-entry-title').next().attr('data-id', titleID);
|
$('#modal-entry-title').next().attr('data-id', titleID);
|
||||||
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
$('#modal-entry-title').next().attr('data-entry-id', entryID);
|
||||||
@@ -37,27 +55,35 @@ 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();
|
||||||
styleModal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -72,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'
|
||||||
@@ -114,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);
|
||||||
@@ -126,7 +153,6 @@ const edit = (eid) => {
|
|||||||
setupUpload(eid);
|
setupUpload(eid);
|
||||||
|
|
||||||
UIkit.modal($('#edit-modal')).show();
|
UIkit.modal($('#edit-modal')).show();
|
||||||
styleModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupUpload = (eid) => {
|
const setupUpload = (eid) => {
|
||||||
@@ -134,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);
|
||||||
@@ -166,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
@@ -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}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
+39
-15
@@ -1,46 +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.2.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:
|
||||||
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
|
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:
|
||||||
|
git: https://github.com/kostya/myhtml.git
|
||||||
|
version: 1.5.1
|
||||||
|
|
||||||
|
open_api:
|
||||||
|
git: https://github.com/jreinert/open_api.cr.git
|
||||||
|
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.7.2
|
version: 0.17.0
|
||||||
|
|
||||||
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,9 +21,21 @@ 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:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
clim:
|
clim:
|
||||||
github: at-grandpa/clim
|
github: at-grandpa/clim
|
||||||
|
duktape:
|
||||||
|
github: jessedoyle/duktape.cr
|
||||||
|
version: ~> 0.20.0
|
||||||
|
myhtml:
|
||||||
|
github: kostya/myhtml
|
||||||
|
http_proxy:
|
||||||
|
github: mamantoha/http_proxy
|
||||||
|
image_size:
|
||||||
|
github: hkalexling/image_size.cr
|
||||||
|
koa:
|
||||||
|
github: hkalexling/koa
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
require "./spec_helper"
|
|
||||||
|
|
||||||
include MangaDex
|
|
||||||
|
|
||||||
describe Queue do
|
|
||||||
it "creates DB at given path" do
|
|
||||||
with_queue do |_, path|
|
|
||||||
File.exists?(path).should be_true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "pops nil when empty" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.pop.should be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "inserts multiple jobs" do
|
|
||||||
with_queue do |queue|
|
|
||||||
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
|
|
||||||
Time.utc
|
|
||||||
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
|
|
||||||
Time.utc
|
|
||||||
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
|
|
||||||
Time.utc
|
|
||||||
j4 = Job.new "4", "4", "title", "manga_title",
|
|
||||||
JobStatus::Downloading, Time.utc
|
|
||||||
count = queue.push [j1, j2, j3, j4]
|
|
||||||
count.should eq 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "pops pending job" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.id.should eq "3"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "correctly counts jobs" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.count.should eq 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "deletes job" do
|
|
||||||
with_queue do |queue|
|
|
||||||
queue.delete "4"
|
|
||||||
queue.count.should eq 3
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "sets status" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.set_status JobStatus::Downloading, job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.status.should eq JobStatus::Downloading
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "sets number of pages" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.set_pages 100, job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.pages.should eq 100
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "adds fail/success counts" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.add_success job
|
|
||||||
queue.add_success job
|
|
||||||
queue.add_fail job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.success_count.should eq 2
|
|
||||||
job.not_nil!.fail_count.should eq 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "appends status message" do
|
|
||||||
with_queue do |queue|
|
|
||||||
job = queue.pop.not_nil!
|
|
||||||
queue.add_message "hello", job
|
|
||||||
queue.add_message "world", job
|
|
||||||
job = queue.pop
|
|
||||||
job.should_not be_nil
|
|
||||||
job.not_nil!.status_message.should eq "\nhello\nworld"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "cleans up" do
|
|
||||||
with_queue do
|
|
||||||
true
|
|
||||||
end
|
|
||||||
State.reset
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+2
-11
@@ -1,6 +1,8 @@
|
|||||||
require "spec"
|
require "spec"
|
||||||
|
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
|
||||||
@@ -52,14 +54,3 @@ def with_storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_queue
|
|
||||||
with_default_config do
|
|
||||||
temp_queue_db = get_tempfile "mango-test-queue-db"
|
|
||||||
queue = MangaDex::Queue.new temp_queue_db.path
|
|
||||||
clear = yield queue, temp_queue_db.path
|
|
||||||
if clear == true
|
|
||||||
temp_queue_db.delete
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
+15
-5
@@ -1,10 +1,10 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe "compare_alphanumerically" do
|
describe "compare_numerically" do
|
||||||
it "sorts filenames with leading zeros correctly" do
|
it "sorts filenames with leading zeros correctly" do
|
||||||
ary = ["010.jpg", "001.jpg", "002.png"]
|
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||||
end
|
end
|
||||||
@@ -12,7 +12,7 @@ describe "compare_alphanumerically" do
|
|||||||
it "sorts filenames without leading zeros correctly" do
|
it "sorts filenames without leading zeros correctly" do
|
||||||
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
||||||
ary.sort! { |a, b|
|
ary.sort! { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}
|
}
|
||||||
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||||
end
|
end
|
||||||
@@ -22,7 +22,7 @@ describe "compare_alphanumerically" do
|
|||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,7 +30,17 @@ describe "compare_alphanumerically" do
|
|||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort { |a, b|
|
||||||
compare_alphanumerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "chapter_sort" do
|
||||||
|
it "sorts correctly" do
|
||||||
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
|
sorter = ChapterSorter.new ary
|
||||||
|
ary.reverse.sort do |a, b|
|
||||||
|
sorter.compare a, b
|
||||||
|
end.should eq ary
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
+11
-11
@@ -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
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
@import "node_modules/uikit/src/less/uikit.theme.less";
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: @label-padding-vertical @label-padding-horizontal;
|
|
||||||
background: @label-background;
|
|
||||||
line-height: @label-line-height;
|
|
||||||
font-size: @label-font-size;
|
|
||||||
color: @label-color;
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
.hook-label;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-success {
|
|
||||||
background-color: @label-success-background;
|
|
||||||
color: @label-success-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-warning {
|
|
||||||
background-color: @label-warning-background;
|
|
||||||
color: @label-warning-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-danger {
|
|
||||||
background-color: @label-danger-background;
|
|
||||||
color: @label-danger-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-pending {
|
|
||||||
background-color: @global-secondary-background;
|
|
||||||
color: @global-inverse-color;
|
|
||||||
}
|
|
||||||
+8
-8
@@ -11,11 +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",
|
||||||
|
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)]
|
||||||
@@ -50,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,6 +1,6 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../storage"
|
require "../storage"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class AuthHandler < Kemal::Handler
|
class AuthHandler < Kemal::Handler
|
||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require "baked_file_system"
|
require "baked_file_system"
|
||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class FS
|
class FS
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
require "../util"
|
require "../util/*"
|
||||||
|
|
||||||
class UploadHandler < Kemal::Handler
|
class UploadHandler < Kemal::Handler
|
||||||
def initialize(@upload_dir : String)
|
def initialize(@upload_dir : String)
|
||||||
|
|||||||
-696
@@ -1,696 +0,0 @@
|
|||||||
require "mime"
|
|
||||||
require "json"
|
|
||||||
require "uri"
|
|
||||||
require "./util"
|
|
||||||
require "./archive"
|
|
||||||
|
|
||||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
|
||||||
|
|
||||||
struct Image
|
|
||||||
property data : Bytes
|
|
||||||
property mime : String
|
|
||||||
property filename : String
|
|
||||||
property size : Int32
|
|
||||||
|
|
||||||
def initialize(@data, @mime, @filename, @size)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Entry
|
|
||||||
property zip_path : String, book : Title, title : String,
|
|
||||||
size : String, pages : Int32, id : String, title_id : String,
|
|
||||||
encoded_path : String, encoded_title : String, mtime : Time
|
|
||||||
|
|
||||||
def initialize(path, @book, @title_id, storage)
|
|
||||||
@zip_path = path
|
|
||||||
@encoded_path = URI.encode path
|
|
||||||
@title = File.basename path, File.extname path
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
@size = (File.size path).humanize_bytes
|
|
||||||
file = ArchiveFile.new path
|
|
||||||
@pages = file.entries.count do |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
end
|
|
||||||
file.close
|
|
||||||
id = storage.get_id @zip_path, false
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_id({
|
|
||||||
path: @zip_path,
|
|
||||||
id: id,
|
|
||||||
is_title: false,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@mtime = File.info(@zip_path).modification_time
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
{% for str in ["zip_path", "title", "size", "id", "title_id",
|
|
||||||
"encoded_path", "encoded_title"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "display_name", @book.display_name @title
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "pages" { json.number @pages }
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
|
||||||
@book.display_name @title
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_display_name
|
|
||||||
URI.encode display_name
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_url
|
|
||||||
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
info_url = info.entry_cover_url[@title]?
|
|
||||||
unless info_url.nil? || info_url.empty?
|
|
||||||
url = File.join Config.current.base_url, info_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
url
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_page(page_num)
|
|
||||||
img = nil
|
|
||||||
ArchiveFile.open @zip_path do |file|
|
|
||||||
page = file.entries
|
|
||||||
.select { |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
}
|
|
||||||
.sort { |a, b|
|
|
||||||
compare_alphanumerically a.filename, b.filename
|
|
||||||
}
|
|
||||||
.[page_num - 1]
|
|
||||||
data = file.read_entry page
|
|
||||||
if data
|
|
||||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
|
||||||
data.size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
img
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_entry
|
|
||||||
idx = @book.entries.index self
|
|
||||||
return nil if idx.nil? || idx == @book.entries.size - 1
|
|
||||||
@book.entries[idx + 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def previous_entry
|
|
||||||
idx = @book.entries.index self
|
|
||||||
return nil if idx.nil? || idx == 0
|
|
||||||
@book.entries[idx - 1]
|
|
||||||
end
|
|
||||||
|
|
||||||
def date_added
|
|
||||||
date_added = nil
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
info_da = info.date_added[@title]?
|
|
||||||
if info_da.nil?
|
|
||||||
date_added = info.date_added[@title] = ctime @zip_path
|
|
||||||
info.save
|
|
||||||
else
|
|
||||||
date_added = info_da
|
|
||||||
end
|
|
||||||
end
|
|
||||||
date_added.not_nil! # is it ok to set not_nil! here?
|
|
||||||
end
|
|
||||||
|
|
||||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
|
||||||
# instead of IDs in info.json
|
|
||||||
def save_progress(username, page)
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
if info.progress[username]?.nil?
|
|
||||||
info.progress[username] = {@title => page}
|
|
||||||
else
|
|
||||||
info.progress[username][@title] = page
|
|
||||||
end
|
|
||||||
# save last_read timestamp
|
|
||||||
if info.last_read[username]?.nil?
|
|
||||||
info.last_read[username] = {@title => Time.utc}
|
|
||||||
else
|
|
||||||
info.last_read[username][@title] = Time.utc
|
|
||||||
end
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_progress(username)
|
|
||||||
progress = 0
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
unless info.progress[username]?.nil? ||
|
|
||||||
info.progress[username][@title]?.nil?
|
|
||||||
progress = info.progress[username][@title]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
[progress, @pages].min
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage(username)
|
|
||||||
page = load_progress username
|
|
||||||
page / @pages
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_last_read(username)
|
|
||||||
last_read = nil
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
unless info.last_read[username]?.nil? ||
|
|
||||||
info.last_read[username][@title]?.nil?
|
|
||||||
last_read = info.last_read[username][@title]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
last_read
|
|
||||||
end
|
|
||||||
|
|
||||||
def finished?(username)
|
|
||||||
load_progress(username) == @pages
|
|
||||||
end
|
|
||||||
|
|
||||||
def started?(username)
|
|
||||||
load_progress(username) > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Title
|
|
||||||
property dir : String, parent_id : String, title_ids : Array(String),
|
|
||||||
entries : Array(Entry), title : String, id : String,
|
|
||||||
encoded_title : String, mtime : Time
|
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id, storage,
|
|
||||||
@library : Library)
|
|
||||||
id = storage.get_id @dir, true
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_id({
|
|
||||||
path: @dir,
|
|
||||||
id: id,
|
|
||||||
is_title: true,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@title = File.basename dir
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
@title_ids = [] of String
|
|
||||||
@entries = [] of Entry
|
|
||||||
@mtime = File.info(dir).modification_time
|
|
||||||
|
|
||||||
Dir.entries(dir).each do |fn|
|
|
||||||
next if fn.starts_with? "."
|
|
||||||
path = File.join dir, fn
|
|
||||||
if File.directory? path
|
|
||||||
title = Title.new path, @id, storage, library
|
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
|
||||||
@library.title_hash[title.id] = title
|
|
||||||
@title_ids << title.id
|
|
||||||
next
|
|
||||||
end
|
|
||||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
|
||||||
unless File.readable? path
|
|
||||||
Logger.warn "File #{path} is not readable. Please make sure the " \
|
|
||||||
"file permission is configured correctly."
|
|
||||||
next
|
|
||||||
end
|
|
||||||
archive_exception = validate_archive path
|
|
||||||
unless archive_exception.nil?
|
|
||||||
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
|
||||||
"Archive error: #{archive_exception}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
entry = Entry.new path, self, @id, storage
|
|
||||||
@entries << entry if entry.pages > 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
mtimes = [@mtime]
|
|
||||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
|
||||||
mtimes += @entries.map { |e| e.mtime }
|
|
||||||
@mtime = mtimes.max
|
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
|
||||||
compare_alphanumerically @library.title_hash[a].title,
|
|
||||||
@library.title_hash[b].title
|
|
||||||
end
|
|
||||||
@entries.sort! do |a, b|
|
|
||||||
compare_alphanumerically a.title, b.title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "display_name", display_name
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
json.field "titles" do
|
|
||||||
json.raw self.titles.to_json
|
|
||||||
end
|
|
||||||
json.field "entries" do
|
|
||||||
json.raw @entries.to_json
|
|
||||||
end
|
|
||||||
json.field "parents" do
|
|
||||||
json.array do
|
|
||||||
self.parents.each do |title|
|
|
||||||
json.object do
|
|
||||||
json.field "title", title.title
|
|
||||||
json.field "id", title.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def titles
|
|
||||||
@title_ids.map { |tid| @library.get_title! tid }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get all entries, including entries in nested titles
|
|
||||||
def deep_entries
|
|
||||||
return @entries if title_ids.empty?
|
|
||||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_titles
|
|
||||||
return [] of Title if titles.empty?
|
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
|
||||||
end
|
|
||||||
|
|
||||||
def parents
|
|
||||||
ary = [] of Title
|
|
||||||
tid = @parent_id
|
|
||||||
while !tid.empty?
|
|
||||||
title = @library.get_title! tid
|
|
||||||
ary << title
|
|
||||||
tid = title.parent_id
|
|
||||||
end
|
|
||||||
ary.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def size
|
|
||||||
@entries.size + @title_ids.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_entry(eid)
|
|
||||||
@entries.find { |e| e.id == eid }
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name
|
|
||||||
dn = @title
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info_dn = info.display_name
|
|
||||||
dn = info_dn unless info_dn.empty?
|
|
||||||
end
|
|
||||||
dn
|
|
||||||
end
|
|
||||||
|
|
||||||
def encoded_display_name
|
|
||||||
URI.encode display_name
|
|
||||||
end
|
|
||||||
|
|
||||||
def display_name(entry_name)
|
|
||||||
dn = entry_name
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info_dn = info.entry_display_name[entry_name]?
|
|
||||||
unless info_dn.nil? || info_dn.empty?
|
|
||||||
dn = info_dn
|
|
||||||
end
|
|
||||||
end
|
|
||||||
dn
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_display_name(dn)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.display_name = dn
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_display_name(entry_name : String, dn)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.entry_display_name[entry_name] = dn
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def cover_url
|
|
||||||
url = "#{Config.current.base_url}img/icon.png"
|
|
||||||
if @entries.size > 0
|
|
||||||
url = @entries[0].cover_url
|
|
||||||
end
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info_url = info.cover_url
|
|
||||||
unless info_url.nil? || info_url.empty?
|
|
||||||
url = File.join Config.current.base_url, info_url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
url
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_cover_url(url : String)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.cover_url = url
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_cover_url(entry_name : String, url : String)
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.entry_cover_url[entry_name] = url
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 100%
|
|
||||||
def read_all(username)
|
|
||||||
@entries.each do |e|
|
|
||||||
e.save_progress username, e.pages
|
|
||||||
end
|
|
||||||
titles.each do |t|
|
|
||||||
t.read_all username
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 0%
|
|
||||||
def unread_all(username)
|
|
||||||
@entries.each do |e|
|
|
||||||
e.save_progress username, 0
|
|
||||||
end
|
|
||||||
titles.each do |t|
|
|
||||||
t.unread_all username
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
|
||||||
load_progress_for_all_entries(username).sum +
|
|
||||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
|
||||||
entries.map { |e| e.pages }.sum +
|
|
||||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage(username)
|
|
||||||
deep_read_page_count(username) / deep_total_page_count
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_continue_reading_entry(username)
|
|
||||||
in_progress_entries = @entries.select do |e|
|
|
||||||
load_progress(username, e.title) > 0
|
|
||||||
end
|
|
||||||
return nil if in_progress_entries.empty?
|
|
||||||
|
|
||||||
latest_read_entry = in_progress_entries[-1]
|
|
||||||
if load_progress(username, latest_read_entry.title) ==
|
|
||||||
latest_read_entry.pages
|
|
||||||
next_entry latest_read_entry
|
|
||||||
else
|
|
||||||
latest_read_entry
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_progress_for_all_entries(username)
|
|
||||||
progress = {} of String => Int32
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
progress = info.progress[username]?
|
|
||||||
end
|
|
||||||
|
|
||||||
@entries.map do |e|
|
|
||||||
info_progress = 0
|
|
||||||
if progress && progress.has_key? e.title
|
|
||||||
info_progress = [progress[e.title], e.pages].min
|
|
||||||
end
|
|
||||||
info_progress
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_percentage_for_all_entries(username)
|
|
||||||
progress = load_progress_for_all_entries username
|
|
||||||
@entries.map_with_index do |e, i|
|
|
||||||
progress[i] / e.pages
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# === helper methods ===
|
|
||||||
|
|
||||||
# Gets the last read entry in the title. If the entry has been completed,
|
|
||||||
# returns the next entry. Returns nil when no entry has been read yet,
|
|
||||||
# or when all entries are completed
|
|
||||||
def get_last_read_entry(username) : Entry?
|
|
||||||
progress = {} of String => Int32
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
progress = info.progress[username]?
|
|
||||||
end
|
|
||||||
return if progress.nil?
|
|
||||||
|
|
||||||
last_read_entry = nil
|
|
||||||
|
|
||||||
@entries.reverse_each do |e|
|
|
||||||
if progress.has_key? e.title
|
|
||||||
last_read_entry = e
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if last_read_entry && last_read_entry.finished? username
|
|
||||||
last_read_entry = last_read_entry.next_entry
|
|
||||||
end
|
|
||||||
|
|
||||||
last_read_entry
|
|
||||||
end
|
|
||||||
|
|
||||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
|
||||||
def get_date_added_for_all_entries
|
|
||||||
da = {} of String => Time
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
da = info.date_added
|
|
||||||
end
|
|
||||||
|
|
||||||
@entries.each do |e|
|
|
||||||
next if da.has_key? e.title
|
|
||||||
da[e.title] = ctime e.zip_path
|
|
||||||
end
|
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.date_added = da
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
|
|
||||||
@entries.map { |e| da[e.title] }
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_entries_with_date_added
|
|
||||||
da_ary = get_date_added_for_all_entries
|
|
||||||
zip = @entries.map_with_index do |e, i|
|
|
||||||
{entry: e, date_added: da_ary[i]}
|
|
||||||
end
|
|
||||||
return zip if title_ids.empty?
|
|
||||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class TitleInfo
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
|
||||||
property progress = {} of String => Hash(String, Int32)
|
|
||||||
property display_name = ""
|
|
||||||
property entry_display_name = {} of String => String
|
|
||||||
property cover_url = ""
|
|
||||||
property entry_cover_url = {} of String => String
|
|
||||||
property last_read = {} of String => Hash(String, Time)
|
|
||||||
property date_added = {} of String => Time
|
|
||||||
|
|
||||||
@[JSON::Field(ignore: true)]
|
|
||||||
property dir : String = ""
|
|
||||||
|
|
||||||
@@mutex_hash = {} of String => Mutex
|
|
||||||
|
|
||||||
def self.new(dir, &)
|
|
||||||
if @@mutex_hash[dir]?
|
|
||||||
mutex = @@mutex_hash[dir]
|
|
||||||
else
|
|
||||||
mutex = Mutex.new
|
|
||||||
@@mutex_hash[dir] = mutex
|
|
||||||
end
|
|
||||||
mutex.synchronize do
|
|
||||||
instance = TitleInfo.allocate
|
|
||||||
json_path = File.join dir, "info.json"
|
|
||||||
if File.exists? json_path
|
|
||||||
instance = TitleInfo.from_json File.read json_path
|
|
||||||
end
|
|
||||||
instance.dir = dir
|
|
||||||
yield instance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
json_path = File.join @dir, "info.json"
|
|
||||||
File.write json_path, self.to_pretty_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Library
|
|
||||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
|
||||||
title_hash : Hash(String, Title)
|
|
||||||
|
|
||||||
def self.default : self
|
|
||||||
unless @@default
|
|
||||||
@@default = new
|
|
||||||
end
|
|
||||||
@@default.not_nil!
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
register_mime_types
|
|
||||||
|
|
||||||
@dir = Config.current.library_path
|
|
||||||
@scan_interval = Config.current.scan_interval
|
|
||||||
# explicitly initialize @titles to bypass the compiler check. it will
|
|
||||||
# be filled with actual Titles in the `scan` call below
|
|
||||||
@title_ids = [] of String
|
|
||||||
@title_hash = {} of String => Title
|
|
||||||
|
|
||||||
return scan if @scan_interval < 1
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
start = Time.local
|
|
||||||
scan
|
|
||||||
ms = (Time.local - start).total_milliseconds
|
|
||||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
|
||||||
sleep @scan_interval * 60
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def titles
|
|
||||||
@title_ids.map { |tid| self.get_title!(tid) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def deep_titles
|
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.object do
|
|
||||||
json.field "dir", @dir
|
|
||||||
json.field "titles" do
|
|
||||||
json.raw self.titles.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_title(tid)
|
|
||||||
@title_hash[tid]?
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_title!(tid)
|
|
||||||
@title_hash[tid]
|
|
||||||
end
|
|
||||||
|
|
||||||
def scan
|
|
||||||
unless Dir.exists? @dir
|
|
||||||
Logger.info "The library directory #{@dir} does not exist. " \
|
|
||||||
"Attempting to create it"
|
|
||||||
Dir.mkdir_p @dir
|
|
||||||
end
|
|
||||||
@title_ids.clear
|
|
||||||
|
|
||||||
storage = Storage.new auto_close: false
|
|
||||||
|
|
||||||
(Dir.entries @dir)
|
|
||||||
.select { |fn| !fn.starts_with? "." }
|
|
||||||
.map { |fn| File.join @dir, fn }
|
|
||||||
.select { |path| File.directory? path }
|
|
||||||
.map { |path| Title.new path, "", storage, self }
|
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
|
||||||
.sort { |a, b| a.title <=> b.title }
|
|
||||||
.each do |title|
|
|
||||||
@title_hash[title.id] = title
|
|
||||||
@title_ids << title.id
|
|
||||||
end
|
|
||||||
|
|
||||||
storage.bulk_insert_ids
|
|
||||||
storage.close
|
|
||||||
|
|
||||||
Logger.debug "Scan completed"
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_continue_reading_entries(username)
|
|
||||||
cr_entries = deep_titles
|
|
||||||
.map { |t| t.get_last_read_entry username }
|
|
||||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
|
||||||
.select(Entry)[0..11]
|
|
||||||
.map { |e|
|
|
||||||
# Get the last read time of the entry. If it hasn't been started, get
|
|
||||||
# the last read time of the previous entry
|
|
||||||
last_read = e.load_last_read username
|
|
||||||
pe = e.previous_entry
|
|
||||||
if last_read.nil? && pe
|
|
||||||
last_read = pe.load_last_read username
|
|
||||||
end
|
|
||||||
{
|
|
||||||
entry: e,
|
|
||||||
percentage: e.load_percentage(username),
|
|
||||||
last_read: last_read,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sort by by last_read, most recent first (nils at the end)
|
|
||||||
cr_entries.sort { |a, b|
|
|
||||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
|
||||||
next 1 if a[:last_read].nil?
|
|
||||||
next -1 if b[:last_read].nil?
|
|
||||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
alias RA = NamedTuple(
|
|
||||||
entry: Entry,
|
|
||||||
percentage: Float64,
|
|
||||||
grouped_count: Int32)
|
|
||||||
|
|
||||||
def get_recently_added_entries(username)
|
|
||||||
recently_added = [] of RA
|
|
||||||
last_date_added = nil
|
|
||||||
|
|
||||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
|
||||||
.select { |e| e[:date_added] > 1.month.ago }
|
|
||||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
|
||||||
.each do |e|
|
|
||||||
break if recently_added.size > 12
|
|
||||||
last = recently_added.last?
|
|
||||||
if last && e[:entry].title_id == last[:entry].title_id &&
|
|
||||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
|
||||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
|
||||||
last_hash = last.to_h
|
|
||||||
count = last_hash[:grouped_count].as(Int32)
|
|
||||||
last_hash[:grouped_count] = count + 1
|
|
||||||
# Setting the percentage to a negative value will hide the
|
|
||||||
# percentage badge on the card
|
|
||||||
last_hash[:percentage] = -1.0
|
|
||||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
|
||||||
else
|
|
||||||
last_date_added = e[:date_added]
|
|
||||||
recently_added << {
|
|
||||||
entry: e[:entry],
|
|
||||||
percentage: e[:entry].load_percentage(username),
|
|
||||||
grouped_count: 1,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
recently_added[0..11]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
require "image_size"
|
||||||
|
|
||||||
|
class Entry
|
||||||
|
property zip_path : String, book : Title, title : String,
|
||||||
|
size : String, pages : Int32, id : String, encoded_path : String,
|
||||||
|
encoded_title : String, mtime : Time, err_msg : String?
|
||||||
|
|
||||||
|
def initialize(@zip_path, @book, storage)
|
||||||
|
@encoded_path = URI.encode @zip_path
|
||||||
|
@title = File.basename @zip_path, File.extname @zip_path
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@size = (File.size @zip_path).humanize_bytes
|
||||||
|
id = storage.get_id @zip_path, false
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_id({
|
||||||
|
path: @zip_path,
|
||||||
|
id: id,
|
||||||
|
is_title: false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
@mtime = File.info(@zip_path).modification_time
|
||||||
|
|
||||||
|
unless File.readable? @zip_path
|
||||||
|
@err_msg = "File #{@zip_path} is not readable."
|
||||||
|
Logger.warn "#{@err_msg} Please make sure the " \
|
||||||
|
"file permission is configured correctly."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
archive_exception = validate_archive @zip_path
|
||||||
|
unless archive_exception.nil?
|
||||||
|
@err_msg = "Archive error: #{archive_exception}"
|
||||||
|
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
||||||
|
"Ignoring it. #{@err_msg}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
file = ArchiveFile.new @zip_path
|
||||||
|
@pages = file.entries.count do |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
end
|
||||||
|
file.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "title_id", @book.id
|
||||||
|
json.field "display_name", @book.display_name @title
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
@book.display_name @title
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||||
|
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_url = info.entry_cover_url[@title]?
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = File.join Config.current.base_url, info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
private def sorted_archive_entries
|
||||||
|
ArchiveFile.open @zip_path do |file|
|
||||||
|
entries = file.entries
|
||||||
|
.select { |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
}
|
||||||
|
.sort { |a, b|
|
||||||
|
compare_numerically a.filename, b.filename
|
||||||
|
}
|
||||||
|
yield file, entries
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_page(page_num)
|
||||||
|
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||||
|
img = nil
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
page = entries[page_num - 1]
|
||||||
|
data = file.read_entry page
|
||||||
|
if data
|
||||||
|
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||||
|
data.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_dimensions
|
||||||
|
sizes = [] of Hash(String, Int32)
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
entries.each_with_index do |e, i|
|
||||||
|
begin
|
||||||
|
data = file.read_entry(e).not_nil!
|
||||||
|
size = ImageSize.get data
|
||||||
|
sizes << {
|
||||||
|
"width" => size.width,
|
||||||
|
"height" => size.height,
|
||||||
|
}
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
||||||
|
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sizes
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_entry(username)
|
||||||
|
entries = @book.sorted_entries username
|
||||||
|
idx = entries.index self
|
||||||
|
return nil if idx.nil? || idx == entries.size - 1
|
||||||
|
entries[idx + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
def previous_entry
|
||||||
|
idx = @book.entries.index self
|
||||||
|
return nil if idx.nil? || idx == 0
|
||||||
|
@book.entries[idx - 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
def date_added
|
||||||
|
date_added = nil
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_da = info.date_added[@title]?
|
||||||
|
if info_da.nil?
|
||||||
|
date_added = info.date_added[@title] = ctime @zip_path
|
||||||
|
info.save
|
||||||
|
else
|
||||||
|
date_added = info_da
|
||||||
|
end
|
||||||
|
end
|
||||||
|
date_added.not_nil! # is it ok to set not_nil! here?
|
||||||
|
end
|
||||||
|
|
||||||
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
|
# instead of IDs in info.json
|
||||||
|
def save_progress(username, page)
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
if info.progress[username]?.nil?
|
||||||
|
info.progress[username] = {@title => page}
|
||||||
|
else
|
||||||
|
info.progress[username][@title] = page
|
||||||
|
end
|
||||||
|
# save last_read timestamp
|
||||||
|
if info.last_read[username]?.nil?
|
||||||
|
info.last_read[username] = {@title => Time.utc}
|
||||||
|
else
|
||||||
|
info.last_read[username][@title] = Time.utc
|
||||||
|
end
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_progress(username)
|
||||||
|
progress = 0
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
unless info.progress[username]?.nil? ||
|
||||||
|
info.progress[username][@title]?.nil?
|
||||||
|
progress = info.progress[username][@title]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
[progress, @pages].min
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_percentage(username)
|
||||||
|
page = load_progress username
|
||||||
|
page / @pages
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_last_read(username)
|
||||||
|
last_read = nil
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
unless info.last_read[username]?.nil? ||
|
||||||
|
info.last_read[username][@title]?.nil?
|
||||||
|
last_read = info.last_read[username][@title]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
last_read
|
||||||
|
end
|
||||||
|
|
||||||
|
def finished?(username)
|
||||||
|
load_progress(username) == @pages
|
||||||
|
end
|
||||||
|
|
||||||
|
def started?(username)
|
||||||
|
load_progress(username) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_thumbnail : Image?
|
||||||
|
return if @err_msg
|
||||||
|
|
||||||
|
img = read_page(1).not_nil!
|
||||||
|
begin
|
||||||
|
size = ImageSize.get img.data
|
||||||
|
if size.height > size.width
|
||||||
|
thumbnail = ImageSize.resize img.data, width: 200
|
||||||
|
else
|
||||||
|
thumbnail = ImageSize.resize img.data, height: 300
|
||||||
|
end
|
||||||
|
img.data = thumbnail
|
||||||
|
img.size = thumbnail.size
|
||||||
|
unless img.mime == "image/webp"
|
||||||
|
# image_size.cr resizes non-webp images to jpg
|
||||||
|
img.mime = "image/jpeg"
|
||||||
|
end
|
||||||
|
Storage.default.save_thumbnail @id, img
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
|
||||||
|
end
|
||||||
|
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_thumbnail : Image?
|
||||||
|
Storage.default.get_thumbnail @id
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
class Library
|
||||||
|
property dir : String, title_ids : Array(String),
|
||||||
|
title_hash : Hash(String, Title)
|
||||||
|
|
||||||
|
use_default
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
register_mime_types
|
||||||
|
|
||||||
|
@dir = Config.current.library_path
|
||||||
|
# explicitly initialize @titles to bypass the compiler check. it will
|
||||||
|
# be filled with actual Titles in the `scan` call below
|
||||||
|
@title_ids = [] of String
|
||||||
|
@title_hash = {} of String => Title
|
||||||
|
|
||||||
|
@entries_count = 0
|
||||||
|
@thumbnails_count = 0
|
||||||
|
|
||||||
|
scan_interval = Config.current.scan_interval_minutes
|
||||||
|
if scan_interval < 1
|
||||||
|
scan
|
||||||
|
else
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
start = Time.local
|
||||||
|
scan
|
||||||
|
ms = (Time.local - start).total_milliseconds
|
||||||
|
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||||
|
sleep scan_interval.minutes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
|
||||||
|
unless thumbnail_interval < 1
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
# Wait for scan to complete (in most cases)
|
||||||
|
sleep 1.minutes
|
||||||
|
generate_thumbnails
|
||||||
|
sleep thumbnail_interval.hours
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
db_interval = Config.current.db_optimization_interval_hours
|
||||||
|
unless db_interval < 1
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
Storage.default.optimize
|
||||||
|
sleep db_interval.hours
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def titles
|
||||||
|
@title_ids.map { |tid| self.get_title!(tid) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def sorted_titles(username, opt : SortOptions? = nil)
|
||||||
|
if opt.nil?
|
||||||
|
opt = SortOptions.from_info_json @dir, username
|
||||||
|
else
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.sort_by[username] = opt.to_tuple
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This is a hack to bypass a compiler bug
|
||||||
|
ary = titles
|
||||||
|
|
||||||
|
case opt.not_nil!.method
|
||||||
|
when .time_modified?
|
||||||
|
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
|
compare_numerically a.title, b.title }
|
||||||
|
when .progress?
|
||||||
|
ary.sort! do |a, b|
|
||||||
|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||||
|
compare_numerically a.title, b.title
|
||||||
|
end
|
||||||
|
else
|
||||||
|
unless opt.method.auto?
|
||||||
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
|
"Auto instead"
|
||||||
|
end
|
||||||
|
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||||
|
end
|
||||||
|
|
||||||
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_titles
|
||||||
|
titles + titles.map { |t| t.deep_titles }.flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
json.field "dir", @dir
|
||||||
|
json.field "titles" do
|
||||||
|
json.raw self.titles.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title(tid)
|
||||||
|
@title_hash[tid]?
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title!(tid)
|
||||||
|
@title_hash[tid]
|
||||||
|
end
|
||||||
|
|
||||||
|
def scan
|
||||||
|
unless Dir.exists? @dir
|
||||||
|
Logger.info "The library directory #{@dir} does not exist. " \
|
||||||
|
"Attempting to create it"
|
||||||
|
Dir.mkdir_p @dir
|
||||||
|
end
|
||||||
|
|
||||||
|
storage = Storage.new auto_close: false
|
||||||
|
|
||||||
|
(Dir.entries @dir)
|
||||||
|
.select { |fn| !fn.starts_with? "." }
|
||||||
|
.map { |fn| File.join @dir, fn }
|
||||||
|
.select { |path| File.directory? path }
|
||||||
|
.map { |path| Title.new path, "", storage, self }
|
||||||
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
|
.sort { |a, b| a.title <=> b.title }
|
||||||
|
.tap { |_| @title_ids.clear }
|
||||||
|
.each do |title|
|
||||||
|
@title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
end
|
||||||
|
|
||||||
|
storage.bulk_insert_ids
|
||||||
|
storage.close
|
||||||
|
|
||||||
|
Logger.debug "Scan completed"
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_continue_reading_entries(username)
|
||||||
|
cr_entries = deep_titles
|
||||||
|
.map { |t| t.get_last_read_entry username }
|
||||||
|
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||||
|
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
|
.map { |e|
|
||||||
|
# Get the last read time of the entry. If it hasn't been started, get
|
||||||
|
# the last read time of the previous entry
|
||||||
|
last_read = e.load_last_read username
|
||||||
|
pe = e.previous_entry
|
||||||
|
if last_read.nil? && pe
|
||||||
|
last_read = pe.load_last_read username
|
||||||
|
end
|
||||||
|
{
|
||||||
|
entry: e,
|
||||||
|
percentage: e.load_percentage(username),
|
||||||
|
last_read: last_read,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort by by last_read, most recent first (nils at the end)
|
||||||
|
cr_entries.sort { |a, b|
|
||||||
|
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||||
|
next 1 if a[:last_read].nil?
|
||||||
|
next -1 if b[:last_read].nil?
|
||||||
|
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
alias RA = NamedTuple(
|
||||||
|
entry: Entry,
|
||||||
|
percentage: Float64,
|
||||||
|
grouped_count: Int32)
|
||||||
|
|
||||||
|
def get_recently_added_entries(username)
|
||||||
|
recently_added = [] of RA
|
||||||
|
last_date_added = nil
|
||||||
|
|
||||||
|
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||||
|
.select { |e| e[:date_added] > 1.month.ago }
|
||||||
|
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||||
|
.each do |e|
|
||||||
|
break if recently_added.size > 12
|
||||||
|
last = recently_added.last?
|
||||||
|
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||||
|
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||||
|
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||||
|
last_hash = last.to_h
|
||||||
|
count = last_hash[:grouped_count].as(Int32)
|
||||||
|
last_hash[:grouped_count] = count + 1
|
||||||
|
# Setting the percentage to a negative value will hide the
|
||||||
|
# percentage badge on the card
|
||||||
|
last_hash[:percentage] = -1.0
|
||||||
|
recently_added[recently_added.size - 1] = RA.from last_hash
|
||||||
|
else
|
||||||
|
last_date_added = e[:date_added]
|
||||||
|
recently_added << {
|
||||||
|
entry: e[:entry],
|
||||||
|
percentage: e[:entry].load_percentage(username),
|
||||||
|
grouped_count: 1,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_start_reading_titles(username)
|
||||||
|
# Here we are not using `deep_titles` as it may cause unexpected behaviors
|
||||||
|
# For example, consider the following nested titles:
|
||||||
|
# - One Puch Man
|
||||||
|
# - Vol. 1
|
||||||
|
# - Vol. 2
|
||||||
|
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||||
|
# when the user hasn't started `Vol. 1` yet
|
||||||
|
titles
|
||||||
|
.select { |t| t.load_percentage(username) == 0 }
|
||||||
|
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||||
|
.shuffle
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_generation_progress
|
||||||
|
return 0 if @entries_count == 0
|
||||||
|
@thumbnails_count / @entries_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_thumbnails
|
||||||
|
if @thumbnails_count > 0
|
||||||
|
Logger.debug "Thumbnail generation in progress"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info "Starting thumbnail generation"
|
||||||
|
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
||||||
|
@entries_count = entries.size
|
||||||
|
@thumbnails_count = 0
|
||||||
|
|
||||||
|
# Report generation progress regularly
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
unless @thumbnails_count == 0
|
||||||
|
Logger.debug "Thumbnail generation progress: " \
|
||||||
|
"#{(thumbnail_generation_progress * 100).round 1}%"
|
||||||
|
end
|
||||||
|
# Generation is completed. We reset the count to 0 to allow subsequent
|
||||||
|
# calls to the function, and break from the loop to stop the progress
|
||||||
|
# report fiber
|
||||||
|
if thumbnail_generation_progress.to_i == 1
|
||||||
|
@thumbnails_count = 0
|
||||||
|
break
|
||||||
|
end
|
||||||
|
sleep 10.seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
entries.each do |e|
|
||||||
|
unless e.get_thumbnail
|
||||||
|
e.generate_thumbnail
|
||||||
|
# Sleep after each generation to minimize the impact on disk IO
|
||||||
|
# and CPU
|
||||||
|
sleep 0.5.seconds
|
||||||
|
end
|
||||||
|
@thumbnails_count += 1
|
||||||
|
end
|
||||||
|
Logger.info "Thumbnail generation finished"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
require "../archive"
|
||||||
|
|
||||||
|
class Title
|
||||||
|
property dir : String, parent_id : String, title_ids : Array(String),
|
||||||
|
entries : Array(Entry), title : String, id : String,
|
||||||
|
encoded_title : String, mtime : Time
|
||||||
|
|
||||||
|
def initialize(@dir : String, @parent_id, storage,
|
||||||
|
@library : Library)
|
||||||
|
id = storage.get_id @dir, true
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_id({
|
||||||
|
path: @dir,
|
||||||
|
id: id,
|
||||||
|
is_title: true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
@title = File.basename dir
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@title_ids = [] of String
|
||||||
|
@entries = [] of Entry
|
||||||
|
@mtime = File.info(dir).modification_time
|
||||||
|
|
||||||
|
Dir.entries(dir).each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dir, fn
|
||||||
|
if File.directory? path
|
||||||
|
title = Title.new path, @id, storage, library
|
||||||
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
|
@library.title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
next
|
||||||
|
end
|
||||||
|
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||||
|
entry = Entry.new path, self, storage
|
||||||
|
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mtimes = [@mtime]
|
||||||
|
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||||
|
mtimes += @entries.map { |e| e.mtime }
|
||||||
|
@mtime = mtimes.max
|
||||||
|
|
||||||
|
@title_ids.sort! do |a, b|
|
||||||
|
compare_numerically @library.title_hash[a].title,
|
||||||
|
@library.title_hash[b].title
|
||||||
|
end
|
||||||
|
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||||
|
@entries.sort! do |a, b|
|
||||||
|
sorter.compare a.title, b.title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(json : JSON::Builder)
|
||||||
|
json.object do
|
||||||
|
{% for str in ["dir", "title", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "display_name", display_name
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
json.field "titles" do
|
||||||
|
json.raw self.titles.to_json
|
||||||
|
end
|
||||||
|
json.field "entries" do
|
||||||
|
json.raw @entries.to_json
|
||||||
|
end
|
||||||
|
json.field "parents" do
|
||||||
|
json.array do
|
||||||
|
self.parents.each do |title|
|
||||||
|
json.object do
|
||||||
|
json.field "title", title.title
|
||||||
|
json.field "id", title.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def titles
|
||||||
|
@title_ids.map { |tid| @library.get_title! tid }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get all entries, including entries in nested titles
|
||||||
|
def deep_entries
|
||||||
|
return @entries if title_ids.empty?
|
||||||
|
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_titles
|
||||||
|
return [] of Title if titles.empty?
|
||||||
|
titles + titles.map { |t| t.deep_titles }.flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
def parents
|
||||||
|
ary = [] of Title
|
||||||
|
tid = @parent_id
|
||||||
|
while !tid.empty?
|
||||||
|
title = @library.get_title! tid
|
||||||
|
ary << title
|
||||||
|
tid = title.parent_id
|
||||||
|
end
|
||||||
|
ary.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
def size
|
||||||
|
@entries.size + @title_ids.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entry(eid)
|
||||||
|
@entries.find { |e| e.id == eid }
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
dn = @title
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.display_name
|
||||||
|
dn = info_dn unless info_dn.empty?
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name(entry_name)
|
||||||
|
dn = entry_name
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.entry_display_name[entry_name]?
|
||||||
|
unless info_dn.nil? || info_dn.empty?
|
||||||
|
dn = info_dn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.display_name = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(entry_name : String, dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.entry_display_name[entry_name] = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
url = "#{Config.current.base_url}img/icon.png"
|
||||||
|
readable_entries = @entries.select &.err_msg.nil?
|
||||||
|
if readable_entries.size > 0
|
||||||
|
url = readable_entries[0].cover_url
|
||||||
|
end
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_url = info.cover_url
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = File.join Config.current.base_url, info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cover_url(url : String)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.cover_url = url
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cover_url(entry_name : String, url : String)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.entry_cover_url[entry_name] = url
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the reading progress of all entries and nested libraries to 100%
|
||||||
|
def read_all(username)
|
||||||
|
@entries.each do |e|
|
||||||
|
e.save_progress username, e.pages
|
||||||
|
end
|
||||||
|
titles.each do |t|
|
||||||
|
t.read_all username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the reading progress of all entries and nested libraries to 0%
|
||||||
|
def unread_all(username)
|
||||||
|
@entries.each do |e|
|
||||||
|
e.save_progress username, 0
|
||||||
|
end
|
||||||
|
titles.each do |t|
|
||||||
|
t.unread_all username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_read_page_count(username) : Int32
|
||||||
|
load_progress_for_all_entries(username).sum +
|
||||||
|
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_total_page_count : Int32
|
||||||
|
entries.map { |e| e.pages }.sum +
|
||||||
|
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_percentage(username)
|
||||||
|
deep_read_page_count(username) / deep_total_page_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
|
||||||
|
unsorted = false)
|
||||||
|
progress = {} of String => Int32
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
progress = info.progress[username]?
|
||||||
|
end
|
||||||
|
|
||||||
|
if unsorted
|
||||||
|
ary = @entries
|
||||||
|
else
|
||||||
|
ary = sorted_entries username, opt
|
||||||
|
end
|
||||||
|
|
||||||
|
ary.map do |e|
|
||||||
|
info_progress = 0
|
||||||
|
if progress && progress.has_key? e.title
|
||||||
|
info_progress = [progress[e.title], e.pages].min
|
||||||
|
end
|
||||||
|
info_progress
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
|
||||||
|
unsorted = false)
|
||||||
|
if unsorted
|
||||||
|
ary = @entries
|
||||||
|
else
|
||||||
|
ary = sorted_entries username, opt
|
||||||
|
end
|
||||||
|
|
||||||
|
progress = load_progress_for_all_entries username, opt, unsorted
|
||||||
|
ary.map_with_index do |e, i|
|
||||||
|
progress[i] / e.pages
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the sorted entries array
|
||||||
|
#
|
||||||
|
# When `opt` is nil, it uses the preferred sorting options in info.json, or
|
||||||
|
# use the default (auto, ascending)
|
||||||
|
# When `opt` is not nil, it saves the options to info.json
|
||||||
|
def sorted_entries(username, opt : SortOptions? = nil)
|
||||||
|
if opt.nil?
|
||||||
|
opt = SortOptions.from_info_json @dir, username
|
||||||
|
else
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.sort_by[username] = opt.to_tuple
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
case opt.not_nil!.method
|
||||||
|
when .title?
|
||||||
|
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
||||||
|
when .time_modified?
|
||||||
|
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
|
compare_numerically a.title, b.title }
|
||||||
|
when .time_added?
|
||||||
|
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||||
|
compare_numerically a.title, b.title }
|
||||||
|
when .progress?
|
||||||
|
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||||
|
ary = @entries.zip(percentage_ary)
|
||||||
|
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||||
|
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||||
|
.map { |tp| tp[0] }
|
||||||
|
else
|
||||||
|
unless opt.method.auto?
|
||||||
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
|
"Auto instead"
|
||||||
|
end
|
||||||
|
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||||
|
ary = @entries.sort do |a, b|
|
||||||
|
sorter.compare(a.title, b.title).or \
|
||||||
|
compare_numerically a.title, b.title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
# === helper methods ===
|
||||||
|
|
||||||
|
# Gets the last read entry in the title. If the entry has been completed,
|
||||||
|
# returns the next entry. Returns nil when no entry has been read yet,
|
||||||
|
# or when all entries are completed
|
||||||
|
def get_last_read_entry(username) : Entry?
|
||||||
|
progress = {} of String => Int32
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
progress = info.progress[username]?
|
||||||
|
end
|
||||||
|
return if progress.nil?
|
||||||
|
|
||||||
|
last_read_entry = nil
|
||||||
|
|
||||||
|
sorted_entries(username).reverse_each do |e|
|
||||||
|
if progress.has_key?(e.title) && progress[e.title] > 0
|
||||||
|
last_read_entry = e
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if last_read_entry && last_read_entry.finished? username
|
||||||
|
last_read_entry = last_read_entry.next_entry username
|
||||||
|
end
|
||||||
|
|
||||||
|
last_read_entry
|
||||||
|
end
|
||||||
|
|
||||||
|
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
||||||
|
def get_date_added_for_all_entries
|
||||||
|
da = {} of String => Time
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
da = info.date_added
|
||||||
|
end
|
||||||
|
|
||||||
|
@entries.each do |e|
|
||||||
|
next if da.has_key? e.title
|
||||||
|
da[e.title] = ctime e.zip_path
|
||||||
|
end
|
||||||
|
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.date_added = da
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
|
||||||
|
@entries.map { |e| da[e.title] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def deep_entries_with_date_added
|
||||||
|
da_ary = get_date_added_for_all_entries
|
||||||
|
zip = @entries.map_with_index do |e, i|
|
||||||
|
{entry: e, date_added: da_ary[i]}
|
||||||
|
end
|
||||||
|
return zip if title_ids.empty?
|
||||||
|
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
|
selected_entries = ids
|
||||||
|
.map { |id|
|
||||||
|
@entries.find { |e| e.id == id }
|
||||||
|
}
|
||||||
|
.select(Entry)
|
||||||
|
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
selected_entries.each do |e|
|
||||||
|
page = action == "read" ? e.pages : 0
|
||||||
|
if info.progress[username]?.nil?
|
||||||
|
info.progress[username] = {e.title => page}
|
||||||
|
else
|
||||||
|
info.progress[username][e.title] = page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||||
|
|
||||||
|
enum SortMethod
|
||||||
|
Auto
|
||||||
|
Title
|
||||||
|
Progress
|
||||||
|
TimeModified
|
||||||
|
TimeAdded
|
||||||
|
end
|
||||||
|
|
||||||
|
class SortOptions
|
||||||
|
property method : SortMethod, ascend : Bool
|
||||||
|
|
||||||
|
def initialize(in_method : String? = nil, @ascend = true)
|
||||||
|
@method = SortMethod::Auto
|
||||||
|
SortMethod.each do |m, _|
|
||||||
|
if in_method && m.to_s.underscore == in_method
|
||||||
|
@method = m
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(in_method : SortMethod? = nil, @ascend = true)
|
||||||
|
if in_method
|
||||||
|
@method = in_method
|
||||||
|
else
|
||||||
|
@method = SortMethod::Auto
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_tuple(tp : Tuple(String, Bool))
|
||||||
|
method, ascend = tp
|
||||||
|
self.new method, ascend
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_info_json(dir, username)
|
||||||
|
opt = SortOptions.new
|
||||||
|
TitleInfo.new dir do |info|
|
||||||
|
if info.sort_by.has_key? username
|
||||||
|
opt = SortOptions.from_tuple info.sort_by[username]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
opt
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_tuple
|
||||||
|
{@method.to_s.underscore, ascend}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Image
|
||||||
|
property data : Bytes
|
||||||
|
property mime : String
|
||||||
|
property filename : String
|
||||||
|
property size : Int32
|
||||||
|
|
||||||
|
def initialize(@data, @mime, @filename, @size)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_db(res : DB::ResultSet)
|
||||||
|
img = Image.allocate
|
||||||
|
res.read String
|
||||||
|
img.data = res.read Bytes
|
||||||
|
img.filename = res.read String
|
||||||
|
img.mime = res.read String
|
||||||
|
img.size = res.read Int32
|
||||||
|
img
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TitleInfo
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||||
|
property progress = {} of String => Hash(String, Int32)
|
||||||
|
property display_name = ""
|
||||||
|
property entry_display_name = {} of String => String
|
||||||
|
property cover_url = ""
|
||||||
|
property entry_cover_url = {} of String => String
|
||||||
|
property last_read = {} of String => Hash(String, Time)
|
||||||
|
property date_added = {} of String => Time
|
||||||
|
property sort_by = {} of String => Tuple(String, Bool)
|
||||||
|
|
||||||
|
@[JSON::Field(ignore: true)]
|
||||||
|
property dir : String = ""
|
||||||
|
|
||||||
|
@@mutex_hash = {} of String => Mutex
|
||||||
|
|
||||||
|
def self.new(dir, &)
|
||||||
|
if @@mutex_hash[dir]?
|
||||||
|
mutex = @@mutex_hash[dir]
|
||||||
|
else
|
||||||
|
mutex = Mutex.new
|
||||||
|
@@mutex_hash[dir] = mutex
|
||||||
|
end
|
||||||
|
mutex.synchronize do
|
||||||
|
instance = TitleInfo.allocate
|
||||||
|
json_path = File.join dir, "info.json"
|
||||||
|
if File.exists? json_path
|
||||||
|
instance = TitleInfo.from_json File.read json_path
|
||||||
|
end
|
||||||
|
instance.dir = dir
|
||||||
|
yield instance
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def save
|
||||||
|
json_path = File.join @dir, "info.json"
|
||||||
|
File.write json_path, self.to_pretty_json
|
||||||
|
end
|
||||||
|
end
|
||||||
+7
-10
@@ -8,12 +8,7 @@ class Logger
|
|||||||
|
|
||||||
@@severity : Log::Severity = :info
|
@@severity : Log::Severity = :info
|
||||||
|
|
||||||
def self.default : self
|
use_default
|
||||||
unless @@default
|
|
||||||
@@default = new
|
|
||||||
end
|
|
||||||
@@default.not_nil!
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
level = Config.current.log_level
|
level = Config.current.log_level
|
||||||
@@ -31,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
|
||||||
@@ -50,12 +45,14 @@ class Logger
|
|||||||
io << entry.message
|
io << entry. | ||||||