mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 952aa0c6ca | |||
| bd81c2e005 | |||
| b471ed2fa0 | |||
| 7507ab64ad | |||
| e4587d36bc | |||
| 7d6d3640ad | |||
| 3071d44e32 | |||
| 7a09c9006a | |||
| 959560c7a7 | |||
| ff679b30d8 | |||
| f7a360c2d8 | |||
| 1065b430e3 | |||
| 5abf7032a5 | |||
| 44336c546a | |||
| a4c6e6611c | |||
| 0b457a2797 | |||
| 653751bede | |||
| a02bf4a81e | |||
| 5271d12f4c | |||
| c2e2f0b9b3 | |||
| 72d319902e | |||
| bbd0fd68cb | |||
| 0fb1e1598d | |||
| 4645582f5d | |||
| ac9c51dd33 | |||
| f51d27860a | |||
| 4a7439a1ea | |||
| 00e19399d7 | |||
| cb723acef7 | |||
| 794bed12bd | |||
| bae8220e75 | |||
| 0cc5e1626b | |||
| da0ca665a6 | |||
| a91cf21aa9 | |||
| 39b2636711 | |||
| 2618d8412b | |||
| 445ebdf357 | |||
| 60134dc364 | |||
| aa70752244 | |||
| 0f39535097 | |||
| e086bec9da | |||
| dcdcf29114 | |||
| c5c73ddff3 | |||
| f18ee4284f | |||
| 0fbc11386e | |||
| a68282b4bf | |||
| e64908ad06 | |||
| af0913df64 | |||
| 5685dd1cc5 | |||
| af2fd2a66a | |||
| db2a51a26b | |||
| cf930418cb | |||
| 911848ad11 | |||
| 93f745aecb | |||
| 981a1f0226 | |||
| 8188456788 | |||
| 1eace2c64c | |||
| c6ee5409f8 | |||
| b05ed57762 | |||
| 0f1d1099f6 | |||
| 40a24f4247 | |||
| a6862e86d4 | |||
| bfc1b697bd | |||
| 276f62cb76 | |||
| 45a81ad5f6 | |||
| ce88acb9e5 | |||
| bd34b803f1 | |||
| 2559f65f35 | |||
| 93c21ea659 | |||
| 85ad38c321 | |||
| b6a204f5bd | |||
| f7b8e2d852 | |||
| 946017c8bd | |||
| ec5256dabd | |||
| 4e707076a1 | |||
| 66a3cc268b | |||
| 96949905b9 | |||
| 30c0199039 | |||
| 7a7cb78f82 | |||
| 8931ba8c43 | |||
| d50981c151 | |||
| df4deb1415 | |||
| aa5e999ed4 | |||
| 84d4b0c529 | |||
| d3e5691478 | |||
| 1000b02ae0 | |||
| 1f795889a9 | |||
| d33b45233a | |||
| 4f6df5b9a3 | |||
| 341b586cb3 | |||
| 9dcc9665ce | |||
| 1cd90926df | |||
| ac1ff61e6d | |||
| 6ea41f79e9 | |||
| dad02a2a30 | |||
| 280490fb36 | |||
| 455315a362 | |||
| df51406638 | |||
| 531d42ef18 |
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"projectName": "Mango",
|
||||||
|
"projectOwner": "hkalexling",
|
||||||
|
"repoType": "github",
|
||||||
|
"repoHost": "https://github.com",
|
||||||
|
"files": [
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"imageSize": 100,
|
||||||
|
"commit": false,
|
||||||
|
"commitConvention": "none",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"login": "hkalexling",
|
||||||
|
"name": "Alex Ling",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
|
||||||
|
"profile": "https://github.com/hkalexling/",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc",
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jaredlt",
|
||||||
|
"name": "jaredlt",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
|
||||||
|
"profile": "https://github.com/jaredlt",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"ideas",
|
||||||
|
"design"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "shincurry",
|
||||||
|
"name": "ココロ",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
|
||||||
|
"profile": "https://windisco.com/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "noirscape",
|
||||||
|
"name": "Valentijn",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
|
||||||
|
"profile": "https://catgirlsin.space/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "flying-sausages",
|
||||||
|
"name": "flying-sausages",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
|
||||||
|
"profile": "https://github.com/flying-sausages",
|
||||||
|
"contributions": [
|
||||||
|
"doc",
|
||||||
|
"ideas"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "XavierSchiller",
|
||||||
|
"name": "Xavier",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
|
||||||
|
"profile": "https://github.com/XavierSchiller",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "WROIATE",
|
||||||
|
"name": "Jarao",
|
||||||
|
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
|
||||||
|
"profile": "https://github.com/WROIATE",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Leeingnyo",
|
||||||
|
"name": "이인용",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
|
||||||
|
"profile": "https://github.com/Leeingnyo",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "h45h74x",
|
||||||
|
"name": "Simon",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
|
||||||
|
"profile": "http://h45h74x.eu.org",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contributorsPerLine": 7,
|
||||||
|
"skipCi": true
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ mango
|
|||||||
public/css/uikit.css
|
public/css/uikit.css
|
||||||
public/img/*.svg
|
public/img/*.svg
|
||||||
public/js/*.min.js
|
public/js/*.min.js
|
||||||
|
public/css/*.css
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM crystallang/crystal:0.34.0-alpine AS builder
|
FROM crystallang/crystal:0.35.1-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ 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 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.34.0 && make deps && cd ..
|
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/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/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 ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ 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 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.34.0 && make deps && cd ..
|
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/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/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 ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|||||||
@@ -52,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.16.0
|
Mango - Manga Server and Web Reader. Version 0.19.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -87,18 +87,22 @@ log_level: info
|
|||||||
upload_path: ~/mango/uploads
|
upload_path: ~/mango/uploads
|
||||||
plugin_path: ~/mango/plugins
|
plugin_path: ~/mango/plugins
|
||||||
download_timeout_seconds: 30
|
download_timeout_seconds: 30
|
||||||
|
page_margin: 30
|
||||||
|
disable_login: false
|
||||||
|
default_username: ""
|
||||||
mangadex:
|
mangadex:
|
||||||
base_url: https://mangadex.org
|
base_url: https://mangadex.org
|
||||||
api_url: https://mangadex.org/api
|
api_url: https://mangadex.org/api
|
||||||
download_wait_seconds: 5
|
download_wait_seconds: 5
|
||||||
download_retries: 4
|
download_retries: 4
|
||||||
download_queue_db_path: /home/alex_ling/mango/queue.db
|
download_queue_db_path: ~/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`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `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
|
||||||
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
@@ -153,5 +157,26 @@ Mobile UI:
|
|||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
|
||||||
|
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
[](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)
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
class ForeignKeys < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add foreign key to tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
|
||||||
|
-- add foreign key to thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove foreign key from thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove foreign key from tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateIds < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
is_title INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE ids;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateTags < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE tags;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateThumbnails < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE thumbnails;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class CreateTitles < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- create titles
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- migrate data from ids to titles
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT id, path, null
|
||||||
|
FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
DELETE FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
-- remove the is_title column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- insert the is_title column
|
||||||
|
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- migrate data from titles to ids
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, 1
|
||||||
|
FROM titles;
|
||||||
|
|
||||||
|
-- remove titles
|
||||||
|
DROP TABLE titles;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateUsers < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
token TEXT,
|
||||||
|
admin INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE users;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.11.5",
|
"@babel/preset-env": "^7.11.5",
|
||||||
|
"all-contributors-cli": "^6.19.0",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"gulp-babel": "^8.0.0",
|
"gulp-babel": "^8.0.0",
|
||||||
"gulp-babel-minify": "^0.5.1",
|
"gulp-babel-minify": "^0.5.1",
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
.uk-alert-close {
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-media-top {
|
|
||||||
width: 100%;
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
|
||||||
.uk-card-media-top {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-media-top>img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-title {
|
|
||||||
max-height: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acard:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-list li:not(.nopointer) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scan-status {
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-bg {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-word {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-logo>img {
|
|
||||||
height: 90px;
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selectable .ui-selecting {
|
|
||||||
background: #EEE6B9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selectable .ui-selected {
|
|
||||||
background: #F4E487;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light #selectable .ui-selecting {
|
|
||||||
background: #5E5731;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light #selectable .ui-selected {
|
|
||||||
background: #9D9252;
|
|
||||||
}
|
|
||||||
|
|
||||||
td>.uk-dropdown {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal .uk-grid>div {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal #cover {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal #cover-upload {
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal .uk-modal-body .uk-inline {
|
|
||||||
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,124 @@
|
|||||||
|
// Item cards
|
||||||
|
.item .uk-card {
|
||||||
|
cursor: pointer;
|
||||||
|
.uk-card-media-top {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
&.grayscale {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uk-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
.uk-card-title {
|
||||||
|
max-height: 3em;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jQuery selectable
|
||||||
|
#selectable {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #EEE6B9;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #F4E487;
|
||||||
|
}
|
||||||
|
.uk-light & {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #5E5731;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #9D9252;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit modal
|
||||||
|
#edit-modal {
|
||||||
|
.uk-grid > div {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
#cover {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
#cover-upload {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.uk-modal-body .uk-inline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
.uk-light {
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-modal-header,
|
||||||
|
.uk-modal-body,
|
||||||
|
.uk-modal-footer {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-dropdown {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.uk-nav-header,
|
||||||
|
.uk-description-list > dt {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpine magic
|
||||||
|
[x-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch select bar on title page
|
||||||
|
#select-bar-controls {
|
||||||
|
a {
|
||||||
|
transform: scale(1.5, 1.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totop button
|
||||||
|
#totop-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100vh;
|
||||||
|
right: 2em;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
position: fixed;
|
||||||
|
position: sticky;
|
||||||
|
top: calc(100vh - 5em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
.uk-alert-close {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.break-word {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.uk-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@light-gray: #e5e5e5;
|
||||||
|
@gray: #666666;
|
||||||
|
@black: #141414;
|
||||||
|
@blue: rgb(30, 135, 240);
|
||||||
|
@white1: rgba(255, 255, 255, .1);
|
||||||
|
@white2: rgba(255, 255, 255, .2);
|
||||||
|
@white7: rgba(255, 255, 255, .7);
|
||||||
|
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @light-gray;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: @blue;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
.select2-results__option--highlighted.select2-results__option--selectable {
|
||||||
|
background-color: @blue;
|
||||||
|
}
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @light-gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light {
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection {
|
||||||
|
background-color: @white1;
|
||||||
|
}
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @white2;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: white;
|
||||||
|
color: @gray;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.select2-search__field {
|
||||||
|
color: @white7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
background-color: @black;
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @white2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-82
@@ -1,90 +1,55 @@
|
|||||||
$(() => {
|
const component = () => {
|
||||||
|
return {
|
||||||
|
progress: 1.0,
|
||||||
|
generating: false,
|
||||||
|
scanning: false,
|
||||||
|
scanTitles: 0,
|
||||||
|
scanMs: -1,
|
||||||
|
themeSetting: '',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.getProgress();
|
||||||
|
setInterval(() => {
|
||||||
|
this.getProgress();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
const setting = loadThemeSetting();
|
const setting = loadThemeSetting();
|
||||||
$('#theme-select').val(capitalize(setting));
|
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
|
||||||
$('#theme-select').change((e) => {
|
},
|
||||||
const newSetting = $(e.currentTarget).val().toLowerCase();
|
themeChanged(event) {
|
||||||
|
const newSetting = $(event.currentTarget).val().toLowerCase();
|
||||||
saveThemeSetting(newSetting);
|
saveThemeSetting(newSetting);
|
||||||
setTheme();
|
setTheme();
|
||||||
});
|
},
|
||||||
|
scan() {
|
||||||
getProgress();
|
if (this.scanning) return;
|
||||||
setInterval(getProgress, 5000);
|
this.scanning = true;
|
||||||
});
|
this.scanMs = -1;
|
||||||
|
this.scanTitles = 0;
|
||||||
/**
|
|
||||||
* 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`)
|
$.post(`${base_url}api/admin/scan`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setProp('scanMs', data.milliseconds);
|
this.scanMs = data.milliseconds;
|
||||||
setProp('scanTitles', data.titles);
|
this.scanTitles = data.titles;
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
setProp('scanning', false);
|
this.scanning = false;
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
generateThumbnails() {
|
||||||
|
if (this.generating) return;
|
||||||
|
this.generating = true;
|
||||||
|
this.progress = 0.0;
|
||||||
|
$.post(`${base_url}api/admin/generate_thumbnails`)
|
||||||
|
.then(() => {
|
||||||
|
this.getProgress()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getProgress() {
|
||||||
|
$.get(`${base_url}api/admin/thumbnail_progress`)
|
||||||
|
.then(data => {
|
||||||
|
this.progress = data.progress;
|
||||||
|
this.generating = data.progress > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* --- Alpine helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an alpine.js property
|
||||||
|
*
|
||||||
|
* @function setProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {*} prop - The data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
*/
|
||||||
|
const setProp = (key, prop, selector = '#root') => {
|
||||||
|
$(selector).get(0).__x.$data[key] = prop;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an alpine.js property
|
||||||
|
*
|
||||||
|
* @function getProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
* @return {*} The data property
|
||||||
|
*/
|
||||||
|
const getProp = (key, selector = '#root') => {
|
||||||
|
return $(selector).get(0).__x.$data[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --- Theme related functions
|
||||||
|
* Note: In the comments below we treat "theme" and "theme setting"
|
||||||
|
* differently. A theme can have only two values, either "dark" or
|
||||||
|
* "light", while a theme setting can have the third value "system".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the system setting prefers dark theme.
|
||||||
|
* from https://flaviocopes.com/javascript-detect-dark-mode/
|
||||||
|
*
|
||||||
|
* @function preferDarkMode
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const preferDarkMode = () => {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given string represents a valid theme setting
|
||||||
|
*
|
||||||
|
* @function validThemeSetting
|
||||||
|
* @param {string} theme - The string representing the theme setting
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const validThemeSetting = (theme) => {
|
||||||
|
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load theme setting from local storage, or use 'light'
|
||||||
|
*
|
||||||
|
* @function loadThemeSetting
|
||||||
|
* @return {string} A theme setting ('dark', 'light', or 'system')
|
||||||
|
*/
|
||||||
|
const loadThemeSetting = () => {
|
||||||
|
let str = localStorage.getItem('theme');
|
||||||
|
if (!str || !validThemeSetting(str)) str = 'system';
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current theme (not theme setting)
|
||||||
|
*
|
||||||
|
* @function loadTheme
|
||||||
|
* @return {string} The current theme to use ('dark' or 'light')
|
||||||
|
*/
|
||||||
|
const loadTheme = () => {
|
||||||
|
let setting = loadThemeSetting();
|
||||||
|
if (setting === 'system') {
|
||||||
|
setting = preferDarkMode() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a theme setting
|
||||||
|
*
|
||||||
|
* @function saveThemeSetting
|
||||||
|
* @param {string} setting - A theme setting
|
||||||
|
*/
|
||||||
|
const saveThemeSetting = setting => {
|
||||||
|
if (!validThemeSetting(setting)) setting = 'system';
|
||||||
|
localStorage.setItem('theme', setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the current theme. When the current theme setting is 'system', it
|
||||||
|
* will be changed to either 'light' or 'dark'
|
||||||
|
*
|
||||||
|
* @function toggleTheme
|
||||||
|
*/
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = loadTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
saveThemeSetting(newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a theme, or load a theme and then apply it
|
||||||
|
*
|
||||||
|
* @function setTheme
|
||||||
|
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
|
||||||
|
* `loadTheme` to get a theme and apply it.
|
||||||
|
*/
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (!theme) theme = loadTheme();
|
||||||
|
if (theme === 'dark') {
|
||||||
|
$('html').css('background', 'rgb(20, 20, 20)');
|
||||||
|
$('body').addClass('uk-light');
|
||||||
|
$('.uk-card').addClass('uk-card-secondary');
|
||||||
|
$('.uk-card').removeClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').addClass('dark');
|
||||||
|
} else {
|
||||||
|
$('html').css('background', '');
|
||||||
|
$('body').removeClass('uk-light');
|
||||||
|
$('.uk-card').removeClass('uk-card-secondary');
|
||||||
|
$('.uk-card').addClass('uk-card-default');
|
||||||
|
$('.ui-widget-content').removeClass('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// do it before document is ready to prevent the initial flash of white on
|
||||||
|
// most pages
|
||||||
|
setTheme();
|
||||||
|
$(() => {
|
||||||
|
// hack for the reader page
|
||||||
|
setTheme();
|
||||||
|
|
||||||
|
// on system dark mode setting change
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', event => {
|
||||||
|
if (loadThemeSetting() === 'system')
|
||||||
|
setTheme(event.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+98
-127
@@ -1,94 +1,37 @@
|
|||||||
$(() => {
|
const component = () => {
|
||||||
$('input.uk-checkbox').each((i, e) => {
|
return {
|
||||||
$(e).change(() => {
|
jobs: [],
|
||||||
loadConfig();
|
paused: undefined,
|
||||||
});
|
loading: false,
|
||||||
});
|
toggling: false,
|
||||||
loadConfig();
|
ws: undefined,
|
||||||
load();
|
|
||||||
|
|
||||||
const intervalMS = 5000;
|
wsConnect(secure = true) {
|
||||||
setTimeout(() => {
|
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
|
||||||
setInterval(() => {
|
console.log(`Connecting to ${url}`);
|
||||||
if (globalConfig.autoRefresh !== true) return;
|
this.ws = new WebSocket(url);
|
||||||
load();
|
this.ws.onmessage = event => {
|
||||||
}, intervalMS);
|
const data = JSON.parse(event.data);
|
||||||
}, intervalMS);
|
this.jobs = data.jobs;
|
||||||
});
|
this.paused = data.paused;
|
||||||
var globalConfig = {};
|
};
|
||||||
var loading = false;
|
this.ws.onclose = () => {
|
||||||
|
if (this.ws.failed)
|
||||||
const loadConfig = () => {
|
return this.wsConnect(false);
|
||||||
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
alert('danger', 'Socket connection closed');
|
||||||
};
|
};
|
||||||
const remove = (id) => {
|
this.ws.onerror = () => {
|
||||||
var url = base_url + 'api/admin/mangadex/queue/delete';
|
if (secure)
|
||||||
if (id !== undefined)
|
return this.ws.failed = true;
|
||||||
url += '?' + $.param({
|
alert('danger', 'Socket connection failed');
|
||||||
id: id
|
};
|
||||||
});
|
},
|
||||||
console.log(url);
|
init() {
|
||||||
$.ajax({
|
this.wsConnect();
|
||||||
type: 'POST',
|
this.load();
|
||||||
url: url,
|
},
|
||||||
dataType: 'json'
|
load() {
|
||||||
})
|
this.loading = true;
|
||||||
.done(data => {
|
|
||||||
if (!data.success && data.error) {
|
|
||||||
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const refresh = (id) => {
|
|
||||||
var url = base_url + 'api/admin/mangadex/queue/retry';
|
|
||||||
if (id !== undefined)
|
|
||||||
url += '?' + $.param({
|
|
||||||
id: id
|
|
||||||
});
|
|
||||||
console.log(url);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: url,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (!data.success && data.error) {
|
|
||||||
alert('danger', `Failed to restart download job. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toggle = () => {
|
|
||||||
$('#pause-resume-btn').attr('disabled', '');
|
|
||||||
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
|
||||||
const action = paused ? 'resume' : 'pause';
|
|
||||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: url,
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
load();
|
|
||||||
$('#pause-resume-btn').removeAttr('disabled');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const load = () => {
|
|
||||||
if (loading) return;
|
|
||||||
loading = true;
|
|
||||||
console.log('fetching');
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
url: base_url + 'api/admin/mangadex/queue',
|
url: base_url + 'api/admin/mangadex/queue',
|
||||||
@@ -99,47 +42,75 @@ const load = () => {
|
|||||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(data);
|
this.jobs = data.jobs;
|
||||||
const btnText = data.paused ? "Resume download" : "Pause download";
|
this.paused = data.paused;
|
||||||
$('#pause-resume-btn').text(btnText);
|
|
||||||
$('#pause-resume-btn').removeAttr('hidden');
|
|
||||||
const rows = data.jobs.map(obj => {
|
|
||||||
var cls = 'label ';
|
|
||||||
if (obj.status === 'Pending')
|
|
||||||
cls += 'label-pending';
|
|
||||||
if (obj.status === 'Completed')
|
|
||||||
cls += 'label-success';
|
|
||||||
if (obj.status === 'Error')
|
|
||||||
cls += 'label-danger';
|
|
||||||
if (obj.status === 'MissingPages')
|
|
||||||
cls += 'label-warning';
|
|
||||||
|
|
||||||
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
|
||||||
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
|
||||||
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
|
|
||||||
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
|
|
||||||
return `<tr id="chapter-${obj.id}">
|
|
||||||
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
|
|
||||||
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
|
|
||||||
<td>${obj.success_count}/${obj.pages}</td>
|
|
||||||
<td>${moment(obj.time).fromNow()}</td>
|
|
||||||
<td>${statusSpan} ${dropdown}</td>
|
|
||||||
<td>${obj.plugin_id || ""}</td>
|
|
||||||
<td>
|
|
||||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
|
||||||
${retryBtn}
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
loading = false;
|
this.loading = false;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
jobAction(action, event) {
|
||||||
|
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
|
if (event) {
|
||||||
|
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
||||||
|
url = `${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 ${action} job from download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
this.toggling = true;
|
||||||
|
const action = this.paused ? 'resume' : 'pause';
|
||||||
|
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.load();
|
||||||
|
this.toggling = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
statusClass(status) {
|
||||||
|
let cls = 'label ';
|
||||||
|
switch (status) {
|
||||||
|
case 'Pending':
|
||||||
|
cls += 'label-pending';
|
||||||
|
break;
|
||||||
|
case 'Completed':
|
||||||
|
cls += 'label-success';
|
||||||
|
break;
|
||||||
|
case 'Error':
|
||||||
|
cls += 'label-danger';
|
||||||
|
break;
|
||||||
|
case 'MissingPages':
|
||||||
|
cls += 'label-warning';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ const search = () => {
|
|||||||
if (searching)
|
if (searching)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const query = $('#search-input').val();
|
const query = $.param({
|
||||||
$.ajax({
|
query: $('#search-input').val(),
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/plugin/list',
|
|
||||||
data: JSON.stringify({
|
|
||||||
query: query,
|
|
||||||
plugin: pid
|
plugin: pid
|
||||||
}),
|
});
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json'
|
dataType: 'json'
|
||||||
})
|
})
|
||||||
|
|||||||
+229
-258
@@ -1,41 +1,197 @@
|
|||||||
let lastSavedPage = page;
|
const readerComponent = () => {
|
||||||
let items = [];
|
return {
|
||||||
let longPages = false;
|
loading: true,
|
||||||
|
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
|
||||||
|
msg: 'Loading the web reader. Please wait...',
|
||||||
|
alertClass: 'uk-alert-primary',
|
||||||
|
items: [],
|
||||||
|
curItem: {},
|
||||||
|
flipAnimation: null,
|
||||||
|
longPages: false,
|
||||||
|
lastSavedPage: page,
|
||||||
|
|
||||||
$(() => {
|
/**
|
||||||
getPages();
|
* Initialize the component by fetching the page dimensions
|
||||||
|
*/
|
||||||
|
init(nextTick) {
|
||||||
|
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success && data.error)
|
||||||
|
throw new Error(resp.error);
|
||||||
|
const dimensions = data.dimensions;
|
||||||
|
|
||||||
$('#page-select').change(() => {
|
this.items = dimensions.map((d, i) => {
|
||||||
const p = parseInt($('#page-select').val());
|
return {
|
||||||
toPage(p);
|
id: i + 1,
|
||||||
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
|
width: d.width,
|
||||||
|
height: d.height,
|
||||||
|
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#mode-select').change(() => {
|
const avgRatio = this.items.reduce((acc, cur) => {
|
||||||
|
return acc + cur.height / cur.width
|
||||||
|
}, 0) / this.items.length;
|
||||||
|
|
||||||
|
console.log(avgRatio);
|
||||||
|
this.longPages = avgRatio > 2;
|
||||||
|
this.loading = false;
|
||||||
|
this.mode = localStorage.getItem('mode') || 'continuous';
|
||||||
|
|
||||||
|
// Here we save a copy of this.mode, and use the copy as
|
||||||
|
// the model-select value. This is because `updateMode`
|
||||||
|
// might change this.mode and make it `height` or `width`,
|
||||||
|
// which are not available in mode-select
|
||||||
|
const mode = this.mode;
|
||||||
|
this.updateMode(this.mode, page, nextTick);
|
||||||
|
$('#mode-select').val(mode);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
|
console.error(e);
|
||||||
|
this.alertClass = 'uk-alert-danger';
|
||||||
|
this.msg = errMsg;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the page selector
|
||||||
|
*/
|
||||||
|
pageChanged() {
|
||||||
|
const p = parseInt($('#page-select').val());
|
||||||
|
this.toPage(p);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the mode selector
|
||||||
|
*
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
modeChanged(nextTick) {
|
||||||
const mode = $('#mode-select').val();
|
const mode = $('#mode-select').val();
|
||||||
const curIdx = parseInt($('#page-select').val());
|
const curIdx = parseInt($('#page-select').val());
|
||||||
|
|
||||||
updateMode(mode, curIdx);
|
this.updateMode(mode, curIdx, nextTick);
|
||||||
});
|
},
|
||||||
});
|
/**
|
||||||
|
* Handles the window `resize` event
|
||||||
$(window).resize(() => {
|
*/
|
||||||
const mode = getProp('mode');
|
resized() {
|
||||||
if (mode === 'continuous') return;
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
const wideScreen = $(window).width() > $(window).height();
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
const propMode = wideScreen ? 'height' : 'width';
|
this.mode = wideScreen ? 'height' : 'width';
|
||||||
setProp('mode', propMode);
|
},
|
||||||
});
|
/**
|
||||||
|
* Handles the window `keydown` event
|
||||||
/**
|
|
||||||
* Update the reader mode
|
|
||||||
*
|
*
|
||||||
* @function updateMode
|
* @param {Event} event - The triggering event
|
||||||
* @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) => {
|
keyHandler(event) {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||||
|
this.flipPage(false);
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||||
|
this.flipPage(true);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Flips to the next or the previous page
|
||||||
|
*
|
||||||
|
* @param {bool} isNext - Whether we are going to the next page
|
||||||
|
*/
|
||||||
|
flipPage(isNext) {
|
||||||
|
const idx = parseInt(this.curItem.id);
|
||||||
|
const newIdx = idx + (isNext ? 1 : -1);
|
||||||
|
|
||||||
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
|
|
||||||
|
this.toPage(newIdx);
|
||||||
|
|
||||||
|
if (isNext)
|
||||||
|
this.flipAnimation = 'right';
|
||||||
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.flipAnimation = null;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.replaceHistory(newIdx);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Jumps to a specific page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
toPage(idx) {
|
||||||
|
if (this.mode === 'continuous') {
|
||||||
|
$(`#${idx}`).get(0).scrollIntoView(true);
|
||||||
|
} else {
|
||||||
|
if (idx >= 1 && idx <= this.items.length) {
|
||||||
|
this.curItem = this.items[idx - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.replaceHistory(idx);
|
||||||
|
UIkit.modal($('#modal-sections')).hide();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Replace the address bar history and save the reading progress if necessary
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
replaceHistory(idx) {
|
||||||
|
const ary = window.location.pathname.split('/');
|
||||||
|
ary[ary.length - 1] = idx;
|
||||||
|
ary.shift(); // remove leading `/`
|
||||||
|
ary.unshift(window.location.origin);
|
||||||
|
const url = ary.join('/');
|
||||||
|
this.saveProgress(idx);
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the backend reading progress if:
|
||||||
|
* 1) the current page is more than five pages away from the last
|
||||||
|
* saved page, or
|
||||||
|
* 2) the average height/width ratio of the pages is over 2, or
|
||||||
|
* 3) the current page is the first page, or
|
||||||
|
* 4) the current page is the last page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
* @param {function} cb - Callback
|
||||||
|
*/
|
||||||
|
saveProgress(idx, cb) {
|
||||||
|
idx = parseInt(idx);
|
||||||
|
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
|
||||||
|
this.longPages ||
|
||||||
|
idx === 1 || idx === this.items.length
|
||||||
|
) {
|
||||||
|
this.lastSavedPage = idx;
|
||||||
|
console.log('saving progress', idx);
|
||||||
|
|
||||||
|
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
||||||
|
$.ajax({
|
||||||
|
method: 'PUT',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error)
|
||||||
|
alert('danger', data.error);
|
||||||
|
if (cb) cb();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the reader mode
|
||||||
|
*
|
||||||
|
* @param {string} mode - Either `continuous` or `paged`
|
||||||
|
* @param {number} targetPage - The one-based index of the target page
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
updateMode(mode, targetPage, nextTick) {
|
||||||
localStorage.setItem('mode', mode);
|
localStorage.setItem('mode', mode);
|
||||||
|
|
||||||
// The mode to be put into the `mode` prop. It can't be `screen`
|
// The mode to be put into the `mode` prop. It can't be `screen`
|
||||||
@@ -46,265 +202,80 @@ const updateMode = (mode, targetPage) => {
|
|||||||
propMode = wideScreen ? 'height' : 'width';
|
propMode = wideScreen ? 'height' : 'width';
|
||||||
}
|
}
|
||||||
|
|
||||||
setProp('mode', propMode);
|
this.mode = propMode;
|
||||||
|
|
||||||
if (mode === 'continuous') {
|
if (mode === 'continuous') {
|
||||||
waitForPage(items.length, () => {
|
nextTick(() => {
|
||||||
setupScroller();
|
this.setupScroller();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForPage(targetPage, () => {
|
nextTick(() => {
|
||||||
setTimeout(() => {
|
this.toPage(targetPage);
|
||||||
toPage(targetPage);
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Shows the control modal
|
||||||
* Set an alpine.js property
|
|
||||||
*
|
*
|
||||||
* @function setProp
|
* @param {Event} event - The triggering event
|
||||||
* @param {string} key - Key of the data property
|
|
||||||
* @param {*} prop - The data property
|
|
||||||
*/
|
*/
|
||||||
const setProp = (key, prop) => {
|
showControl(event) {
|
||||||
$('#root').get(0).__x.$data[key] = prop;
|
const idx = event.currentTarget.id;
|
||||||
};
|
const pageCount = this.items.length;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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 {object} event - The onclick event that triggers the function
|
|
||||||
*/
|
|
||||||
const showControl = (event) => {
|
|
||||||
const idx = parseInt($(event.currentTarget).attr('id'));
|
|
||||||
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();
|
||||||
}
|
},
|
||||||
|
/**
|
||||||
/**
|
* Redirects to a URL
|
||||||
* Redirect to a URL
|
|
||||||
*
|
*
|
||||||
* @function redirect
|
|
||||||
* @param {string} url - The target URL
|
* @param {string} url - The target URL
|
||||||
*/
|
*/
|
||||||
const redirect = (url) => {
|
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
|
* Set up the scroll handler that calls `replaceHistory` when an image
|
||||||
* enters the view port
|
* enters the view port
|
||||||
*
|
|
||||||
* @function setupScroller
|
|
||||||
*/
|
*/
|
||||||
const setupScroller = () => {
|
setupScroller() {
|
||||||
const mode = getProp('mode');
|
if (this.mode !== 'continuous') return;
|
||||||
if (mode !== 'continuous') return;
|
$('img').each((idx, el) => {
|
||||||
$('#root img').each((idx, el) => {
|
|
||||||
$(el).on('inview', (event, inView) => {
|
$(el).on('inview', (event, inView) => {
|
||||||
if (inView) {
|
if (inView) {
|
||||||
const current = $(event.currentTarget).attr('id');
|
const current = $(event.currentTarget).attr('id');
|
||||||
replaceHistory(current);
|
|
||||||
|
this.curItem = this.items[current - 1];
|
||||||
|
this.replaceHistory(current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Marks progress as 100% and jumps to the next entry
|
||||||
* 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({entry: eid})}`;
|
|
||||||
$.post(url)
|
|
||||||
.then(data => {
|
|
||||||
if (data.error) throw new Error(data.error);
|
|
||||||
if (cb) cb();
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e);
|
|
||||||
alert('danger', e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* @param {string} nextUrl - URL of the next entry
|
||||||
*/
|
*/
|
||||||
const nextEntry = (nextUrl) => {
|
nextEntry(nextUrl) {
|
||||||
saveProgress(items.length, () => {
|
this.saveProgress(this.items.length, () => {
|
||||||
redirect(nextUrl);
|
this.redirect(nextUrl);
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Exits the reader, and optionally sets the reading progress tp 100%
|
||||||
* Show the next or the previous page
|
|
||||||
*
|
*
|
||||||
* @function flipPage
|
* @param {string} exitUrl - The Exit URL
|
||||||
* @param {bool} isNext - Whether we are going to the next page
|
* @param {boolean} [markCompleted] - Whether we should mark the
|
||||||
|
* reading progress to 100%
|
||||||
*/
|
*/
|
||||||
const flipPage = (isNext) => {
|
exitReader(exitUrl, markCompleted = false) {
|
||||||
const curItem = getProp('curItem');
|
if (!markCompleted) {
|
||||||
const idx = parseInt(curItem.id);
|
return this.redirect(exitUrl);
|
||||||
const delta = isNext ? 1 : -1;
|
}
|
||||||
const newIdx = idx + delta;
|
this.saveProgress(this.items.length, () => {
|
||||||
|
this.redirect(exitUrl);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
// https://flaviocopes.com/javascript-detect-dark-mode/
|
|
||||||
const preferDarkMode = () => {
|
|
||||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validThemeSetting = (theme) => {
|
|
||||||
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// dark / light / system
|
|
||||||
const loadThemeSetting = () => {
|
|
||||||
let str = localStorage.getItem('theme');
|
|
||||||
if (!str || !validThemeSetting(str)) str = 'light';
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
// dark / light
|
|
||||||
const loadTheme = () => {
|
|
||||||
let setting = loadThemeSetting();
|
|
||||||
if (setting === 'system') {
|
|
||||||
setting = preferDarkMode() ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveThemeSetting = setting => {
|
|
||||||
if (!validThemeSetting(setting)) setting = 'light';
|
|
||||||
localStorage.setItem('theme', setting);
|
|
||||||
};
|
|
||||||
|
|
||||||
// when toggled, Auto will be changed to light or dark
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const theme = loadTheme();
|
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
|
||||||
saveThemeSetting(newTheme);
|
|
||||||
setTheme(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTheme = (theme) => {
|
|
||||||
if (!theme) theme = loadTheme();
|
|
||||||
if (theme === 'dark') {
|
|
||||||
$('html').css('background', 'rgb(20, 20, 20)');
|
|
||||||
$('body').addClass('uk-light');
|
|
||||||
$('.uk-card').addClass('uk-card-secondary');
|
|
||||||
$('.uk-card').removeClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').addClass('dark');
|
|
||||||
} else {
|
|
||||||
$('html').css('background', '');
|
|
||||||
$('body').removeClass('uk-light');
|
|
||||||
$('.uk-card').removeClass('uk-card-secondary');
|
|
||||||
$('.uk-card').addClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').removeClass('dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// do it before document is ready to prevent the initial flash of white on
|
|
||||||
// most pages
|
|
||||||
setTheme();
|
|
||||||
|
|
||||||
$(() => {
|
|
||||||
// hack for the reader page
|
|
||||||
setTheme();
|
|
||||||
|
|
||||||
// on system dark mode setting change
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
.addEventListener('change', event => {
|
|
||||||
if (loadThemeSetting() === 'system')
|
|
||||||
setTheme(event.matches ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+101
-9
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
|
|
||||||
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
||||||
|
|
||||||
$('#modal-download-btn').attr('href', `${base_url}opds/download/${titleID}/${entryID}`);
|
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
|
||||||
|
|
||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
}
|
}
|
||||||
@@ -63,17 +63,26 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
const updateProgress = (tid, eid, page) => {
|
const updateProgress = (tid, eid, page) => {
|
||||||
let url = `${base_url}api/progress/${tid}/${page}`
|
let url = `${base_url}api/progress/${tid}/${page}`
|
||||||
const query = $.param({
|
const query = $.param({
|
||||||
entry: eid
|
eid: eid
|
||||||
});
|
});
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
$.post(url, (data) => {
|
|
||||||
|
$.ajax({
|
||||||
|
method: 'PUT',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
error = data.error;
|
error = data.error;
|
||||||
alert('danger', error);
|
alert('danger', error);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = $.param({
|
const query = $.param({
|
||||||
entry: eid
|
eid: eid
|
||||||
});
|
});
|
||||||
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'PUT',
|
||||||
url: url,
|
url: url,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json'
|
dataType: 'json'
|
||||||
@@ -131,6 +140,7 @@ const edit = (eid) => {
|
|||||||
|
|
||||||
const displayNameField = $('#display-name-field');
|
const displayNameField = $('#display-name-field');
|
||||||
displayNameField.attr('value', displayName);
|
displayNameField.attr('value', displayName);
|
||||||
|
console.log(displayNameField);
|
||||||
displayNameField.keyup(event => {
|
displayNameField.keyup(event => {
|
||||||
if (event.keyCode === 13) {
|
if (event.keyCode === 13) {
|
||||||
renameSubmit(displayNameField.val(), eid);
|
renameSubmit(displayNameField.val(), eid);
|
||||||
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
|
|||||||
const bar = $('#upload-progress').get(0);
|
const bar = $('#upload-progress').get(0);
|
||||||
const titleId = upload.attr('data-title-id');
|
const titleId = upload.attr('data-title-id');
|
||||||
const queryObj = {
|
const queryObj = {
|
||||||
title: titleId
|
tid: titleId
|
||||||
};
|
};
|
||||||
if (eid)
|
if (eid)
|
||||||
queryObj['entry'] = eid;
|
queryObj['eid'] = eid;
|
||||||
const query = $.param(queryObj);
|
const query = $.param(queryObj);
|
||||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||||
console.log(url);
|
console.log(url);
|
||||||
@@ -218,9 +228,9 @@ const selectedIDs = () => {
|
|||||||
const bulkProgress = (action, el) => {
|
const bulkProgress = (action, el) => {
|
||||||
const tid = $(el).attr('data-id');
|
const tid = $(el).attr('data-id');
|
||||||
const ids = selectedIDs();
|
const ids = selectedIDs();
|
||||||
const url = `${base_url}api/bulk-progress/${action}/${tid}`;
|
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'PUT',
|
||||||
url: url,
|
url: url,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -242,3 +252,85 @@ const bulkProgress = (action, el) => {
|
|||||||
deselectAll();
|
deselectAll();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tagsComponent = () => {
|
||||||
|
return {
|
||||||
|
isAdmin: false,
|
||||||
|
tags: [],
|
||||||
|
tid: $('.upload-field').attr('data-title-id'),
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
load(admin) {
|
||||||
|
this.isAdmin = admin;
|
||||||
|
|
||||||
|
$('.tag-select').select2({
|
||||||
|
tags: true,
|
||||||
|
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
|
||||||
|
disabled: !this.isAdmin,
|
||||||
|
templateSelection(state) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
|
||||||
|
a.setAttribute('class', 'uk-link-reset');
|
||||||
|
a.onclick = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
a.innerText = state.text;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.request(`${base_url}api/tags`, 'GET', (data) => {
|
||||||
|
const allTags = data.tags;
|
||||||
|
const url = `${base_url}api/tags/${this.tid}`;
|
||||||
|
this.request(url, 'GET', data => {
|
||||||
|
this.tags = data.tags;
|
||||||
|
allTags.forEach(t => {
|
||||||
|
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
|
||||||
|
$('.tag-select').append(op);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:select', e => {
|
||||||
|
this.onAdd(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:unselect', e => {
|
||||||
|
this.onDelete(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('change', () => {
|
||||||
|
this.onChange();
|
||||||
|
});
|
||||||
|
$('.tag-select').trigger('change');
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChange() {
|
||||||
|
this.tags = $('.tag-select').select2('data').map(o => o.text);
|
||||||
|
},
|
||||||
|
onAdd(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'PUT');
|
||||||
|
},
|
||||||
|
onDelete(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'DELETE');
|
||||||
|
},
|
||||||
|
request(url, method, cb) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.success) {
|
||||||
|
if (cb) cb(data);
|
||||||
|
} else {
|
||||||
|
alert('danger', data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+14
-9
@@ -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}`,
|
||||||
|
type: 'DELETE',
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.success)
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
else
|
||||||
else {
|
alert('danger', data.error);
|
||||||
error = data.error;
|
})
|
||||||
alert('danger', error);
|
.fail((jqXHR, status) => {
|
||||||
}
|
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
+34
-18
@@ -1,62 +1,78 @@
|
|||||||
version: 1.0
|
version: 2.0
|
||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 0.12.1
|
version: 0.12.1
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
git: https://github.com/schovi/baked_file_system.git
|
||||||
version: 0.9.8
|
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||||
|
|
||||||
clim:
|
clim:
|
||||||
github: at-grandpa/clim
|
git: https://github.com/at-grandpa/clim.git
|
||||||
version: 0.12.0
|
version: 0.12.0
|
||||||
|
|
||||||
db:
|
db:
|
||||||
github: crystal-lang/crystal-db
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
version: 0.9.0
|
version: 0.9.0
|
||||||
|
|
||||||
duktape:
|
duktape:
|
||||||
github: jessedoyle/duktape.cr
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
version: 0.20.0
|
version: 0.20.0
|
||||||
|
|
||||||
exception_page:
|
exception_page:
|
||||||
github: crystal-loot/exception_page
|
git: https://github.com/crystal-loot/exception_page.git
|
||||||
version: 0.1.4
|
version: 0.1.4
|
||||||
|
|
||||||
http_proxy:
|
http_proxy:
|
||||||
github: mamantoha/http_proxy
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
||||||
image_size:
|
image_size:
|
||||||
github: hkalexling/image_size.cr
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
version: 0.4.0
|
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
|
||||||
|
|
||||||
|
mg:
|
||||||
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
github: kostya/myhtml
|
git: https://github.com/kostya/myhtml.git
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
|
|
||||||
|
open_api:
|
||||||
|
git: https://github.com/jreinert/open_api.cr.git
|
||||||
|
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
github: luislavena/radix
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.3.9
|
version: 0.3.9
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.16.0
|
version: 0.16.0
|
||||||
|
|
||||||
|
tallboy:
|
||||||
|
git: https://github.com/epoch/tallboy.git
|
||||||
|
version: 0.9.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.16.0
|
version: 0.19.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,6 +21,7 @@ dependencies:
|
|||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
|
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
github: hkalexling/archive.cr
|
||||||
ameba:
|
ameba:
|
||||||
@@ -36,3 +37,9 @@ dependencies:
|
|||||||
github: mamantoha/http_proxy
|
github: mamantoha/http_proxy
|
||||||
image_size:
|
image_size:
|
||||||
github: hkalexling/image_size.cr
|
github: hkalexling/image_size.cr
|
||||||
|
koa:
|
||||||
|
github: hkalexling/koa
|
||||||
|
tallboy:
|
||||||
|
github: epoch/tallboy
|
||||||
|
mg:
|
||||||
|
github: hkalexling/mg
|
||||||
|
|||||||
+8
-8
@@ -40,11 +40,6 @@ describe Rule do
|
|||||||
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
||||||
end
|
end
|
||||||
|
|
||||||
it "allows `|` outside of patterns" do
|
|
||||||
rule = Rule.new "hello|world"
|
|
||||||
rule.render({} of String => String).should eq "hello|world"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "raises on escaped characters" do
|
it "raises on escaped characters" do
|
||||||
expect_raises Exception do
|
expect_raises Exception do
|
||||||
Rule.new "hello/world"
|
Rule.new "hello/world"
|
||||||
@@ -69,8 +64,13 @@ describe Rule do
|
|||||||
rule.render({} of String => String).should eq "testing"
|
rule.render({} of String => String).should eq "testing"
|
||||||
end
|
end
|
||||||
|
|
||||||
it "escapes slash" do
|
it "escapes illegal characters" do
|
||||||
rule = Rule.new "{id}"
|
rule = Rule.new "{a}"
|
||||||
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
|
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips trailing spaces and dots" do
|
||||||
|
rule = Rule.new "hello. world. .."
|
||||||
|
rule.render({} of String => String).should eq "hello. world"
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class Config
|
|||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
home: true
|
home: true
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
|
property page_margin : Int32 = 30
|
||||||
|
property disable_login = false
|
||||||
|
property default_username = ""
|
||||||
property mangadex = Hash(String, String | Int32).new
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@@ -85,5 +88,9 @@ class Config
|
|||||||
unless base_url.ends_with? "/"
|
unless base_url.ends_with? "/"
|
||||||
@base_url += "/"
|
@base_url += "/"
|
||||||
end
|
end
|
||||||
|
if disable_login && default_username.empty?
|
||||||
|
raise "Login is disabled, but default username is not set. " \
|
||||||
|
"Please set a default username"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ class AuthHandler < Kemal::Handler
|
|||||||
"You have to login with proper credentials"
|
"You have to login with proper credentials"
|
||||||
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
||||||
|
|
||||||
def initialize(@storage : Storage)
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_basic_auth(env)
|
def require_basic_auth(env)
|
||||||
env.response.status_code = 401
|
env.response.status_code = 401
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||||
@@ -23,12 +20,12 @@ class AuthHandler < Kemal::Handler
|
|||||||
|
|
||||||
def validate_token(env)
|
def validate_token(env)
|
||||||
token = env.session.string? "token"
|
token = env.session.string? "token"
|
||||||
!token.nil? && @storage.verify_token token
|
!token.nil? && Storage.default.verify_token token
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_token_admin(env)
|
def validate_token_admin(env)
|
||||||
token = env.session.string? "token"
|
token = env.session.string? "token"
|
||||||
!token.nil? && @storage.verify_admin token
|
!token.nil? && Storage.default.verify_admin token
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_auth_header(env)
|
def validate_auth_header(env)
|
||||||
@@ -49,7 +46,7 @@ class AuthHandler < Kemal::Handler
|
|||||||
def verify_user(value)
|
def verify_user(value)
|
||||||
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
||||||
.split(":")
|
.split(":")
|
||||||
@storage.verify_user username, password
|
Storage.default.verify_user username, password
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_opds_auth(env)
|
def handle_opds_auth(env)
|
||||||
@@ -68,14 +65,28 @@ class AuthHandler < Kemal::Handler
|
|||||||
return call_next(env)
|
return call_next(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
unless validate_token env
|
unless validate_token(env) || Config.current.disable_login
|
||||||
env.session.string "callback", env.request.path
|
env.session.string "callback", env.request.path
|
||||||
return redirect env, "/login"
|
return redirect env, "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||||
unless validate_token_admin env
|
# The token (if exists) takes precedence over the default user option.
|
||||||
|
# this is why we check the default username first before checking the
|
||||||
|
# token.
|
||||||
|
should_reject = true
|
||||||
|
if Config.current.disable_login &&
|
||||||
|
Storage.default.username_is_admin Config.current.default_username
|
||||||
|
should_reject = false
|
||||||
|
end
|
||||||
|
if env.session.string? "token"
|
||||||
|
should_reject = !validate_token_admin(env)
|
||||||
|
end
|
||||||
|
if should_reject
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
|
send_error_page "HTTP 403: You are not authorized to visit " \
|
||||||
|
"#{env.request.path}"
|
||||||
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +1,12 @@
|
|||||||
require "image_size"
|
require "image_size"
|
||||||
|
|
||||||
class Entry
|
class Entry
|
||||||
property zip_path : String, book : Title, title : String,
|
getter zip_path : String, book : Title, title : String,
|
||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
size : String, pages : Int32, id : String, encoded_path : String,
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
encoded_title : String, mtime : Time, err_msg : String?
|
||||||
|
|
||||||
def initialize(@zip_path, @book, storage)
|
def initialize(@zip_path, @book)
|
||||||
|
storage = Storage.default
|
||||||
@encoded_path = URI.encode @zip_path
|
@encoded_path = URI.encode @zip_path
|
||||||
@title = File.basename @zip_path, File.extname @zip_path
|
@title = File.basename @zip_path, File.extname @zip_path
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@@ -47,8 +48,7 @@ class Entry
|
|||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["zip_path", "title", "size", "id",
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
"encoded_path", "encoded_title"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "title_id", @book.id
|
json.field "title_id", @book.id
|
||||||
|
|||||||
+4
-25
@@ -1,5 +1,5 @@
|
|||||||
class Library
|
class Library
|
||||||
property dir : String, title_ids : Array(String),
|
getter dir : String, title_ids : Array(String),
|
||||||
title_hash : Hash(String, Title)
|
title_hash : Hash(String, Title)
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
@@ -68,29 +68,8 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# This is a hack to bypass a compiler bug
|
# Helper function from src/util/util.cr
|
||||||
ary = titles
|
sort_titles titles, opt.not_nil!, username
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
@@ -127,7 +106,7 @@ class Library
|
|||||||
.select { |fn| !fn.starts_with? "." }
|
.select { |fn| !fn.starts_with? "." }
|
||||||
.map { |fn| File.join @dir, fn }
|
.map { |fn| File.join @dir, fn }
|
||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "", storage, self }
|
.map { |path| Title.new path, "" }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort { |a, b| a.title <=> b.title }
|
||||||
.tap { |_| @title_ids.clear }
|
.tap { |_| @title_ids.clear }
|
||||||
|
|||||||
+45
-17
@@ -1,12 +1,14 @@
|
|||||||
require "../archive"
|
require "../archive"
|
||||||
|
|
||||||
class Title
|
class Title
|
||||||
property dir : String, parent_id : String, title_ids : Array(String),
|
getter dir : String, parent_id : String, title_ids : Array(String),
|
||||||
entries : Array(Entry), title : String, id : String,
|
entries : Array(Entry), title : String, id : String,
|
||||||
encoded_title : String, mtime : Time
|
encoded_title : String, mtime : Time
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id, storage,
|
@entry_display_name_cache : Hash(String, String)?
|
||||||
@library : Library)
|
|
||||||
|
def initialize(@dir : String, @parent_id)
|
||||||
|
storage = Storage.default
|
||||||
id = storage.get_id @dir, true
|
id = storage.get_id @dir, true
|
||||||
if id.nil?
|
if id.nil?
|
||||||
id = random_str
|
id = random_str
|
||||||
@@ -27,26 +29,26 @@ class Title
|
|||||||
next if fn.starts_with? "."
|
next if fn.starts_with? "."
|
||||||
path = File.join dir, fn
|
path = File.join dir, fn
|
||||||
if File.directory? path
|
if File.directory? path
|
||||||
title = Title.new path, @id, storage, library
|
title = Title.new path, @id
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
@library.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||||
entry = Entry.new path, self, storage
|
entry = Entry.new path, self
|
||||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
mtimes = [@mtime]
|
mtimes = [@mtime]
|
||||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
mtimes += @entries.map { |e| e.mtime }
|
mtimes += @entries.map { |e| e.mtime }
|
||||||
@mtime = mtimes.max
|
@mtime = mtimes.max
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
@title_ids.sort! do |a, b|
|
||||||
compare_numerically @library.title_hash[a].title,
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
@library.title_hash[b].title
|
Library.default.title_hash[b].title
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
@@ -56,7 +58,7 @@ class Title
|
|||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "display_name", display_name
|
json.field "display_name", display_name
|
||||||
@@ -82,7 +84,7 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
@title_ids.map { |tid| @library.get_title! tid }
|
@title_ids.map { |tid| Library.default.get_title! tid }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get all entries, including entries in nested titles
|
# Get all entries, including entries in nested titles
|
||||||
@@ -100,15 +102,37 @@ class Title
|
|||||||
ary = [] of Title
|
ary = [] of Title
|
||||||
tid = @parent_id
|
tid = @parent_id
|
||||||
while !tid.empty?
|
while !tid.empty?
|
||||||
title = @library.get_title! tid
|
title = Library.default.get_title! tid
|
||||||
ary << title
|
ary << title
|
||||||
tid = title.parent_id
|
tid = title.parent_id
|
||||||
end
|
end
|
||||||
ary.reverse
|
ary.reverse
|
||||||
end
|
end
|
||||||
|
|
||||||
def size
|
# Returns a string the describes the content of the title
|
||||||
@entries.size + @title_ids.size
|
# e.g., - 3 titles and 1 entry
|
||||||
|
# - 4 entries
|
||||||
|
# - 1 title
|
||||||
|
def content_label
|
||||||
|
ary = [] of String
|
||||||
|
tsize = titles.size
|
||||||
|
esize = entries.size
|
||||||
|
|
||||||
|
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
|
||||||
|
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
|
||||||
|
ary.join " and "
|
||||||
|
end
|
||||||
|
|
||||||
|
def tags
|
||||||
|
Storage.default.get_title_tags @id
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_tag(tag)
|
||||||
|
Storage.default.add_tag @id, tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_tag(tag)
|
||||||
|
Storage.default.delete_tag @id, tag
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@@ -129,13 +153,17 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def display_name(entry_name)
|
def display_name(entry_name)
|
||||||
dn = entry_name
|
unless @entry_display_name_cache
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info_dn = info.entry_display_name[entry_name]?
|
@entry_display_name_cache = info.entry_display_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
dn = entry_name
|
||||||
|
info_dn = @entry_display_name_cache.not_nil![entry_name]?
|
||||||
unless info_dn.nil? || info_dn.empty?
|
unless info_dn.nil? || info_dn.empty?
|
||||||
dn = info_dn
|
dn = info_dn
|
||||||
end
|
end
|
||||||
end
|
|
||||||
dn
|
dn
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+28
-20
@@ -6,29 +6,17 @@ class Logger
|
|||||||
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
||||||
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
||||||
|
|
||||||
|
getter raw_log = Log.for ""
|
||||||
|
|
||||||
@@severity : Log::Severity = :info
|
@@severity : Log::Severity = :info
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
level = Config.current.log_level
|
@@severity = Logger.get_severity
|
||||||
{% begin %}
|
|
||||||
case level.downcase
|
|
||||||
when "off"
|
|
||||||
@@severity = :none
|
|
||||||
{% for lvl, i in LEVELS %}
|
|
||||||
when {{lvl}}
|
|
||||||
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
|
|
||||||
{% end %}
|
|
||||||
else
|
|
||||||
raise "Unknown log level #{level}"
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
@log = Log.for("")
|
|
||||||
|
|
||||||
@backend = Log::IOBackend.new
|
@backend = Log::IOBackend.new
|
||||||
@backend.formatter = ->(entry : Log::Entry, io : IO) do
|
|
||||||
|
format_proc = ->(entry : Log::Entry, io : IO) do
|
||||||
color = :default
|
color = :default
|
||||||
{% begin %}
|
{% begin %}
|
||||||
case entry.severity.label.to_s().downcase
|
case entry.severity.label.to_s().downcase
|
||||||
@@ -45,12 +33,32 @@ class Logger
|
|||||||
io << entry.message
|
io << entry.message
|
||||||
end
|
end
|
||||||
|
|
||||||
Log.builder.bind "*", @@severity, @backend
|
@backend.formatter = Log::Formatter.new &format_proc
|
||||||
|
Log.setup @@severity, @backend
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get_severity(level = "") : Log::Severity
|
||||||
|
if level.empty?
|
||||||
|
level = Config.current.log_level
|
||||||
|
end
|
||||||
|
{% begin %}
|
||||||
|
case level.downcase
|
||||||
|
when "off"
|
||||||
|
return Log::Severity::None
|
||||||
|
{% for lvl, i in LEVELS %}
|
||||||
|
when {{lvl}}
|
||||||
|
return Log::Severity.new SEVERITY_IDS[{{i}}]
|
||||||
|
{% end %}
|
||||||
|
else
|
||||||
|
raise "Unknown log level #{level}"
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ignores @@severity and always log msg
|
# Ignores @@severity and always log msg
|
||||||
def log(msg)
|
def log(msg)
|
||||||
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
@backend.write Log::Entry.new "", Log::Severity::None, msg,
|
||||||
|
Log::Metadata.empty, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.log(msg)
|
def self.log(msg)
|
||||||
@@ -59,7 +67,7 @@ class Logger
|
|||||||
|
|
||||||
{% for lvl in LEVELS %}
|
{% for lvl in LEVELS %}
|
||||||
def {{lvl.id}}(msg)
|
def {{lvl.id}}(msg)
|
||||||
@log.{{lvl.id}} { msg }
|
raw_log.{{lvl.id}} { msg }
|
||||||
end
|
end
|
||||||
def self.{{lvl.id}}(msg)
|
def self.{{lvl.id}}(msg)
|
||||||
default.not_nil!.{{lvl.id}} msg
|
default.not_nil!.{{lvl.id}} msg
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
require "./api"
|
require "./api"
|
||||||
require "zip"
|
require "compress/zip"
|
||||||
|
|
||||||
module MangaDex
|
module MangaDex
|
||||||
class PageJob
|
class PageJob
|
||||||
property success = false
|
property success = false
|
||||||
property url : String
|
property url : String
|
||||||
property filename : String
|
property filename : String
|
||||||
property writer : Zip::Writer
|
property writer : Compress::Zip::Writer
|
||||||
property tries_remaning : Int32
|
property tries_remaning : Int32
|
||||||
|
|
||||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||||
@@ -69,7 +69,7 @@ module MangaDex
|
|||||||
# Find the number of digits needed to store the number of pages
|
# Find the number of digits needed to store the number of pages
|
||||||
len = Math.log10(chapter.pages.size).to_i + 1
|
len = Math.log10(chapter.pages.size).to_i + 1
|
||||||
|
|
||||||
writer = Zip::Writer.new zip_path
|
writer = Compress::Zip::Writer.new zip_path
|
||||||
# Create a buffered channel. It works as an FIFO queue
|
# Create a buffered channel. It works as an FIFO queue
|
||||||
channel = Channel(PageJob).new chapter.pages.size
|
channel = Channel(PageJob).new chapter.pages.size
|
||||||
spawn do
|
spawn do
|
||||||
@@ -91,6 +91,7 @@ module MangaDex
|
|||||||
end
|
end
|
||||||
|
|
||||||
channel.send page_job
|
channel.send page_job
|
||||||
|
break unless @queue.exists? job
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ module MangaDex
|
|||||||
page_jobs = [] of PageJob
|
page_jobs = [] of PageJob
|
||||||
chapter.pages.size.times do
|
chapter.pages.size.times do
|
||||||
page_job = channel.receive
|
page_job = channel.receive
|
||||||
|
|
||||||
|
break unless @queue.exists? job
|
||||||
|
|
||||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||||
"#{page_job.url}"
|
"#{page_job.url}"
|
||||||
page_jobs << page_job
|
page_jobs << page_job
|
||||||
@@ -110,6 +114,13 @@ module MangaDex
|
|||||||
Logger.error msg
|
Logger.error msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless @queue.exists? job
|
||||||
|
Logger.debug "Download cancelled"
|
||||||
|
@downloading = false
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
fail_count = page_jobs.count { |j| !j.success }
|
fail_count = page_jobs.count { |j| !j.success }
|
||||||
Logger.debug "Download completed. " \
|
Logger.debug "Download completed. " \
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
"#{fail_count}/#{page_jobs.size} failed"
|
||||||
|
|||||||
+13
-12
@@ -3,11 +3,12 @@ require "./queue"
|
|||||||
require "./server"
|
require "./server"
|
||||||
require "./main_fiber"
|
require "./main_fiber"
|
||||||
require "./mangadex/*"
|
require "./mangadex/*"
|
||||||
|
require "./plugin/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
require "clim"
|
||||||
require "./plugin/*"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.16.0"
|
MANGO_VERSION = "0.19.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@@ -53,6 +54,11 @@ class CLI < Clim
|
|||||||
ARGV.clear
|
ARGV.clear
|
||||||
|
|
||||||
Config.load(opts.config).set_current
|
Config.load(opts.config).set_current
|
||||||
|
|
||||||
|
# Initialize main components
|
||||||
|
Storage.default
|
||||||
|
Queue.default
|
||||||
|
Library.default
|
||||||
MangaDex::Downloader.default
|
MangaDex::Downloader.default
|
||||||
Plugin::Downloader.default
|
Plugin::Downloader.default
|
||||||
|
|
||||||
@@ -105,18 +111,13 @@ class CLI < Clim
|
|||||||
password.not_nil!, opts.admin
|
password.not_nil!, opts.admin
|
||||||
when "list"
|
when "list"
|
||||||
users = storage.list_users
|
users = storage.list_users
|
||||||
name_length = users.map(&.[0].size).max? || 0
|
table = Tallboy.table do
|
||||||
l_cell_width = ["username".size, name_length].max
|
header ["username", "admin access"]
|
||||||
r_cell_width = "admin access".size
|
|
||||||
header = " #{"username".ljust l_cell_width} | admin access "
|
|
||||||
puts "-" * header.size
|
|
||||||
puts header
|
|
||||||
puts "-" * header.size
|
|
||||||
users.each do |name, admin|
|
users.each do |name, admin|
|
||||||
puts " #{name.ljust l_cell_width} | " \
|
row [name, admin]
|
||||||
"#{admin.to_s.ljust r_cell_width} "
|
|
||||||
end
|
end
|
||||||
puts "-" * header.size
|
end
|
||||||
|
puts table
|
||||||
when nil
|
when nil
|
||||||
puts opts.help_string
|
puts opts.help_string
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
||||||
writer = Zip::Writer.new zip_path
|
writer = Compress::Zip::Writer.new zip_path
|
||||||
rescue e
|
rescue e
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
@queue.set_status Queue::JobStatus::Error, job
|
||||||
unless e.message.nil?
|
unless e.message.nil?
|
||||||
@@ -66,6 +66,8 @@ class Plugin
|
|||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
|
||||||
while page = plugin.next_page
|
while page = plugin.next_page
|
||||||
|
break unless @queue.exists? job
|
||||||
|
|
||||||
fn = process_filename page["filename"].as_s
|
fn = process_filename page["filename"].as_s
|
||||||
url = page["url"].as_s
|
url = page["url"].as_s
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
@@ -109,6 +111,12 @@ class Plugin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless @queue.exists? job
|
||||||
|
Logger.debug "Download cancelled"
|
||||||
|
@downloading = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
||||||
writer.close
|
writer.close
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||||
|
|||||||
@@ -257,6 +257,48 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "get"
|
sbx.put_prop_string -2, "get"
|
||||||
|
|
||||||
|
sbx.push_proc LibDUK::VARARGS do |ptr|
|
||||||
|
env = Duktape::Sandbox.new ptr
|
||||||
|
url = env.require_string 0
|
||||||
|
body = env.require_string 1
|
||||||
|
|
||||||
|
headers = HTTP::Headers.new
|
||||||
|
|
||||||
|
if env.get_top == 3
|
||||||
|
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
|
||||||
|
while env.next -1, true
|
||||||
|
key = env.require_string -2
|
||||||
|
val = env.require_string -1
|
||||||
|
headers.add key, val
|
||||||
|
env.pop_2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
res = HTTP::Client.post url, headers, body
|
||||||
|
|
||||||
|
env.push_object
|
||||||
|
|
||||||
|
env.push_int res.status_code
|
||||||
|
env.put_prop_string -2, "status_code"
|
||||||
|
|
||||||
|
env.push_string res.body
|
||||||
|
env.put_prop_string -2, "body"
|
||||||
|
|
||||||
|
env.push_object
|
||||||
|
res.headers.each do |k, v|
|
||||||
|
if v.size == 1
|
||||||
|
env.push_string v[0]
|
||||||
|
else
|
||||||
|
env.push_string v.join ","
|
||||||
|
end
|
||||||
|
env.put_prop_string -2, k
|
||||||
|
end
|
||||||
|
env.put_prop_string -2, "headers"
|
||||||
|
|
||||||
|
env.call_success
|
||||||
|
end
|
||||||
|
sbx.put_prop_string -2, "post"
|
||||||
|
|
||||||
sbx.push_proc 2 do |ptr|
|
sbx.push_proc 2 do |ptr|
|
||||||
env = Duktape::Sandbox.new ptr
|
env = Duktape::Sandbox.new ptr
|
||||||
html = env.require_string 0
|
html = env.require_string 0
|
||||||
|
|||||||
@@ -196,6 +196,21 @@ class Queue
|
|||||||
self.delete job.id
|
self.delete job.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def exists?(id : String)
|
||||||
|
res = false
|
||||||
|
MainFiber.run do
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
res = db.query_one "select count(*) from queue where id = (?)", id,
|
||||||
|
as: Bool
|
||||||
|
end
|
||||||
|
end
|
||||||
|
res
|
||||||
|
end
|
||||||
|
|
||||||
|
def exists?(job : Job)
|
||||||
|
self.exists? job.id
|
||||||
|
end
|
||||||
|
|
||||||
def delete_status(status : JobStatus)
|
def delete_status(status : JobStatus)
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
|||||||
+5
-1
@@ -139,9 +139,13 @@ module Rename
|
|||||||
post_process str
|
post_process str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Post-processes the generated file/folder name
|
||||||
|
# - Handles the rare case where the string is `..`
|
||||||
|
# - Removes trailing spaces and periods
|
||||||
|
# - Replace illegal characters with `_`
|
||||||
private def post_process(str)
|
private def post_process(str)
|
||||||
return "_" if str == ".."
|
return "_" if str == ".."
|
||||||
str.gsub "/", "_"
|
str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+6
-8
@@ -1,13 +1,11 @@
|
|||||||
require "./router"
|
struct AdminRouter
|
||||||
|
|
||||||
class AdminRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
layout "admin"
|
layout "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user" do |env|
|
get "/admin/user" do |env|
|
||||||
users = @context.storage.list_users
|
users = Storage.default.list_users
|
||||||
username = get_username env
|
username = get_username env
|
||||||
layout "user"
|
layout "user"
|
||||||
end
|
end
|
||||||
@@ -32,11 +30,11 @@ class AdminRouter < Router
|
|||||||
# would not contain `admin`
|
# would not contain `admin`
|
||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
|
|
||||||
@context.storage.new_user username, password, admin
|
Storage.default.new_user username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
redirect env, "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"error" => e.message})
|
query: hash_to_query({"error" => e.message})
|
||||||
@@ -51,12 +49,12 @@ class AdminRouter < Router
|
|||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
original_username = env.params.url["original_username"]
|
original_username = env.params.url["original_username"]
|
||||||
|
|
||||||
@context.storage.update_user \
|
Storage.default.update_user \
|
||||||
original_username, username, password, admin
|
original_username, username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
redirect env, "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"username" => original_username, \
|
query: hash_to_query({"username" => original_username, \
|
||||||
|
|||||||
+445
-47
@@ -1,16 +1,184 @@
|
|||||||
require "./router"
|
|
||||||
require "../mangadex/*"
|
require "../mangadex/*"
|
||||||
require "../upload"
|
require "../upload"
|
||||||
|
require "koa"
|
||||||
|
|
||||||
|
struct APIRouter
|
||||||
|
@@api_json : String?
|
||||||
|
|
||||||
|
API_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
macro s(fields)
|
||||||
|
{
|
||||||
|
{% for field in fields %}
|
||||||
|
{{field}} => "string",
|
||||||
|
{% end %}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
class APIRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
|
Koa.init "Mango API", version: API_VERSION, desc: <<-MD
|
||||||
|
# A Word of Caution
|
||||||
|
|
||||||
|
This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
|
||||||
|
|
||||||
|
# Terminologies
|
||||||
|
|
||||||
|
- Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
|
||||||
|
- Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
|
||||||
|
- Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
|
||||||
|
MD
|
||||||
|
|
||||||
|
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
|
||||||
|
Koa.global_tag "admin", desc: <<-MD
|
||||||
|
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
|
||||||
|
MD
|
||||||
|
|
||||||
|
Koa.binary "binary", desc: "A binary file"
|
||||||
|
Koa.array "entryAry", "$entry", desc: "An array of entries"
|
||||||
|
Koa.array "titleAry", "$title", desc: "An array of titles"
|
||||||
|
Koa.array "strAry", "string", desc: "An array of strings"
|
||||||
|
|
||||||
|
entry_schema = {
|
||||||
|
"pages" => "integer",
|
||||||
|
"mtime" => "integer",
|
||||||
|
}.merge s %w(zip_path title size id title_id display_name cover_url)
|
||||||
|
Koa.object "entry", entry_schema, desc: "An entry in a book"
|
||||||
|
|
||||||
|
title_schema = {
|
||||||
|
"mtime" => "integer",
|
||||||
|
"entries" => "$entryAry",
|
||||||
|
"titles" => "$titleAry",
|
||||||
|
"parents" => "$strAry",
|
||||||
|
}.merge s %w(dir title id display_name cover_url)
|
||||||
|
Koa.object "title", title_schema,
|
||||||
|
desc: "A manga title (a collection of entries and sub-titles)"
|
||||||
|
|
||||||
|
Koa.object "library", {
|
||||||
|
"dir" => "string",
|
||||||
|
"titles" => "$titleAry",
|
||||||
|
}, desc: "A library containing a list of top-level titles"
|
||||||
|
|
||||||
|
Koa.object "scanResult", {
|
||||||
|
"milliseconds" => "integer",
|
||||||
|
"titles" => "integer",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "progressResult", {
|
||||||
|
"progress" => "number",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "result", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"error" => "string?",
|
||||||
|
}
|
||||||
|
|
||||||
|
mc_schema = {
|
||||||
|
"groups" => "object",
|
||||||
|
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
|
||||||
|
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
|
||||||
|
|
||||||
|
Koa.array "chapterAry", "$mangadexChapter"
|
||||||
|
|
||||||
|
mm_schema = {
|
||||||
|
"chapers" => "$chapterAry",
|
||||||
|
}.merge s %w(id title description author artist cover_url)
|
||||||
|
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
|
||||||
|
|
||||||
|
Koa.object "chaptersObj", {
|
||||||
|
"chapters" => "$chapterAry",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "successFailCount", {
|
||||||
|
"success" => "integer",
|
||||||
|
"fail" => "integer",
|
||||||
|
}
|
||||||
|
|
||||||
|
job_schema = {
|
||||||
|
"pages" => "integer",
|
||||||
|
"success_count" => "integer",
|
||||||
|
"fail_count" => "integer",
|
||||||
|
"time" => "integer",
|
||||||
|
}.merge s %w(id manga_id title manga_title status_message status)
|
||||||
|
Koa.object "job", job_schema, desc: "A download job in the queue"
|
||||||
|
|
||||||
|
Koa.array "jobAry", "$job"
|
||||||
|
|
||||||
|
Koa.object "jobs", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"paused" => "boolean",
|
||||||
|
"jobs" => "$jobAry",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "binaryUpload", {
|
||||||
|
"file" => "$binary",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "pluginListBody", {
|
||||||
|
"plugin" => "string",
|
||||||
|
"query" => "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "pluginChapter", {
|
||||||
|
"id" => "string",
|
||||||
|
"title" => "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.array "pluginChapterAry", "$pluginChapter"
|
||||||
|
|
||||||
|
Koa.object "pluginList", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"chapters" => "$pluginChapterAry?",
|
||||||
|
"title" => "string?",
|
||||||
|
"error" => "string?",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "pluginDownload", {
|
||||||
|
"plugin" => "string",
|
||||||
|
"title" => "string",
|
||||||
|
"chapters" => "$pluginChapterAry",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "dimension", {
|
||||||
|
"width" => "integer",
|
||||||
|
"height" => "integer",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.array "dimensionAry", "$dimension"
|
||||||
|
|
||||||
|
Koa.object "dimensionResult", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"dimensions" => "$dimensionAry?",
|
||||||
|
"margin" => "number",
|
||||||
|
"error" => "string?",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "ids", {
|
||||||
|
"ids" => "$strAry",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "tagsResult", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"tags" => "$strAry?",
|
||||||
|
"error" => "string?",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.describe "Returns a page in a manga entry"
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.path "eid", desc: "Entry ID"
|
||||||
|
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
|
||||||
|
Koa.response 200, ref: "$binary", media_type: "image/*"
|
||||||
|
Koa.response 500, "Page not found or not readable"
|
||||||
get "/api/page/:tid/:eid/:page" do |env|
|
get "/api/page/:tid/:eid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
|
|
||||||
title = @context.library.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
@@ -20,18 +188,23 @@ class APIRouter < Router
|
|||||||
|
|
||||||
send_img env, img
|
send_img env, img
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the cover image of a manga entry"
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.path "eid", desc: "Entry ID"
|
||||||
|
Koa.response 200, ref: "$binary", media_type: "image/*"
|
||||||
|
Koa.response 500, "Page not found or not readable"
|
||||||
get "/api/cover/:tid/:eid" do |env|
|
get "/api/cover/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
|
||||||
title = @context.library.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
@@ -42,58 +215,75 @@ class APIRouter < Router
|
|||||||
|
|
||||||
send_img env, img
|
send_img env, img
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the book with title `tid`"
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.response 200, ref: "$title"
|
||||||
|
Koa.response 404, "Title not found"
|
||||||
get "/api/book/:tid" do |env|
|
get "/api/book/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = @context.library.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
send_json env, title.to_json
|
send_json env, title.to_json
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 404
|
||||||
e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/book" do |env|
|
Koa.describe "Returns the entire library with all titles and entries"
|
||||||
send_json env, @context.library.to_json
|
Koa.response 200, ref: "$library"
|
||||||
|
get "/api/library" do |env|
|
||||||
|
send_json env, Library.default.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Triggers a library scan"
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.response 200, ref: "$scanResult"
|
||||||
post "/api/admin/scan" do |env|
|
post "/api/admin/scan" do |env|
|
||||||
start = Time.utc
|
start = Time.utc
|
||||||
@context.library.scan
|
Library.default.scan
|
||||||
ms = (Time.utc - start).total_milliseconds
|
ms = (Time.utc - start).total_milliseconds
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"milliseconds" => ms,
|
"milliseconds" => ms,
|
||||||
"titles" => @context.library.titles.size,
|
"titles" => Library.default.titles.size,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.response 200, ref: "$progressResult"
|
||||||
get "/api/admin/thumbnail_progress" do |env|
|
get "/api/admin/thumbnail_progress" do |env|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"progress" => Library.default.thumbnail_generation_progress,
|
"progress" => Library.default.thumbnail_generation_progress,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Triggers a thumbnail generation"
|
||||||
|
Koa.tag "admin"
|
||||||
post "/api/admin/generate_thumbnails" do |env|
|
post "/api/admin/generate_thumbnails" do |env|
|
||||||
spawn do
|
spawn do
|
||||||
Library.default.generate_thumbnails
|
Library.default.generate_thumbnails
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/admin/user/delete/:username" do |env|
|
Koa.describe "Deletes a user with `username`"
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
delete "/api/admin/user/delete/:username" do |env|
|
||||||
begin
|
begin
|
||||||
username = env.params.url["username"]
|
username = env.params.url["username"]
|
||||||
@context.storage.delete_user username
|
Storage.default.delete_user username
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -103,13 +293,24 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/progress/:title/:page" do |env|
|
Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
|
||||||
|
When `eid` is provided, sets the reading progress of the entry to `page`.
|
||||||
|
|
||||||
|
When `eid` is omitted, updates the progress of the entire title. Specifically:
|
||||||
|
|
||||||
|
- if `page` is 0, marks the entire title as unread
|
||||||
|
- otherwise, marks the entire title as read
|
||||||
|
MD
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.query "eid", desc: "Entry ID", required: false
|
||||||
|
Koa.path "page", desc: "The new page number indicating the progress"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
put "/api/progress/:tid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
title = (@context.library.get_title env.params.url["title"])
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
.not_nil!
|
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
entry_id = env.params.query["entry"]?
|
entry_id = env.params.query["eid"]?
|
||||||
|
|
||||||
if !entry_id.nil?
|
if !entry_id.nil?
|
||||||
entry = title.get_entry(entry_id).not_nil!
|
entry = title.get_entry(entry_id).not_nil!
|
||||||
@@ -121,7 +322,7 @@ class APIRouter < Router
|
|||||||
title.read_all username
|
title.read_all username
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -131,10 +332,15 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/bulk-progress/:action/:title" do |env|
|
Koa.describe "Updates the reading progress of multiple entries in a title"
|
||||||
|
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.body ref: "$ids", desc: "An array of entry IDs"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
put "/api/bulk_progress/:action/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
action = env.params.url["action"]
|
action = env.params.url["action"]
|
||||||
ids = env.params.json["ids"].as(Array).map &.as_s
|
ids = env.params.json["ids"].as(Array).map &.as_s
|
||||||
|
|
||||||
@@ -143,7 +349,7 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
title.bulk_progress action, ids, username
|
title.bulk_progress action, ids, username
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -153,12 +359,20 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/admin/display_name/:title/:name" do |env|
|
Koa.describe "Sets the display name of a title or an entry", <<-MD
|
||||||
|
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.query "eid", desc: "Entry ID", required: false
|
||||||
|
Koa.path "name", desc: "The new display name"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
put "/api/admin/display_name/:tid/:name" do |env|
|
||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["title"])
|
title = (Library.default.get_title env.params.url["tid"])
|
||||||
.not_nil!
|
.not_nil!
|
||||||
name = env.params.url["name"]
|
name = env.params.url["name"]
|
||||||
entry = env.params.query["entry"]?
|
entry = env.params.query["eid"]?
|
||||||
if entry.nil?
|
if entry.nil?
|
||||||
title.set_display_name name
|
title.set_display_name name
|
||||||
else
|
else
|
||||||
@@ -166,7 +380,7 @@ class APIRouter < Router
|
|||||||
title.set_display_name eobj.not_nil!.title, name
|
title.set_display_name eobj.not_nil!.title, name
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -176,6 +390,12 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
||||||
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.path "id", desc: "A MangaDex manga ID"
|
||||||
|
Koa.response 200, ref: "$mangadexManga"
|
||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
@@ -183,11 +403,17 @@ class APIRouter < Router
|
|||||||
manga = api.get_manga id
|
manga = api.get_manga id
|
||||||
send_json env, manga.to_info_json
|
send_json env, manga.to_info_json
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {"error" => e.message}.to_json
|
send_json env, {"error" => e.message}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
|
||||||
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.body ref: "$chaptersObj"
|
||||||
|
Koa.response 200, ref: "$successFailCount"
|
||||||
post "/api/admin/mangadex/download" do |env|
|
post "/api/admin/mangadex/download" do |env|
|
||||||
begin
|
begin
|
||||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||||
@@ -201,23 +427,40 @@ class APIRouter < Router
|
|||||||
Time.unix chapter["time"].as_s.to_i
|
Time.unix chapter["time"].as_s.to_i
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
inserted_count = @context.queue.push jobs
|
inserted_count = Queue.default.push jobs
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success": inserted_count,
|
"success": inserted_count,
|
||||||
"fail": jobs.size - inserted_count,
|
"fail": jobs.size - inserted_count,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
send_json env, {"error" => e.message}.to_json
|
send_json env, {"error" => e.message}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ws "/api/admin/mangadex/queue" do |socket, env|
|
||||||
|
interval_raw = env.params.query["interval"]?
|
||||||
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
|
loop do
|
||||||
|
socket.send({
|
||||||
|
"jobs" => Queue.default.get_all,
|
||||||
|
"paused" => Queue.default.paused?,
|
||||||
|
}.to_json)
|
||||||
|
sleep interval.seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the current download queue", <<-MD
|
||||||
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.response 200, ref: "$jobs"
|
||||||
get "/api/admin/mangadex/queue" do |env|
|
get "/api/admin/mangadex/queue" do |env|
|
||||||
begin
|
begin
|
||||||
jobs = @context.queue.get_all
|
jobs = Queue.default.get_all
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"jobs" => jobs,
|
"jobs" => jobs,
|
||||||
"paused" => @context.queue.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
"success" => true,
|
"success" => true,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
@@ -228,6 +471,19 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD
|
||||||
|
The `action` parameter can be `delete`, `retry`, `pause` or `resume`.
|
||||||
|
|
||||||
|
When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively.
|
||||||
|
|
||||||
|
When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs in the queue.
|
||||||
|
|
||||||
|
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
|
||||||
|
Koa.query "id", required: false, desc: "A job ID"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
post "/api/admin/mangadex/queue/:action" do |env|
|
post "/api/admin/mangadex/queue/:action" do |env|
|
||||||
begin
|
begin
|
||||||
action = env.params.url["action"]
|
action = env.params.url["action"]
|
||||||
@@ -235,20 +491,20 @@ class APIRouter < Router
|
|||||||
case action
|
case action
|
||||||
when "delete"
|
when "delete"
|
||||||
if id.nil?
|
if id.nil?
|
||||||
@context.queue.delete_status Queue::JobStatus::Completed
|
Queue.default.delete_status Queue::JobStatus::Completed
|
||||||
else
|
else
|
||||||
@context.queue.delete id
|
Queue.default.delete id
|
||||||
end
|
end
|
||||||
when "retry"
|
when "retry"
|
||||||
if id.nil?
|
if id.nil?
|
||||||
@context.queue.reset
|
Queue.default.reset
|
||||||
else
|
else
|
||||||
@context.queue.reset id
|
Queue.default.reset id
|
||||||
end
|
end
|
||||||
when "pause"
|
when "pause"
|
||||||
@context.queue.pause
|
Queue.default.pause
|
||||||
when "resume"
|
when "resume"
|
||||||
@context.queue.resume
|
Queue.default.resume
|
||||||
else
|
else
|
||||||
raise "Unknown queue action #{action}"
|
raise "Unknown queue action #{action}"
|
||||||
end
|
end
|
||||||
@@ -262,6 +518,22 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Uploads a file to the server", <<-MD
|
||||||
|
Currently the only supported value for the `target` parameter is `cover`.
|
||||||
|
|
||||||
|
### Cover
|
||||||
|
|
||||||
|
Uploads a cover image for a title or an entry.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `tid`: A title ID
|
||||||
|
- `eid`: (Optional) An entry ID
|
||||||
|
|
||||||
|
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
|
||||||
|
MD
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.body type: "multipart/form-data", ref: "$binaryUpload"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
post "/api/admin/upload/:target" do |env|
|
post "/api/admin/upload/:target" do |env|
|
||||||
begin
|
begin
|
||||||
target = env.params.url["target"]
|
target = env.params.url["target"]
|
||||||
@@ -276,9 +548,9 @@ class APIRouter < Router
|
|||||||
|
|
||||||
case target
|
case target
|
||||||
when "cover"
|
when "cover"
|
||||||
title_id = env.params.query["title"]
|
title_id = env.params.query["tid"]
|
||||||
entry_id = env.params.query["entry"]?
|
entry_id = env.params.query["eid"]?
|
||||||
title = @context.library.get_title(title_id).not_nil!
|
title = Library.default.get_title(title_id).not_nil!
|
||||||
|
|
||||||
unless SUPPORTED_IMG_TYPES.includes? \
|
unless SUPPORTED_IMG_TYPES.includes? \
|
||||||
MIME.from_filename? filename
|
MIME.from_filename? filename
|
||||||
@@ -316,10 +588,14 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/admin/plugin/list" do |env|
|
Koa.describe "Lists the chapters in a title from a plugin"
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.body ref: "$pluginListBody"
|
||||||
|
Koa.response 200, ref: "$pluginList"
|
||||||
|
get "/api/admin/plugin/list" do |env|
|
||||||
begin
|
begin
|
||||||
query = env.params.json["query"].as String
|
query = env.params.query["query"].as String
|
||||||
plugin = Plugin.new env.params.json["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
json = plugin.list_chapters query
|
||||||
chapters = json["chapters"]
|
chapters = json["chapters"]
|
||||||
@@ -338,6 +614,10 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Adds a list of chapters from a plugin to the download queue"
|
||||||
|
Koa.tag "admin"
|
||||||
|
Koa.body ref: "$pluginDownload"
|
||||||
|
Koa.response 200, ref: "$successFailCount"
|
||||||
post "/api/admin/plugin/download" do |env|
|
post "/api/admin/plugin/download" do |env|
|
||||||
begin
|
begin
|
||||||
plugin = Plugin.new env.params.json["plugin"].as String
|
plugin = Plugin.new env.params.json["plugin"].as String
|
||||||
@@ -354,7 +634,7 @@ class APIRouter < Router
|
|||||||
Time.utc
|
Time.utc
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
inserted_count = @context.queue.push jobs
|
inserted_count = Queue.default.push jobs
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success": inserted_count,
|
"success": inserted_count,
|
||||||
"fail": jobs.size - inserted_count,
|
"fail": jobs.size - inserted_count,
|
||||||
@@ -367,12 +647,16 @@ class APIRouter < Router
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the image dimensions of all pages in an entry"
|
||||||
|
Koa.path "tid", desc: "A title ID"
|
||||||
|
Koa.path "eid", desc: "An entry ID"
|
||||||
|
Koa.response 200, ref: "$dimensionResult"
|
||||||
get "/api/dimensions/:tid/:eid" do |env|
|
get "/api/dimensions/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
|
|
||||||
title = @context.library.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
@@ -381,6 +665,7 @@ class APIRouter < Router
|
|||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
"dimensions" => sizes,
|
"dimensions" => sizes,
|
||||||
|
"margin" => Config.current.page_margin,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
@@ -389,5 +674,118 @@ class APIRouter < Router
|
|||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Downloads an entry"
|
||||||
|
Koa.path "tid", desc: "A title ID"
|
||||||
|
Koa.path "eid", desc: "An entry ID"
|
||||||
|
Koa.response 200, ref: "$binary"
|
||||||
|
Koa.response 404, "Entry not found"
|
||||||
|
get "/api/download/:tid/:eid" do |env|
|
||||||
|
begin
|
||||||
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
|
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||||
|
|
||||||
|
send_attachment env, entry.zip_path
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Gets the tags of a title"
|
||||||
|
Koa.path "tid", desc: "A title ID"
|
||||||
|
Koa.response 200, ref: "$tagsResult"
|
||||||
|
get "/api/tags/:tid" do |env|
|
||||||
|
begin
|
||||||
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
|
tags = title.tags
|
||||||
|
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"tags" => tags,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns all tags"
|
||||||
|
Koa.response 200, ref: "$tagsResult"
|
||||||
|
get "/api/tags" do |env|
|
||||||
|
begin
|
||||||
|
tags = Storage.default.list_tags
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"tags" => tags,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Adds a new tag to a title"
|
||||||
|
Koa.path "tid", desc: "A title ID"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
put "/api/admin/tags/:tid/:tag" do |env|
|
||||||
|
begin
|
||||||
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
|
tag = env.params.url["tag"]
|
||||||
|
|
||||||
|
title.add_tag tag
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes a tag from a title"
|
||||||
|
Koa.path "tid", desc: "A title ID"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
delete "/api/admin/tags/:tid/:tag" do |env|
|
||||||
|
begin
|
||||||
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
|
tag = env.params.url["tag"]
|
||||||
|
|
||||||
|
title.delete_tag tag
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
doc = Koa.generate
|
||||||
|
@@api_json = doc.to_json if doc
|
||||||
|
|
||||||
|
get "/openapi.json" do |env|
|
||||||
|
if @@api_json
|
||||||
|
send_json env, @@api_json
|
||||||
|
else
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+60
-17
@@ -1,6 +1,4 @@
|
|||||||
require "./router"
|
struct MainRouter
|
||||||
|
|
||||||
class MainRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/login" do |env|
|
get "/login" do |env|
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
@@ -11,7 +9,7 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
env.session.delete_string "token"
|
env.session.delete_string "token"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error "Error when attempting to log out: #{e}"
|
Logger.error "Error when attempting to log out: #{e}"
|
||||||
ensure
|
ensure
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
@@ -21,7 +19,7 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
username = env.params.body["username"]
|
username = env.params.body["username"]
|
||||||
password = env.params.body["password"]
|
password = env.params.body["password"]
|
||||||
token = @context.storage.verify_user(username, password).not_nil!
|
token = Storage.default.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
|
|
||||||
@@ -41,22 +39,22 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json @context.library.dir, username
|
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||||
get_sort_opt
|
get_sort_opt
|
||||||
|
|
||||||
titles = @context.library.sorted_titles username, sort_opt
|
titles = Library.default.sorted_titles username, sort_opt
|
||||||
percentage = titles.map &.load_percentage username
|
percentage = titles.map &.load_percentage username
|
||||||
|
|
||||||
layout "library"
|
layout "library"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/book/:title" do |env|
|
get "/book/:title" do |env|
|
||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
@@ -68,7 +66,7 @@ class MainRouter < Router
|
|||||||
title_percentage = title.titles.map &.load_percentage username
|
title_percentage = title.titles.map &.load_percentage username
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -92,7 +90,7 @@ class MainRouter < Router
|
|||||||
|
|
||||||
layout "plugin-download"
|
layout "plugin-download"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -100,18 +98,63 @@ class MainRouter < Router
|
|||||||
get "/" do |env|
|
get "/" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
continue_reading = @context
|
continue_reading = Library.default
|
||||||
.library.get_continue_reading_entries username
|
.get_continue_reading_entries username
|
||||||
recently_added = @context.library.get_recently_added_entries username
|
recently_added = Library.default.get_recently_added_entries username
|
||||||
start_reading = @context.library.get_start_reading_titles username
|
start_reading = Library.default.get_start_reading_titles username
|
||||||
titles = @context.library.titles
|
titles = Library.default.titles
|
||||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||||
empty_library = titles.size == 0
|
empty_library = titles.size == 0
|
||||||
layout "home"
|
layout "home"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/tags/:tag" do |env|
|
||||||
|
begin
|
||||||
|
username = get_username env
|
||||||
|
tag = env.params.url["tag"]
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new
|
||||||
|
get_sort_opt
|
||||||
|
|
||||||
|
title_ids = Storage.default.get_tag_titles tag
|
||||||
|
|
||||||
|
raise "Tag #{tag} not found" if title_ids.empty?
|
||||||
|
|
||||||
|
titles = title_ids.map { |id| Library.default.get_title id }
|
||||||
|
.select Title
|
||||||
|
|
||||||
|
titles = sort_titles titles, sort_opt, username
|
||||||
|
percentage = titles.map &.load_percentage username
|
||||||
|
|
||||||
|
layout "tag"
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/tags" do |env|
|
||||||
|
tags = Storage.default.list_tags.map do |tag|
|
||||||
|
{
|
||||||
|
tag: tag,
|
||||||
|
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
|
||||||
|
count: Storage.default.get_tag_titles(tag).size,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
# Sort by :count reversly, and then sort by :tag
|
||||||
|
tags.sort! do |a, b|
|
||||||
|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
|
||||||
|
end
|
||||||
|
|
||||||
|
layout "tags"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api" do |env|
|
||||||
|
render "src/views/api.html.ecr"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+4
-18
@@ -1,30 +1,16 @@
|
|||||||
require "./router"
|
struct OPDSRouter
|
||||||
|
|
||||||
class OPDSRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/opds" do |env|
|
get "/opds" do |env|
|
||||||
titles = @context.library.titles
|
titles = Library.default.titles
|
||||||
render_xml "src/views/opds/index.xml.ecr"
|
render_xml "src/views/opds/index.xml.ecr"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/opds/book/:title_id" do |env|
|
get "/opds/book/:title_id" do |env|
|
||||||
begin
|
begin
|
||||||
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
|
title = Library.default.get_title(env.params.url["title_id"]).not_nil!
|
||||||
render_xml "src/views/opds/title.xml.ecr"
|
render_xml "src/views/opds/title.xml.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/opds/download/:title/:entry" do |env|
|
|
||||||
begin
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
|
||||||
|
|
||||||
send_attachment env, entry.zip_path
|
|
||||||
rescue e
|
|
||||||
@context.error e
|
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+12
-12
@@ -1,25 +1,23 @@
|
|||||||
require "./router"
|
struct ReaderRouter
|
||||||
|
|
||||||
class ReaderRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/reader/:title/:entry" do |env|
|
get "/reader/:title/:entry" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
|
||||||
next layout "reader-error" if entry.err_msg
|
next layout "reader-error" if entry.err_msg
|
||||||
|
|
||||||
# load progress
|
# load progress
|
||||||
page = [1, entry.load_progress username].max
|
page_idx = [1, entry.load_progress username].max
|
||||||
|
|
||||||
# start from page 1 if the user has finished reading the entry
|
# start from page 1 if the user has finished reading the entry
|
||||||
page = 1 if entry.finished? username
|
page_idx = 1 if entry.finished? username
|
||||||
|
|
||||||
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
|
redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -30,10 +28,12 @@ class ReaderRouter < Router
|
|||||||
|
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
page = env.params.url["page"].to_i
|
page_idx = env.params.url["page"].to_i
|
||||||
raise "" if page > entry.pages || page <= 0
|
if page_idx > entry.pages || page_idx <= 0
|
||||||
|
raise "Page #{page_idx} not found."
|
||||||
|
end
|
||||||
|
|
||||||
exit_url = "#{base_url}book/#{title.id}"
|
exit_url = "#{base_url}book/#{title.id}"
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class ReaderRouter < Router
|
|||||||
|
|
||||||
render "src/views/reader.html.ecr"
|
render "src/views/reader.html.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
class Router
|
|
||||||
@context : Context = Context.default
|
|
||||||
end
|
|
||||||
+3
-29
@@ -5,34 +5,8 @@ require "./handlers/*"
|
|||||||
require "./util/*"
|
require "./util/*"
|
||||||
require "./routes/*"
|
require "./routes/*"
|
||||||
|
|
||||||
class Context
|
|
||||||
property library : Library
|
|
||||||
property storage : Storage
|
|
||||||
property queue : Queue
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@storage = Storage.default
|
|
||||||
@library = Library.default
|
|
||||||
@queue = Queue.default
|
|
||||||
end
|
|
||||||
|
|
||||||
{% for lvl in Logger::LEVELS %}
|
|
||||||
def {{lvl.id}}(msg)
|
|
||||||
Logger.{{lvl.id}} msg
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
class Server
|
class Server
|
||||||
@context : Context = Context.default
|
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
error 403 do |env|
|
|
||||||
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
|
|
||||||
layout "message"
|
|
||||||
end
|
|
||||||
error 404 do |env|
|
error 404 do |env|
|
||||||
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
||||||
layout "message"
|
layout "message"
|
||||||
@@ -53,11 +27,11 @@ class Server
|
|||||||
|
|
||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new
|
||||||
add_handler AuthHandler.new @context.storage
|
add_handler AuthHandler.new
|
||||||
add_handler UploadHandler.new Config.current.upload_path
|
add_handler UploadHandler.new Config.current.upload_path
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
# when building for relase, embed the static files in binary
|
# when building for relase, embed the static files in binary
|
||||||
@context.debug "We are in release mode. Using embedded static files."
|
Logger.debug "We are in release mode. Using embedded static files."
|
||||||
serve_static false
|
serve_static false
|
||||||
add_handler StaticHandler.new
|
add_handler StaticHandler.new
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -71,7 +45,7 @@ class Server
|
|||||||
end
|
end
|
||||||
|
|
||||||
def start
|
def start
|
||||||
@context.debug "Starting Kemal server"
|
Logger.debug "Starting Kemal server"
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
Kemal.config.env = "production"
|
Kemal.config.env = "production"
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|||||||
+131
-38
@@ -3,6 +3,8 @@ require "crypto/bcrypt"
|
|||||||
require "uuid"
|
require "uuid"
|
||||||
require "base64"
|
require "base64"
|
||||||
require "./util/*"
|
require "./util/*"
|
||||||
|
require "mg"
|
||||||
|
require "../migration/*"
|
||||||
|
|
||||||
def hash_password(pw)
|
def hash_password(pw)
|
||||||
Crypto::Bcrypt::Password.create(pw).to_s
|
Crypto::Bcrypt::Password.create(pw).to_s
|
||||||
@@ -13,9 +15,10 @@ def verify_password(hash, pw)
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
|
@@insert_ids = [] of IDTuple
|
||||||
|
|
||||||
@path : String
|
@path : String
|
||||||
@db : DB::Database?
|
@db : DB::Database?
|
||||||
@insert_ids = [] of IDTuple
|
|
||||||
|
|
||||||
alias IDTuple = NamedTuple(path: String,
|
alias IDTuple = NamedTuple(path: String,
|
||||||
id: String,
|
id: String,
|
||||||
@@ -35,34 +38,21 @@ class Storage
|
|||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
db.exec "create table thumbnails " \
|
MG::Migration.new(db, log: Logger.default.raw_log).migrate
|
||||||
"(id text, data blob, filename text, " \
|
|
||||||
"mime text, size integer)"
|
|
||||||
db.exec "create unique index tn_index on thumbnails (id)"
|
|
||||||
|
|
||||||
db.exec "create table ids" \
|
|
||||||
"(path text, id text, is_title integer)"
|
|
||||||
db.exec "create unique index path_idx on ids (path)"
|
|
||||||
db.exec "create unique index id_idx on ids (id)"
|
|
||||||
|
|
||||||
db.exec "create table users" \
|
|
||||||
"(username text, password text, token text, admin integer)"
|
|
||||||
rescue e
|
rescue e
|
||||||
unless e.message.not_nil!.ends_with? "already exists"
|
Logger.fatal "DB migration failed. #{e}"
|
||||||
Logger.fatal "Error when checking tables in DB: #{e}"
|
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
|
||||||
# If the DB is initialized through CLI but no user is added, we need
|
|
||||||
# to create the admin user when first starting the app
|
|
||||||
user_count = db.query_one "select count(*) from users", as: Int32
|
user_count = db.query_one "select count(*) from users", as: Int32
|
||||||
init_admin if init_user && user_count == 0
|
init_admin if init_user && user_count == 0
|
||||||
else
|
|
||||||
Logger.debug "Creating DB file at #{@path}"
|
|
||||||
db.exec "create unique index username_idx on users (username)"
|
|
||||||
db.exec "create unique index token_idx on users (token)"
|
|
||||||
|
|
||||||
init_admin if init_user
|
# Verifies that the default username in config is valid
|
||||||
|
if Config.current.disable_login
|
||||||
|
username = Config.current.default_username
|
||||||
|
unless username_exists username
|
||||||
|
raise "Default username #{username} does not exist"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
unless @auto_close
|
unless @auto_close
|
||||||
@@ -83,13 +73,37 @@ class Storage
|
|||||||
private def get_db(&block : DB::Database ->)
|
private def get_db(&block : DB::Database ->)
|
||||||
if @db.nil?
|
if @db.nil?
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "PRAGMA foreign_keys = 1"
|
||||||
yield db
|
yield db
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
|
||||||
yield @db.not_nil!
|
yield @db.not_nil!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def username_exists(username)
|
||||||
|
exists = false
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
exists = db.query_one("select count(*) from users where " \
|
||||||
|
"username = (?)", username, as: Int32) > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
exists
|
||||||
|
end
|
||||||
|
|
||||||
|
def username_is_admin(username)
|
||||||
|
is_admin = false
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
is_admin = db.query_one("select admin from users where " \
|
||||||
|
"username = (?)", username, as: Int32) > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
is_admin
|
||||||
|
end
|
||||||
|
|
||||||
def verify_user(username, password)
|
def verify_user(username, password)
|
||||||
out_token = nil
|
out_token = nil
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
@@ -220,28 +234,38 @@ class Storage
|
|||||||
id = nil
|
id = nil
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
|
if is_title
|
||||||
|
id = db.query_one? "select id from titles where path = (?)", path,
|
||||||
|
as: String
|
||||||
|
else
|
||||||
id = db.query_one? "select id from ids where path = (?)", path,
|
id = db.query_one? "select id from ids where path = (?)", path,
|
||||||
as: {String}
|
as: String
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
id
|
id
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_id(tp : IDTuple)
|
def insert_id(tp : IDTuple)
|
||||||
@insert_ids << tp
|
@@insert_ids << tp
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_insert_ids
|
def bulk_insert_ids
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
db.transaction do |tx|
|
db.transaction do |tran|
|
||||||
@insert_ids.each do |tp|
|
conn = tran.connection
|
||||||
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
|
@@insert_ids.each do |tp|
|
||||||
tp[:id], tp[:is_title] ? 1 : 0
|
if tp[:is_title]
|
||||||
|
conn.exec "insert into titles values (?, ?, null)", tp[:id],
|
||||||
|
tp[:path]
|
||||||
|
else
|
||||||
|
conn.exec "insert into ids values (?, ?)", tp[:path], tp[:id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@insert_ids.clear
|
end
|
||||||
|
@@insert_ids.clear
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -266,10 +290,75 @@ class Storage
|
|||||||
img
|
img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_title_tags(id : String) : Array(String)
|
||||||
|
tags = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
|
||||||
|
rs.each do
|
||||||
|
tags << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_tag_titles(tag : String) : Array(String)
|
||||||
|
tids = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id from tags where tag = (?)", tag do |rs|
|
||||||
|
rs.each do
|
||||||
|
tids << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tids
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_tags : Array(String)
|
||||||
|
tags = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select distinct tag from tags" do |rs|
|
||||||
|
rs.each do
|
||||||
|
tags << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_tag(id : String, tag : String)
|
||||||
|
err = nil
|
||||||
|
MainFiber.run do
|
||||||
|
begin
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "insert into tags values (?, ?)", id, tag
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
err = e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
raise err.not_nil! if err
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_tag(id : String, tag : String)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def optimize
|
def optimize
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
Logger.info "Starting DB optimization"
|
Logger.info "Starting DB optimization"
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
|
# Delete dangling entry IDs
|
||||||
trash_ids = [] of String
|
trash_ids = [] of String
|
||||||
db.query "select path, id from ids" do |rs|
|
db.query "select path, id from ids" do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
@@ -278,21 +367,25 @@ class Storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Delete dangling IDs
|
|
||||||
db.exec "delete from ids where id in " \
|
db.exec "delete from ids where id in " \
|
||||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
||||||
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
|
Logger.debug "#{trash_ids.size} dangling entry IDs deleted" \
|
||||||
if trash_ids.size > 0
|
if trash_ids.size > 0
|
||||||
|
|
||||||
# Delete dangling thumbnails
|
# Delete dangling title IDs
|
||||||
trash_thumbnails_count = db.query_one "select count(*) from " \
|
trash_titles = [] of String
|
||||||
"thumbnails where id not in " \
|
db.query "select path, id from titles" do |rs|
|
||||||
"(select id from ids)", as: Int32
|
rs.each do
|
||||||
if trash_thumbnails_count > 0
|
path = rs.read String
|
||||||
db.exec "delete from thumbnails where id not in (select id from ids)"
|
trash_titles << rs.read String unless Dir.exists? path
|
||||||
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
db.exec "delete from titles where id in " \
|
||||||
|
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
||||||
|
Logger.debug "#{trash_titles.size} dangling title IDs deleted" \
|
||||||
|
if trash_titles.size > 0
|
||||||
|
end
|
||||||
Logger.info "DB optimization finished"
|
Logger.info "DB optimization finished"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@ private def env_to_proxy(key : String) : HTTP::Proxy::Client?
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
uri = URI.parse val
|
uri = URI.parse val
|
||||||
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!
|
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
|
||||||
|
username: uri.user, password: uri.password
|
||||||
rescue
|
rescue
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -61,3 +61,34 @@ class String
|
|||||||
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def env_is_true?(key : String) : Bool
|
||||||
|
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||||
|
return false unless val
|
||||||
|
val.downcase.in? "1", "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
||||||
|
ary = titles
|
||||||
|
|
||||||
|
case opt.method
|
||||||
|
when .time_modified?
|
||||||
|
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
|
compare_numerically a.title, b.title }
|
||||||
|
when .progress?
|
||||||
|
ary.sort! do |a, b|
|
||||||
|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||||
|
compare_numerically a.title, b.title
|
||||||
|
end
|
||||||
|
else
|
||||||
|
unless opt.method.auto?
|
||||||
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
|
"Auto instead"
|
||||||
|
end
|
||||||
|
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||||
|
end
|
||||||
|
|
||||||
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|||||||
+43
-15
@@ -1,30 +1,58 @@
|
|||||||
# Web related helper functions/macros
|
# Web related helper functions/macros
|
||||||
|
|
||||||
|
# This macro defines `is_admin` when used
|
||||||
|
macro check_admin_access
|
||||||
|
is_admin = false
|
||||||
|
# The token (if exists) takes precedence over the default user option.
|
||||||
|
# this is why we check the default username first before checking the
|
||||||
|
# token.
|
||||||
|
if Config.current.disable_login
|
||||||
|
is_admin = Storage.default.
|
||||||
|
username_is_admin Config.current.default_username
|
||||||
|
end
|
||||||
|
if token = env.session.string? "token"
|
||||||
|
is_admin = Storage.default.verify_admin token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
|
check_admin_access
|
||||||
begin
|
begin
|
||||||
is_admin = false
|
|
||||||
if token = env.session.string? "token"
|
|
||||||
is_admin = @context.storage.verify_admin token
|
|
||||||
end
|
|
||||||
page = {{name}}
|
page = {{name}}
|
||||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
message = e.to_s
|
message = e.to_s
|
||||||
@context.error message
|
Logger.error message
|
||||||
|
page = "Error"
|
||||||
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro send_error_page(msg)
|
||||||
|
message = {{msg}}
|
||||||
|
base_url = Config.current.base_url
|
||||||
|
check_admin_access
|
||||||
|
page = "Error"
|
||||||
|
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||||
|
send_file env, html.to_slice, "text/html"
|
||||||
|
end
|
||||||
|
|
||||||
macro send_img(env, img)
|
macro send_img(env, img)
|
||||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||||
end
|
end
|
||||||
|
|
||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
# if the request gets here, it has gone through the auth handler, and
|
begin
|
||||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
|
||||||
token = env.session.string "token"
|
token = env.session.string "token"
|
||||||
(@context.storage.verify_token token).not_nil!
|
(Storage.default.verify_token token).not_nil!
|
||||||
|
rescue e
|
||||||
|
if Config.current.disable_login
|
||||||
|
Config.current.default_username
|
||||||
|
else
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
@@ -46,12 +74,7 @@ def hash_to_query(hash)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def request_path_startswith(env, ary)
|
def request_path_startswith(env, ary)
|
||||||
ary.each do |prefix|
|
ary.any? { |prefix| env.request.path.starts_with? prefix }
|
||||||
if env.request.path.starts_with? prefix
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def requesting_static_file(env)
|
def requesting_static_file(env)
|
||||||
@@ -85,9 +108,14 @@ end
|
|||||||
module HTTP
|
module HTTP
|
||||||
class Client
|
class Client
|
||||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||||
Logger.debug "Setting read timeout"
|
|
||||||
previous_def uri, tls do |client, path|
|
previous_def uri, tls do |client, path|
|
||||||
|
if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
|
||||||
|
Logger.debug "Disabling SSL verification"
|
||||||
|
client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
|
||||||
|
end
|
||||||
|
Logger.debug "Setting read timeout"
|
||||||
client.read_timeout = Config.current.download_timeout_seconds.seconds
|
client.read_timeout = Config.current.download_timeout_seconds.seconds
|
||||||
|
Logger.debug "Requesting #{uri}"
|
||||||
yield client, path
|
yield client, path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}">
|
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
|
||||||
<li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li>
|
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
|
||||||
<li :class="{'nopointer' : scanning}" @click="scan()">
|
<li>
|
||||||
|
<a class="uk-link-reset" @click="scan()">
|
||||||
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
|
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
|
||||||
<div class="uk-align-right">
|
<div class="uk-align-right">
|
||||||
<div uk-spinner x-show="scanning"></div>
|
<div uk-spinner x-show="scanning"></div>
|
||||||
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
|
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li :class="{'nopointer' : generating}" @click="generateThumbnails()">
|
<li>
|
||||||
|
<a class="uk-link-reset" @click="generateThumbnails()">
|
||||||
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
|
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
|
||||||
<div class="uk-align-right">
|
<div class="uk-align-right">
|
||||||
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
|
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nopointer">
|
<li>
|
||||||
<span>Theme</span>
|
<span>Theme</span>
|
||||||
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2">
|
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
|
||||||
<option>Dark</option>
|
<option>Dark</option>
|
||||||
<option>Light</option>
|
<option>Light</option>
|
||||||
<option>System</option>
|
<option>System</option>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Mango API Documentation</title>
|
||||||
|
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url="/openapi.json"></redoc>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
onclick="location='<%= base_url %>book/<%= item.id %>'"
|
onclick="location='<%= base_url %>book/<%= item.id %>'"
|
||||||
<% end %>>
|
<% end %>>
|
||||||
|
|
||||||
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
|
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true, selecting: false}" :class="{selected: selected}" @count.window="selecting = $event.detail.count > 0"
|
||||||
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
|
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
|
||||||
x-init="disabled = false"
|
x-init="disabled = false"
|
||||||
<% end %>>
|
<% end %>>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
class="grayscale"
|
class="grayscale"
|
||||||
<% end %>>
|
<% end %>>
|
||||||
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
|
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
|
||||||
|
<div class="uk-height-1-1 uk-width-1-1" x-show="selecting" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')"></div>
|
||||||
<div class="uk-position-center">
|
<div class="uk-position-center">
|
||||||
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
|
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% if item.is_a? Title %>
|
<% if item.is_a? Title %>
|
||||||
<% if grouped_count == 1 %>
|
<% if grouped_count == 1 %>
|
||||||
<p class="uk-text-meta"><%= item.size %> entries</p>
|
<p class="uk-text-meta"><%= item.content_label %></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
|
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="X-UA-Compatible" content="IE=edge">
|
<meta name="X-UA-Compatible" content="IE=edge">
|
||||||
<title>Mango</title>
|
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
|
||||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
|
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
||||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||||
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
|
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
|
||||||
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
|
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
|
||||||
<script src="<%= base_url %>js/theme.js"></script>
|
<script src="<%= base_url %>js/common.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<div class="uk-margin">
|
<div x-data="component()" x-init="init()">
|
||||||
<div id="actions" class="uk-margin">
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
|
||||||
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
|
||||||
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
||||||
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="config" class="uk-margin">
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Chapter</th>
|
<th>Chapter</th>
|
||||||
@@ -21,12 +17,52 @@
|
|||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
<tbody>
|
||||||
|
<template x-for="job in jobs" :key="job">
|
||||||
|
<tr :id="`chapter-${job.id}`">
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
|
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<div class="uk-inline">
|
||||||
|
<span uk-icon="info"></span>
|
||||||
|
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script>
|
|
||||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
|
||||||
</script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li class="uk-parent">
|
<li class="uk-parent">
|
||||||
@@ -36,10 +37,11 @@
|
|||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
<div class="uk-navbar-left uk-visible@s">
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
|
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||||
<ul class="uk-navbar-nav">
|
<ul class="uk-navbar-nav">
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li>
|
<li>
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-small">
|
<div class="uk-section uk-section-small">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-small" id="main-section">
|
<div class="uk-section uk-section-small" style="position:relative;">
|
||||||
<div class="uk-container uk-container-small">
|
<div class="uk-container uk-container-small">
|
||||||
<div id="alert"></div>
|
<div id="alert"></div>
|
||||||
<%= content %>
|
<%= content %>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
|
<% page = "Login" %>
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||||
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||||
|
|
||||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||||
|
|
||||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
||||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script>
|
<script>
|
||||||
UIkit.modal('#modal').show().then(function() {
|
UIkit.modal('#modal').show();
|
||||||
styleModal();
|
|
||||||
});
|
|
||||||
UIkit.util.on('#modal', 'hide', function() {
|
UIkit.util.on('#modal', 'hide', function() {
|
||||||
location.href = "<%= base_url %>book/<%= entry.book.id %>";
|
location.href = "<%= base_url %>book/<%= entry.book.id %>";
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-20
@@ -1,21 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="reader-bg">
|
<html style="background-color: black;">
|
||||||
|
|
||||||
|
<% page = "Reader" %>
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body style="position:relative;">
|
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
|
||||||
<div class="uk-section uk-section-default uk-section-small reader-bg"
|
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
|
||||||
id="root"
|
|
||||||
:style="mode === 'continuous' ? '' : 'padding:0'"
|
|
||||||
x-data="{
|
|
||||||
loading: true,
|
|
||||||
mode: 'continuous', // can be 'continuous', 'height' or 'width'
|
|
||||||
msg: 'Loading the web reader. Please wait...',
|
|
||||||
alertClass: 'uk-alert-primary',
|
|
||||||
items: [],
|
|
||||||
curItem: {},
|
|
||||||
flipAnimation: null
|
|
||||||
}">
|
|
||||||
|
|
||||||
<div @keydown.window.debounce="keyHandler($event)"></div>
|
<div @keydown.window.debounce="keyHandler($event)"></div>
|
||||||
|
|
||||||
@@ -35,6 +25,7 @@
|
|||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
class="uk-align-center"
|
class="uk-align-center"
|
||||||
|
:style="item.style"
|
||||||
:data-src="item.url"
|
:data-src="item.url"
|
||||||
:width="item.width"
|
:width="item.width"
|
||||||
:height="item.height"
|
:height="item.height"
|
||||||
@@ -45,7 +36,7 @@
|
|||||||
<%- if next_entry_url -%>
|
<%- if next_entry_url -%>
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
||||||
<%- else -%>
|
<%- else -%>
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button>
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,7 +73,7 @@
|
|||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="page-select">Jump to page</label>
|
<label class="uk-form-label" for="page-select">Jump to page</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select id="page-select" class="uk-select">
|
<select id="page-select" class="uk-select" @change="pageChanged()">
|
||||||
<%- (1..entry.pages).each do |p| -%>
|
<%- (1..entry.pages).each do |p| -%>
|
||||||
<option value="<%= p %>"><%= p %></option>
|
<option value="<%= p %>"><%= p %></option>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
@@ -92,7 +83,7 @@
|
|||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select id="mode-select" class="uk-select">
|
<select id="mode-select" class="uk-select" @change="modeChanged($nextTick)">
|
||||||
<option value="continuous">Continuous</option>
|
<option value="continuous">Continuous</option>
|
||||||
<option value="paged">Paged</option>
|
<option value="paged">Paged</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -100,14 +91,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-footer uk-text-right">
|
<div class="uk-modal-footer uk-text-right">
|
||||||
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const base_url = "<%= base_url %>";
|
const base_url = "<%= base_url %>";
|
||||||
const page = <%= page %>;
|
const page = <%= page_idx %>;
|
||||||
const tid = "<%= title.id %>";
|
const tid = "<%= title.id %>";
|
||||||
const eid = "<%= entry.id %>";
|
const eid = "<%= entry.id %>";
|
||||||
</script>
|
</script>
|
||||||
@@ -120,7 +111,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
img[data-src][src*='data:image'] { background: white; }
|
img[data-src][src*='data:image'] { background: white; }
|
||||||
#root img { width: 100%; }
|
img { width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<h2 class=uk-title>Tag: <%= tag %></h2>
|
||||||
|
<p class="uk-text-meta"><%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> tagged</p>
|
||||||
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
|
<form class="uk-search uk-search-default">
|
||||||
|
<span uk-search-icon></span>
|
||||||
|
<input class="uk-search-input" type="search" placeholder="Search">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
|
<% hash = {
|
||||||
|
"auto" => "Auto",
|
||||||
|
"time_modified" => "Date Modified",
|
||||||
|
"progress" => "Progress"
|
||||||
|
} %>
|
||||||
|
<%= render_component "sort-form" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<% titles.each_with_index do |item, i| %>
|
||||||
|
<% progress = percentage[i] %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "dots-scripts" %>
|
||||||
|
<script src="<%= base_url %>js/search.js"></script>
|
||||||
|
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<h2 class=uk-title>Tags</h2>
|
||||||
|
<p class="uk-text-meta"><%= tags.size %> <%= tags.size > 1 ? "tags" : "tag" %> found</p>
|
||||||
|
|
||||||
|
<% tags.each do |tag| %>
|
||||||
|
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
|
||||||
|
<a class="uk-link-reset" href="<%= base_url %>tags/<%= tag[:encoded_tag] %>"><%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>)</a>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div>
|
<div>
|
||||||
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
|
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++; $dispatch('count', {count: count})" @remove.window="count--; $dispatch('count', {count: count})" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
|
||||||
<div class="uk-child-width-1-3" uk-grid>
|
<div class="uk-child-width-1-3" uk-grid>
|
||||||
<div>
|
<div>
|
||||||
<p x-text="count + ' items selected'" style="color:orange"></p>
|
<p x-text="count + ' items selected'" style="color:orange"></p>
|
||||||
@@ -32,7 +32,13 @@
|
|||||||
<%- end -%>
|
<%- end -%>
|
||||||
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="uk-text-meta"><%= title.size %> entries found</p>
|
<p class="uk-text-meta"><%= title.content_label %> found</p>
|
||||||
|
|
||||||
|
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)" x-show="!loading">
|
||||||
|
<select class="tag-select" multiple="multiple" style="width:100%">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="uk-grid-small" uk-grid>
|
<div class="uk-grid-small" uk-grid>
|
||||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
<form class="uk-search uk-search-default">
|
<form class="uk-search uk-search-default">
|
||||||
@@ -118,6 +124,9 @@
|
|||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "dots-scripts" %>
|
<%= render_component "dots-scripts" %>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<link href="/css/tags.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/title.js"></script>
|
<script src="<%= base_url %>js/title.js"></script>
|
||||||
<script src="<%= base_url %>js/search.js"></script>
|
<script src="<%= base_url %>js/search.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user