mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 604c5d49a6 | |||
| 7449d19075 | |||
| c5c9305a0b | |||
| fdceab9060 | |||
| c18591c5cf | |||
| bb5cb9b94c | |||
| fb499a5caf | |||
| 154d85e197 | |||
| 933617503e | |||
| 31c6893bbb | |||
| 171125e8ac | |||
| d81334026b | |||
| 2b3b2eb8ba | |||
| ffd5f4454b | |||
| cb25d7ba00 | |||
| 3abd2924d0 | |||
| 21233df754 | |||
| c61eb7554e | |||
| edd9a2e093 | |||
| 1f50785e8f | |||
| 70d418d1a1 | |||
| 45e20c94f9 | |||
| ca8e9a164e | |||
| 4da263c594 | |||
| d67a24809b | |||
| cd268af9dd | |||
| 135fa9fde6 | |||
| 77333aaafd | |||
| 1fad530331 | |||
| a1bd87098c | |||
| a389fa7178 | |||
| b5db508005 | |||
| 30178c42ef | |||
| b712db9e8f | |||
| dd9c75d1c9 | |||
| 2d150c3bf2 | |||
| 40f74ea375 | |||
| adf260bc35 | |||
| 432d6f0cd5 | |||
| 3de314ae9a | |||
| c1c8cca877 | |||
| 07965b98b7 | |||
| 5779d225f6 | |||
| bf18a14016 | |||
| 605dc61777 | |||
| def64d9f98 | |||
| 0ba2409c9a | |||
| 2b0cf41336 | |||
| c51cb28df2 | |||
| 2b079c652d | |||
| 68050a9025 | |||
| 54cd15d542 | |||
| 781de97c68 | |||
| c7be0e0e7c | |||
| 667d390be4 | |||
| 7f76322377 | |||
| 377c4c6554 | |||
| 952aa0c6ca | |||
| bd81c2e005 | |||
| b471ed2fa0 | |||
| 7507ab64ad | |||
| e4587d36bc | |||
| 7d6d3640ad | |||
| 3071d44e32 | |||
| 7a09c9006a | |||
| 959560c7a7 | |||
| ff679b30d8 | |||
| f7a360c2d8 | |||
| 1065b430e3 | |||
| 5abf7032a5 | |||
| 18e8e88c66 | |||
| 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 |
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "davidkna",
|
||||||
|
"name": "David Knaack",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
||||||
|
"profile": "https://github.com/davidkna",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contributorsPerLine": 7,
|
||||||
|
"skipCi": true
|
||||||
|
}
|
||||||
@@ -7,3 +7,8 @@ Lint/UnusedArgument:
|
|||||||
- src/routes/*
|
- src/routes/*
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
Layout/LineLength:
|
||||||
|
Enabled: true
|
||||||
|
MaxLength: 80
|
||||||
|
Excluded:
|
||||||
|
- src/routes/api.cr
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from_owner:
|
||||||
|
- hkalexling
|
||||||
|
required_labels:
|
||||||
|
- autoapprove
|
||||||
|
apply_labels:
|
||||||
|
- autoapproved
|
||||||
@@ -2,7 +2,7 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev, hotfix/* ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ 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
|
||||||
|
public/webfonts
|
||||||
|
|||||||
+2
-2
@@ -10,6 +10,6 @@ FROM library/alpine
|
|||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=builder /Mango/mango .
|
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
||||||
|
|
||||||
CMD ["./mango"]
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|||||||
+3
-2
@@ -9,6 +9,7 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
|
|||||||
|
|
||||||
COPY mango-arm32v7.o .
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|
||||||
CMD ["./mango"]
|
|
||||||
|
|||||||
+2
-2
@@ -9,6 +9,6 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
|
|||||||
|
|
||||||
COPY mango-arm64v8.o .
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
CMD ["./mango"]
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ test:
|
|||||||
check:
|
check:
|
||||||
crystal tool format --check
|
crystal tool format --check
|
||||||
./bin/ameba
|
./bin/ameba
|
||||||
./dev/linewidth.sh
|
|
||||||
|
|
||||||
arm32v7:
|
arm32v7:
|
||||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
||||||
|
|||||||
@@ -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.17.0
|
Mango - Manga Server and Web Reader. Version 0.20.1
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -82,23 +82,26 @@ library_path: ~/mango/library
|
|||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
thumbnail_generation_interval_hours: 24
|
thumbnail_generation_interval_hours: 24
|
||||||
db_optimization_interval_hours: 24
|
|
||||||
log_level: info
|
log_level: info
|
||||||
upload_path: ~/mango/uploads
|
upload_path: ~/mango/uploads
|
||||||
plugin_path: ~/mango/plugins
|
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 +156,27 @@ 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>
|
||||||
|
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</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,5 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
|
|
||||||
&& echo "The above lines exceed the 80 characters limit" \
|
|
||||||
|| exit 0
|
|
||||||
+20
-16
@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
|
|||||||
const minifyCss = require('gulp-minify-css');
|
const minifyCss = require('gulp-minify-css');
|
||||||
const less = require('gulp-less');
|
const less = require('gulp-less');
|
||||||
|
|
||||||
// Copy libraries from node_moduels to public/js
|
gulp.task('copy-img', () => {
|
||||||
gulp.task('copy-js', () => {
|
|
||||||
return gulp.src([
|
|
||||||
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
|
||||||
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
|
||||||
'node_modules/uikit/dist/js/uikit.min.js',
|
|
||||||
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
|
||||||
])
|
|
||||||
.pipe(gulp.dest('public/js'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy UIKit SVG icons to public/img
|
|
||||||
gulp.task('copy-uikit-icons', () => {
|
|
||||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||||
.pipe(gulp.dest('public/img'));
|
.pipe(gulp.dest('public/img'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
gulp.task('copy-font', () => {
|
||||||
|
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
|
||||||
|
.pipe(gulp.dest('public/webfonts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy files from node_modules
|
||||||
|
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
|
||||||
|
|
||||||
// Compile less
|
// Compile less
|
||||||
gulp.task('less', () => {
|
gulp.task('less', () => {
|
||||||
return gulp.src('public/css/*.less')
|
return gulp.src([
|
||||||
|
'public/css/mango.less',
|
||||||
|
'public/css/tags.less'
|
||||||
|
])
|
||||||
.pipe(less())
|
.pipe(less())
|
||||||
.pipe(gulp.dest('public/css'));
|
.pipe(gulp.dest('public/css'));
|
||||||
});
|
});
|
||||||
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
|
|||||||
|
|
||||||
// Copy static files (includeing images) to dist
|
// Copy static files (includeing images) to dist
|
||||||
gulp.task('copy-files', () => {
|
gulp.task('copy-files', () => {
|
||||||
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
|
return gulp.src([
|
||||||
|
'public/*.*',
|
||||||
|
'public/img/*',
|
||||||
|
'public/webfonts/*',
|
||||||
|
'public/js/*.min.js'
|
||||||
|
], {
|
||||||
base: 'public'
|
base: 'public'
|
||||||
})
|
})
|
||||||
.pipe(gulp.dest('dist'));
|
.pipe(gulp.dest('dist'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up the public folder for development
|
// Set up the public folder for development
|
||||||
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
|
||||||
|
|
||||||
// Set up the dist folder for deployment
|
// Set up the dist folder for deployment
|
||||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||||
|
|||||||
@@ -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,50 @@
|
|||||||
|
class IDSignature < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
ALTER TABLE ids ADD COLUMN signature TEXT;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove signature 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);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on 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
|
||||||
|
end
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
class RelativePath < MG::Base
|
||||||
|
def up : String
|
||||||
|
base = Config.current.library_path
|
||||||
|
# Escape single quotes in case the path contains them, and remove the
|
||||||
|
# trailing slash (this is a mistake, fixed in DB version 10)
|
||||||
|
base = base.gsub("'", "''").rstrip "/"
|
||||||
|
|
||||||
|
<<-SQL
|
||||||
|
-- update the path column in ids to relative paths
|
||||||
|
UPDATE ids
|
||||||
|
SET path = REPLACE(path, '#{base}', '');
|
||||||
|
|
||||||
|
-- update the path column in titles to relative paths
|
||||||
|
UPDATE titles
|
||||||
|
SET path = REPLACE(path, '#{base}', '');
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
base = Config.current.library_path
|
||||||
|
base = base.gsub("'", "''").rstrip "/"
|
||||||
|
|
||||||
|
<<-SQL
|
||||||
|
-- update the path column in ids to absolute paths
|
||||||
|
UPDATE ids
|
||||||
|
SET path = '#{base}' || path;
|
||||||
|
|
||||||
|
-- update the path column in titles to absolute paths
|
||||||
|
UPDATE titles
|
||||||
|
SET path = '#{base}' || path;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# In DB version 8, we replaced the absolute paths in DB with relative paths,
|
||||||
|
# but we mistakenly left the starting slashes. This migration removes them.
|
||||||
|
class RelativePathFix < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove leading slashes from the paths in ids
|
||||||
|
UPDATE ids
|
||||||
|
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
||||||
|
WHERE path LIKE '/%';
|
||||||
|
|
||||||
|
-- remove leading slashes from the paths in titles
|
||||||
|
UPDATE titles
|
||||||
|
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
||||||
|
WHERE path LIKE '/%';
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- add leading slashes to paths in ids
|
||||||
|
UPDATE ids
|
||||||
|
SET path = '/' || path
|
||||||
|
WHERE path NOT LIKE '/%';
|
||||||
|
|
||||||
|
-- add leading slashes to paths in titles
|
||||||
|
UPDATE titles
|
||||||
|
SET path = '/' || path
|
||||||
|
WHERE path NOT LIKE '/%';
|
||||||
|
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,94 @@
|
|||||||
|
class UnavailableIDs < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add unavailable column to ids
|
||||||
|
ALTER TABLE ids ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- add unavailable column to titles
|
||||||
|
ALTER TABLE titles ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove unavailable column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, signature
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on 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);
|
||||||
|
|
||||||
|
-- remove unavailable column from titles
|
||||||
|
ALTER TABLE titles RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT path, id, signature
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on 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);
|
||||||
|
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,137 @@
|
|||||||
|
// UIKit
|
||||||
|
@import "./uikit.less";
|
||||||
|
|
||||||
|
// FontAwesome
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2');
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
|
||||||
|
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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-modal-header,
|
||||||
|
.uk-modal-body,
|
||||||
|
.uk-modal-footer {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-dropdown {
|
||||||
|
color: #ccc;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+2
-2
@@ -63,7 +63,7 @@ const validThemeSetting = (theme) => {
|
|||||||
*/
|
*/
|
||||||
const loadThemeSetting = () => {
|
const loadThemeSetting = () => {
|
||||||
let str = localStorage.getItem('theme');
|
let str = localStorage.getItem('theme');
|
||||||
if (!str || !validThemeSetting(str)) str = 'light';
|
if (!str || !validThemeSetting(str)) str = 'system';
|
||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ const loadTheme = () => {
|
|||||||
* @param {string} setting - A theme setting
|
* @param {string} setting - A theme setting
|
||||||
*/
|
*/
|
||||||
const saveThemeSetting = setting => {
|
const saveThemeSetting = setting => {
|
||||||
if (!validThemeSetting(setting)) setting = 'light';
|
if (!validThemeSetting(setting)) setting = 'system';
|
||||||
localStorage.setItem('theme', setting);
|
localStorage.setItem('theme', setting);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
/**
|
const component = () => {
|
||||||
* Get the current queue and update the view
|
return {
|
||||||
*
|
jobs: [],
|
||||||
* @function load
|
paused: undefined,
|
||||||
*/
|
loading: false,
|
||||||
const load = () => {
|
toggling: false,
|
||||||
try {
|
ws: undefined,
|
||||||
setProp('loading', true);
|
|
||||||
} catch {}
|
wsConnect(secure = true) {
|
||||||
|
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
|
||||||
|
console.log(`Connecting to ${url}`);
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
this.ws.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.jobs = data.jobs;
|
||||||
|
this.paused = data.paused;
|
||||||
|
};
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
if (this.ws.failed)
|
||||||
|
return this.wsConnect(false);
|
||||||
|
alert('danger', 'Socket connection closed');
|
||||||
|
};
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
if (secure)
|
||||||
|
return this.ws.failed = true;
|
||||||
|
alert('danger', 'Socket connection failed');
|
||||||
|
};
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.wsConnect();
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
url: base_url + 'api/admin/mangadex/queue',
|
url: base_url + 'api/admin/mangadex/queue',
|
||||||
@@ -17,30 +42,24 @@ const load = () => {
|
|||||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProp('jobs', data.jobs);
|
this.jobs = data.jobs;
|
||||||
setProp('paused', data.paused);
|
this.paused = data.paused;
|
||||||
})
|
})
|
||||||
.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(() => {
|
||||||
setProp('loading', false);
|
this.loading = false;
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
jobAction(action, event) {
|
||||||
/**
|
|
||||||
* Perform an action on either a specific job or the entire queue
|
|
||||||
*
|
|
||||||
* @function jobAction
|
|
||||||
* @param {string} action - The action to perform. Should be either 'delete' or 'retry'
|
|
||||||
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue
|
|
||||||
*/
|
|
||||||
const jobAction = (action, id) => {
|
|
||||||
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
if (id !== undefined)
|
if (event) {
|
||||||
url += '?' + $.param({
|
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
||||||
|
url = `${url}?${$.param({
|
||||||
id: id
|
id: id
|
||||||
});
|
})}`;
|
||||||
|
}
|
||||||
console.log(url);
|
console.log(url);
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
@@ -52,21 +71,15 @@ const jobAction = (action, id) => {
|
|||||||
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
load();
|
this.load();
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
toggle() {
|
||||||
/**
|
this.toggling = true;
|
||||||
* Pause/resume the download
|
const action = this.paused ? 'resume' : 'pause';
|
||||||
*
|
|
||||||
* @function toggle
|
|
||||||
*/
|
|
||||||
const toggle = () => {
|
|
||||||
setProp('toggling', true);
|
|
||||||
const action = getProp('paused') ? 'resume' : 'pause';
|
|
||||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
@@ -77,19 +90,11 @@ const toggle = () => {
|
|||||||
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
load();
|
this.load();
|
||||||
setProp('toggling', false);
|
this.toggling = false;
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
statusClass(status) {
|
||||||
/**
|
|
||||||
* Get the uk-label class name for a given job status
|
|
||||||
*
|
|
||||||
* @function statusClass
|
|
||||||
* @param {string} status - The job status
|
|
||||||
* @return {string} The class name string
|
|
||||||
*/
|
|
||||||
const statusClass = status => {
|
|
||||||
let cls = 'label ';
|
let cls = 'label ';
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Pending':
|
case 'Pending':
|
||||||
@@ -106,19 +111,6 @@ const statusClass = status => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return cls;
|
return cls;
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
$(() => {
|
|
||||||
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
|
|
||||||
ws.onmessage = event => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
setProp('jobs', data.jobs);
|
|
||||||
setProp('paused', data.paused);
|
|
||||||
};
|
|
||||||
ws.onerror = err => {
|
|
||||||
alert('danger', `Socket connection failed. Error: ${err}`);
|
|
||||||
};
|
|
||||||
ws.onclose = err => {
|
|
||||||
alert('danger', 'Socket connection failed');
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
+189
-261
@@ -1,39 +1,208 @@
|
|||||||
$(() => {
|
const downloadComponent = () => {
|
||||||
$('#search-input').keypress(event => {
|
return {
|
||||||
if (event.which === 13) {
|
chaptersLimit: 1000,
|
||||||
search();
|
loading: false,
|
||||||
|
addingToDownload: false,
|
||||||
|
searchInput: '',
|
||||||
|
data: {},
|
||||||
|
chapters: [],
|
||||||
|
langChoice: 'All',
|
||||||
|
groupChoice: 'All',
|
||||||
|
chapterRange: '',
|
||||||
|
volumeRange: '',
|
||||||
|
|
||||||
|
get languages() {
|
||||||
|
const set = new Set();
|
||||||
|
if (this.data.chapters) {
|
||||||
|
this.data.chapters.forEach(chp => {
|
||||||
|
set.add(chp.language);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
const ary = [...set].sort();
|
||||||
$('.filter-field').each((i, ele) => {
|
ary.unshift('All');
|
||||||
$(ele).change(() => {
|
return ary;
|
||||||
buildTable();
|
},
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
const set = new Set();
|
||||||
|
if (this.data.chapters) {
|
||||||
|
this.data.chapters.forEach(chp => {
|
||||||
|
Object.keys(chp.groups).forEach(g => {
|
||||||
|
set.add(g);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
const selectAll = () => {
|
const ary = [...set].sort();
|
||||||
|
ary.unshift('All');
|
||||||
|
return ary;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const tableObserver = new MutationObserver(() => {
|
||||||
|
console.log('table mutated');
|
||||||
|
$("#selectable").selectable({
|
||||||
|
filter: 'tr'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tableObserver.observe($('table').get(0), {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
filtersUpdated() {
|
||||||
|
if (!this.data.chapters)
|
||||||
|
this.chapters = [];
|
||||||
|
const filters = {
|
||||||
|
chapter: this.parseRange(this.chapterRange),
|
||||||
|
volume: this.parseRange(this.volumeRange),
|
||||||
|
lang: this.langChoice,
|
||||||
|
group: this.groupChoice
|
||||||
|
};
|
||||||
|
console.log('filters:', filters);
|
||||||
|
let _chapters = this.data.chapters.slice();
|
||||||
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
|
if (v === 'All') return;
|
||||||
|
if (k === 'group') {
|
||||||
|
_chapters = _chapters.filter(c => {
|
||||||
|
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
|
||||||
|
return unescaped_groups.indexOf(v) >= 0;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === 'lang') {
|
||||||
|
_chapters = _chapters.filter(c => c.language === v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lb = parseFloat(v[0]);
|
||||||
|
const ub = parseFloat(v[1]);
|
||||||
|
if (isNaN(lb) && isNaN(ub)) return;
|
||||||
|
_chapters = _chapters.filter(c => {
|
||||||
|
const val = parseFloat(c[k]);
|
||||||
|
if (isNaN(val)) return false;
|
||||||
|
if (isNaN(lb))
|
||||||
|
return val <= ub;
|
||||||
|
else if (isNaN(ub))
|
||||||
|
return val >= lb;
|
||||||
|
else
|
||||||
|
return val >= lb && val <= ub;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('filtered chapters:', _chapters);
|
||||||
|
this.chapters = _chapters;
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
if (this.loading || this.searchInput === '') return;
|
||||||
|
this.loading = true;
|
||||||
|
this.data = {};
|
||||||
|
|
||||||
|
var int_id = -1;
|
||||||
|
try {
|
||||||
|
const path = new URL(this.searchInput).pathname;
|
||||||
|
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||||
|
int_id = parseInt(match[1]);
|
||||||
|
} catch (e) {
|
||||||
|
int_id = parseInt(this.searchInput);
|
||||||
|
}
|
||||||
|
if (int_id <= 0 || isNaN(int_id)) {
|
||||||
|
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = data;
|
||||||
|
this.chapters = data.chapters;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
parseRange(str) {
|
||||||
|
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||||
|
const matches = str.match(regex);
|
||||||
|
var num;
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return [null, null];
|
||||||
|
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
|
// e.g., <= 30
|
||||||
|
num = parseInt(matches[2]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
switch (matches[1]) {
|
||||||
|
case '<':
|
||||||
|
return [null, num - 1];
|
||||||
|
case '<=':
|
||||||
|
return [null, num];
|
||||||
|
case '>':
|
||||||
|
return [num + 1, null];
|
||||||
|
case '>=':
|
||||||
|
return [num, null];
|
||||||
|
}
|
||||||
|
} else if (typeof matches[3] !== 'undefined') {
|
||||||
|
// a single number
|
||||||
|
num = parseInt(matches[3]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, num];
|
||||||
|
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
|
// e.g., 10 - 23
|
||||||
|
num = parseInt(matches[4]);
|
||||||
|
const n2 = parseInt(matches[5]);
|
||||||
|
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, n2];
|
||||||
|
} else {
|
||||||
|
// empty or space only
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unescapeHTML(str) {
|
||||||
|
var elt = document.createElement("span");
|
||||||
|
elt.innerHTML = str;
|
||||||
|
return elt.innerText;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll() {
|
||||||
$('tbody > tr').each((i, e) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).addClass('ui-selected');
|
$(e).addClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
const unselect = () => {
|
|
||||||
|
clearSelection() {
|
||||||
$('tbody > tr').each((i, e) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).removeClass('ui-selected');
|
$(e).removeClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
const download = () => {
|
|
||||||
|
download() {
|
||||||
const selected = $('tbody > tr.ui-selected');
|
const selected = $('tbody > tr.ui-selected');
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const ids = selected.map((i, e) => {
|
const ids = selected.map((i, e) => {
|
||||||
return $(e).find('td').first().text();
|
return parseInt($(e).find('td').first().text());
|
||||||
}).get();
|
}).get();
|
||||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
console.log(ids);
|
console.log(ids);
|
||||||
|
this.addingToDownload = true;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: base_url + 'api/admin/mangadex/download',
|
url: `${base_url}api/admin/mangadex/download`,
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
chapters: chapters
|
chapters: chapters
|
||||||
}),
|
}),
|
||||||
@@ -56,250 +225,9 @@ const download = () => {
|
|||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
$('#download-spinner').attr('hidden', '');
|
this.addingToDownload = false;
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
|
||||||
const toggleSpinner = () => {
|
|
||||||
var attr = $('#spinner').attr('hidden');
|
|
||||||
if (attr) {
|
|
||||||
$('#spinner').removeAttr('hidden');
|
|
||||||
$('#search-btn').attr('hidden', '');
|
|
||||||
} else {
|
|
||||||
$('#search-btn').removeAttr('hidden');
|
|
||||||
$('#spinner').attr('hidden', '');
|
|
||||||
}
|
}
|
||||||
searching = !searching;
|
|
||||||
};
|
|
||||||
var searching = false;
|
|
||||||
var globalChapters;
|
|
||||||
const search = () => {
|
|
||||||
if (searching) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('#manga-details').attr('hidden', '');
|
|
||||||
$('#filter-form').attr('hidden', '');
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
toggleSpinner();
|
|
||||||
const input = $('input').val();
|
|
||||||
|
|
||||||
if (input === "") {
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var int_id = -1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const path = new URL(input).pathname;
|
|
||||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
|
||||||
int_id = parseInt(match[1]);
|
|
||||||
} catch (e) {
|
|
||||||
int_id = parseInt(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (int_id <= 0 || isNaN(int_id)) {
|
|
||||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cover = baseURL + data.cover_url;
|
|
||||||
$('#cover').attr("src", cover);
|
|
||||||
$('#title').text("Title: " + data.title);
|
|
||||||
$('#artist').text("Artist: " + data.artist);
|
|
||||||
$('#author').text("Author: " + data.author);
|
|
||||||
|
|
||||||
$('#manga-details').removeAttr('hidden');
|
|
||||||
|
|
||||||
console.log(data.chapters);
|
|
||||||
globalChapters = data.chapters;
|
|
||||||
|
|
||||||
let langs = new Set();
|
|
||||||
let group_names = new Set();
|
|
||||||
data.chapters.forEach(chp => {
|
|
||||||
Object.entries(chp.groups).forEach(([k, v]) => {
|
|
||||||
group_names.add(k);
|
|
||||||
});
|
|
||||||
langs.add(chp.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
const comp = (a, b) => {
|
|
||||||
var ai;
|
|
||||||
var bi;
|
|
||||||
try {
|
|
||||||
ai = parseFloat(a);
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
|
||||||
bi = parseFloat(b);
|
|
||||||
} catch (e) {}
|
|
||||||
if (typeof ai === 'undefined') return -1;
|
|
||||||
if (typeof bi === 'undefined') return 1;
|
|
||||||
if (ai < bi) return 1;
|
|
||||||
if (ai > bi) return -1;
|
|
||||||
return 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
langs = [...langs].sort();
|
|
||||||
group_names = [...group_names].sort();
|
|
||||||
|
|
||||||
langs.unshift('All');
|
|
||||||
group_names.unshift('All');
|
|
||||||
|
|
||||||
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
|
|
||||||
$('#filter-form').removeAttr('hidden');
|
|
||||||
|
|
||||||
buildTable();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
toggleSpinner();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const parseRange = str => {
|
|
||||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
|
||||||
const matches = str.match(regex);
|
|
||||||
var num;
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
|
||||||
num = parseInt(matches[2]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
switch (matches[1]) {
|
|
||||||
case '<':
|
|
||||||
return [null, num - 1];
|
|
||||||
case '<=':
|
|
||||||
return [null, num];
|
|
||||||
case '>':
|
|
||||||
return [num + 1, null];
|
|
||||||
case '>=':
|
|
||||||
return [num, null];
|
|
||||||
}
|
|
||||||
} else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
|
||||||
num = parseInt(matches[3]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, num];
|
|
||||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
|
||||||
num = parseInt(matches[4]);
|
|
||||||
const n2 = parseInt(matches[5]);
|
|
||||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, n2];
|
|
||||||
} else {
|
|
||||||
// empty or space only
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getFilters = () => {
|
|
||||||
const filters = {};
|
|
||||||
$('.uk-select').each((i, ele) => {
|
|
||||||
const id = $(ele).attr('id');
|
|
||||||
const by = id.split('-')[0];
|
|
||||||
const choice = $(ele).val();
|
|
||||||
filters[by] = choice;
|
|
||||||
});
|
|
||||||
filters.volume = parseRange($('#volume-range').val());
|
|
||||||
filters.chapter = parseRange($('#chapter-range').val());
|
|
||||||
return filters;
|
|
||||||
};
|
|
||||||
const buildTable = () => {
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
console.log('rebuilding table');
|
|
||||||
const filters = getFilters();
|
|
||||||
console.log('filters:', filters);
|
|
||||||
var chapters = globalChapters.slice();
|
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
|
||||||
if (v === 'All') return;
|
|
||||||
if (k === 'group') {
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
|
||||||
return unescaped_groups.indexOf(v) >= 0;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (k === 'lang') {
|
|
||||||
chapters = chapters.filter(c => c.language === v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lb = parseFloat(v[0]);
|
|
||||||
const ub = parseFloat(v[1]);
|
|
||||||
if (isNaN(lb) && isNaN(ub)) return;
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
const val = parseFloat(c[k]);
|
|
||||||
if (isNaN(val)) return false;
|
|
||||||
if (isNaN(lb))
|
|
||||||
return val <= ub;
|
|
||||||
else if (isNaN(ub))
|
|
||||||
return val >= lb;
|
|
||||||
else
|
|
||||||
return val >= lb && val <= ub;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log('filtered chapters:', chapters);
|
|
||||||
$('#count-text').text(`${chapters.length} chapters found`);
|
|
||||||
|
|
||||||
const chaptersLimit = 1000;
|
|
||||||
if (chapters.length > chaptersLimit) {
|
|
||||||
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
|
||||||
$('#filter-notification').removeAttr('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = chapters.map(chp => {
|
|
||||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
|
||||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
|
||||||
}).join(' | ');
|
|
||||||
return `<tr class="ui-widget-content">
|
|
||||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
|
||||||
<td>${chp.title}</td>
|
|
||||||
<td>${chp.language}</td>
|
|
||||||
<td>${group_str}</td>
|
|
||||||
<td>${chp.volume}</td>
|
|
||||||
<td>${chp.chapter}</td>
|
|
||||||
<td>${moment.unix(chp.time).fromNow()}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
$('table').removeAttr('hidden');
|
|
||||||
$("#selectable").selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
$('#selection-controls').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const unescapeHTML = (str) => {
|
|
||||||
var elt = document.createElement("span");
|
|
||||||
elt.innerHTML = str;
|
|
||||||
return elt.innerText;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
empty: true,
|
||||||
|
titles: [],
|
||||||
|
entries: [],
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.request('GET', `${base_url}api/admin/titles/missing`, data => {
|
||||||
|
this.titles = data.titles;
|
||||||
|
this.request('GET', `${base_url}api/admin/entries/missing`, data => {
|
||||||
|
this.entries = data.entries;
|
||||||
|
this.loading = false;
|
||||||
|
this.empty = this.entries.length === 0 && this.titles.length === 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rm(event) {
|
||||||
|
const rawID = event.currentTarget.closest('tr').id;
|
||||||
|
const [type, id] = rawID.split('-');
|
||||||
|
const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
|
||||||
|
this.request('DELETE', url, () => {
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rmAll() {
|
||||||
|
UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
|
||||||
|
labels: {
|
||||||
|
ok: 'Yes, delete them',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
|
||||||
|
this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
request(method, url, cb) {
|
||||||
|
console.log(url);
|
||||||
|
$.ajax({
|
||||||
|
type: method,
|
||||||
|
url: url,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cb) cb(data);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+206
-218
@@ -1,222 +1,171 @@
|
|||||||
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
|
||||||
|
|
||||||
$('#page-select').change(() => {
|
|
||||||
const p = parseInt($('#page-select').val());
|
|
||||||
toPage(p);
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#mode-select').change(() => {
|
|
||||||
const mode = $('#mode-select').val();
|
|
||||||
const curIdx = parseInt($('#page-select').val());
|
|
||||||
|
|
||||||
updateMode(mode, curIdx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(window).resize(() => {
|
|
||||||
const mode = getProp('mode');
|
|
||||||
if (mode === 'continuous') return;
|
|
||||||
|
|
||||||
const wideScreen = $(window).width() > $(window).height();
|
|
||||||
const propMode = wideScreen ? 'height' : 'width';
|
|
||||||
setProp('mode', propMode);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the reader mode
|
|
||||||
*
|
|
||||||
* @function updateMode
|
|
||||||
* @param {string} mode - The mode. Can be one of the followings:
|
|
||||||
* {'continuous', 'paged', 'height', 'width'}
|
|
||||||
* @param {number} targetPage - The one-based index of the target page
|
|
||||||
*/
|
*/
|
||||||
const updateMode = (mode, targetPage) => {
|
init(nextTick) {
|
||||||
localStorage.setItem('mode', mode);
|
|
||||||
|
|
||||||
// The mode to be put into the `mode` prop. It can't be `screen`
|
|
||||||
let propMode = mode;
|
|
||||||
|
|
||||||
if (mode === 'paged') {
|
|
||||||
const wideScreen = $(window).width() > $(window).height();
|
|
||||||
propMode = wideScreen ? 'height' : 'width';
|
|
||||||
}
|
|
||||||
|
|
||||||
setProp('mode', propMode);
|
|
||||||
|
|
||||||
if (mode === 'continuous') {
|
|
||||||
waitForPage(items.length, () => {
|
|
||||||
setupScroller();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForPage(targetPage, () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
toPage(targetPage);
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dimension of the pages in the entry from the API and update the view
|
|
||||||
*/
|
|
||||||
const getPages = () => {
|
|
||||||
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.success && data.error)
|
if (!data.success && data.error)
|
||||||
throw new Error(resp.error);
|
throw new Error(resp.error);
|
||||||
const dimensions = data.dimensions;
|
const dimensions = data.dimensions;
|
||||||
|
|
||||||
items = dimensions.map((d, i) => {
|
this.items = dimensions.map((d, i) => {
|
||||||
return {
|
return {
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
width: d.width,
|
width: d.width,
|
||||||
height: d.height
|
height: d.height,
|
||||||
|
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const avgRatio = items.reduce((acc, cur) => {
|
const avgRatio = this.items.reduce((acc, cur) => {
|
||||||
return acc + cur.height / cur.width
|
return acc + cur.height / cur.width
|
||||||
}, 0) / items.length;
|
}, 0) / this.items.length;
|
||||||
|
|
||||||
console.log(avgRatio);
|
console.log(avgRatio);
|
||||||
longPages = avgRatio > 2;
|
this.longPages = avgRatio > 2;
|
||||||
|
this.loading = false;
|
||||||
|
this.mode = localStorage.getItem('mode') || 'continuous';
|
||||||
|
|
||||||
setProp('items', items);
|
// Here we save a copy of this.mode, and use the copy as
|
||||||
setProp('loading', false);
|
// the model-select value. This is because `updateMode`
|
||||||
|
// might change this.mode and make it `height` or `width`,
|
||||||
const storedMode = localStorage.getItem('mode') || 'continuous';
|
// which are not available in mode-select
|
||||||
|
const mode = this.mode;
|
||||||
setProp('mode', storedMode);
|
this.updateMode(this.mode, page, nextTick);
|
||||||
updateMode(storedMode, page);
|
$('#mode-select').val(mode);
|
||||||
$('#mode-select').val(storedMode);
|
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setProp('alertClass', 'uk-alert-danger');
|
this.alertClass = 'uk-alert-danger';
|
||||||
setProp('msg', errMsg);
|
this.msg = errMsg;
|
||||||
})
|
})
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Handles the `change` event for the page selector
|
||||||
* Jump to a specific page
|
*/
|
||||||
|
pageChanged() {
|
||||||
|
const p = parseInt($('#page-select').val());
|
||||||
|
this.toPage(p);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the mode selector
|
||||||
|
*
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
modeChanged(nextTick) {
|
||||||
|
const mode = $('#mode-select').val();
|
||||||
|
const curIdx = parseInt($('#page-select').val());
|
||||||
|
|
||||||
|
this.updateMode(mode, curIdx, nextTick);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the window `resize` event
|
||||||
|
*/
|
||||||
|
resized() {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
|
this.mode = wideScreen ? 'height' : 'width';
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the window `keydown` event
|
||||||
|
*
|
||||||
|
* @param {Event} event - The triggering event
|
||||||
|
*/
|
||||||
|
keyHandler(event) {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||||
|
this.flipPage(false);
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||||
|
this.flipPage(true);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Flips to the next or the previous page
|
||||||
|
*
|
||||||
|
* @param {bool} isNext - Whether we are going to the next page
|
||||||
|
*/
|
||||||
|
flipPage(isNext) {
|
||||||
|
const idx = parseInt(this.curItem.id);
|
||||||
|
const newIdx = idx + (isNext ? 1 : -1);
|
||||||
|
|
||||||
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
|
|
||||||
|
this.toPage(newIdx);
|
||||||
|
|
||||||
|
if (isNext)
|
||||||
|
this.flipAnimation = 'right';
|
||||||
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.flipAnimation = null;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.replaceHistory(newIdx);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Jumps to a specific page
|
||||||
*
|
*
|
||||||
* @function toPage
|
|
||||||
* @param {number} idx - One-based index of the page
|
* @param {number} idx - One-based index of the page
|
||||||
*/
|
*/
|
||||||
const toPage = (idx) => {
|
toPage(idx) {
|
||||||
const mode = getProp('mode');
|
if (this.mode === 'continuous') {
|
||||||
if (mode === 'continuous') {
|
|
||||||
$(`#${idx}`).get(0).scrollIntoView(true);
|
$(`#${idx}`).get(0).scrollIntoView(true);
|
||||||
} else {
|
} else {
|
||||||
if (idx >= 1 && idx <= items.length) {
|
if (idx >= 1 && idx <= this.items.length) {
|
||||||
setProp('curItem', items[idx - 1]);
|
this.curItem = this.items[idx - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replaceHistory(idx);
|
this.replaceHistory(idx);
|
||||||
UIkit.modal($('#modal-sections')).hide();
|
UIkit.modal($('#modal-sections')).hide();
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Replace the address bar history and save the reading progress if necessary
|
||||||
* 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 {number} idx - One-based index of the page
|
||||||
* @param {function} cb - Callback function
|
|
||||||
*/
|
*/
|
||||||
const waitForPage = (idx, cb) => {
|
replaceHistory(idx) {
|
||||||
if ($(`#${idx}`).length > 0) return cb();
|
|
||||||
setTimeout(() => {
|
|
||||||
waitForPage(idx, cb)
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the control modal
|
|
||||||
*
|
|
||||||
* @function showControl
|
|
||||||
* @param {string} idx - One-based index of the current page
|
|
||||||
*/
|
|
||||||
const showControl = (idx) => {
|
|
||||||
const pageCount = $('#page-select > option').length;
|
|
||||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
|
||||||
$('#progress-label').text(progressText);
|
|
||||||
$('#page-select').val(idx);
|
|
||||||
UIkit.modal($('#modal-sections')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect to a URL
|
|
||||||
*
|
|
||||||
* @function redirect
|
|
||||||
* @param {string} url - The target URL
|
|
||||||
*/
|
|
||||||
const redirect = (url) => {
|
|
||||||
window.location.replace(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the address bar history and save th ereading progress if necessary
|
|
||||||
*
|
|
||||||
* @function replaceHistory
|
|
||||||
* @param {number} idx - One-based index of the current page
|
|
||||||
*/
|
|
||||||
const replaceHistory = (idx) => {
|
|
||||||
const ary = window.location.pathname.split('/');
|
const ary = window.location.pathname.split('/');
|
||||||
ary[ary.length - 1] = idx;
|
ary[ary.length - 1] = idx;
|
||||||
ary.shift(); // remove leading `/`
|
ary.shift(); // remove leading `/`
|
||||||
ary.unshift(window.location.origin);
|
ary.unshift(window.location.origin);
|
||||||
const url = ary.join('/');
|
const url = ary.join('/');
|
||||||
saveProgress(idx);
|
this.saveProgress(idx);
|
||||||
history.replaceState(null, "", url);
|
history.replaceState(null, "", url);
|
||||||
}
|
},
|
||||||
|
/**
|
||||||
/**
|
* Updates the backend reading progress if:
|
||||||
* Set up the scroll handler that calls `replaceHistory` when an image
|
|
||||||
* enters the view port
|
|
||||||
*
|
|
||||||
* @function setupScroller
|
|
||||||
*/
|
|
||||||
const setupScroller = () => {
|
|
||||||
const mode = getProp('mode');
|
|
||||||
if (mode !== 'continuous') return;
|
|
||||||
$('#root img').each((idx, el) => {
|
|
||||||
$(el).on('inview', (event, inView) => {
|
|
||||||
if (inView) {
|
|
||||||
const current = $(event.currentTarget).attr('id');
|
|
||||||
|
|
||||||
setProp('curItem', getProp('items')[current - 1]);
|
|
||||||
replaceHistory(current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the backend reading progress if:
|
|
||||||
* 1) the current page is more than five pages away from the last
|
* 1) the current page is more than five pages away from the last
|
||||||
* saved page, or
|
* saved page, or
|
||||||
* 2) the average height/width ratio of the pages is over 2, or
|
* 2) the average height/width ratio of the pages is over 2, or
|
||||||
* 3) the current page is the first page, or
|
* 3) the current page is the first page, or
|
||||||
* 4) the current page is the last page
|
* 4) the current page is the last page
|
||||||
*
|
*
|
||||||
* @function saveProgress
|
|
||||||
* @param {number} idx - One-based index of the page
|
* @param {number} idx - One-based index of the page
|
||||||
* @param {function} cb - Callback
|
* @param {function} cb - Callback
|
||||||
*/
|
*/
|
||||||
const saveProgress = (idx, cb) => {
|
saveProgress(idx, cb) {
|
||||||
idx = parseInt(idx);
|
idx = parseInt(idx);
|
||||||
if (Math.abs(idx - lastSavedPage) >= 5 ||
|
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
|
||||||
longPages ||
|
this.longPages ||
|
||||||
idx === 1 || idx === items.length
|
idx === 1 || idx === this.items.length
|
||||||
) {
|
) {
|
||||||
lastSavedPage = idx;
|
this.lastSavedPage = idx;
|
||||||
console.log('saving progress', idx);
|
console.log('saving progress', idx);
|
||||||
|
|
||||||
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
||||||
@@ -234,60 +183,99 @@ const saveProgress = (idx, cb) => {
|
|||||||
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
/**
|
||||||
/**
|
* Updates the reader mode
|
||||||
* Mark progress to 100% and redirect to the next entry
|
*
|
||||||
* Used as the onclick handler for the "Next Entry" button
|
* @param {string} mode - Either `continuous` or `paged`
|
||||||
|
* @param {number} targetPage - The one-based index of the target page
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
updateMode(mode, targetPage, nextTick) {
|
||||||
|
localStorage.setItem('mode', mode);
|
||||||
|
|
||||||
|
// The mode to be put into the `mode` prop. It can't be `screen`
|
||||||
|
let propMode = mode;
|
||||||
|
|
||||||
|
if (mode === 'paged') {
|
||||||
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
|
propMode = wideScreen ? 'height' : 'width';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = propMode;
|
||||||
|
|
||||||
|
if (mode === 'continuous') {
|
||||||
|
nextTick(() => {
|
||||||
|
this.setupScroller();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
this.toPage(targetPage);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Shows the control modal
|
||||||
|
*
|
||||||
|
* @param {Event} event - The triggering event
|
||||||
|
*/
|
||||||
|
showControl(event) {
|
||||||
|
const idx = event.currentTarget.id;
|
||||||
|
const pageCount = this.items.length;
|
||||||
|
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||||
|
$('#progress-label').text(progressText);
|
||||||
|
$('#page-select').val(idx);
|
||||||
|
UIkit.modal($('#modal-sections')).show();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Redirects to a URL
|
||||||
|
*
|
||||||
|
* @param {string} url - The target URL
|
||||||
|
*/
|
||||||
|
redirect(url) {
|
||||||
|
window.location.replace(url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Set up the scroll handler that calls `replaceHistory` when an image
|
||||||
|
* enters the view port
|
||||||
|
*/
|
||||||
|
setupScroller() {
|
||||||
|
if (this.mode !== 'continuous') return;
|
||||||
|
$('img').each((idx, el) => {
|
||||||
|
$(el).on('inview', (event, inView) => {
|
||||||
|
if (inView) {
|
||||||
|
const current = $(event.currentTarget).attr('id');
|
||||||
|
|
||||||
|
this.curItem = this.items[current - 1];
|
||||||
|
this.replaceHistory(current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Marks progress as 100% and jumps to the next entry
|
||||||
*
|
*
|
||||||
* @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);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -252,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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+12
@@ -52,6 +52,14 @@ shards:
|
|||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.5.0
|
version: 0.5.0
|
||||||
|
|
||||||
|
mangadex:
|
||||||
|
git: https://github.com/hkalexling/mangadex.git
|
||||||
|
version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb
|
||||||
|
|
||||||
|
mg:
|
||||||
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
git: https://github.com/kostya/myhtml.git
|
git: https://github.com/kostya/myhtml.git
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
@@ -68,3 +76,7 @@ shards:
|
|||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
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.17.0
|
version: 0.20.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -39,3 +39,9 @@ dependencies:
|
|||||||
github: hkalexling/image_size.cr
|
github: hkalexling/image_size.cr
|
||||||
koa:
|
koa:
|
||||||
github: hkalexling/koa
|
github: hkalexling/koa
|
||||||
|
tallboy:
|
||||||
|
github: epoch/tallboy
|
||||||
|
mg:
|
||||||
|
github: hkalexling/mg
|
||||||
|
mangadex:
|
||||||
|
github: hkalexling/mangadex
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ describe "compare_numerically" do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "is_supported_file" do
|
||||||
|
it "returns true when the filename has a supported extension" do
|
||||||
|
filename = "manga.cbz"
|
||||||
|
is_supported_file(filename).should eq true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true when the filename does not have a supported extension" do
|
||||||
|
filename = "info.json"
|
||||||
|
is_supported_file(filename).should eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is case insensitive" do
|
||||||
|
filename = "manga.ZiP"
|
||||||
|
is_supported_file(filename).should eq true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "chapter_sort" do
|
describe "chapter_sort" do
|
||||||
it "sorts correctly" do
|
it "sorts correctly" do
|
||||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
Arabic,sa
|
|
||||||
Bengali,bd
|
|
||||||
Bulgarian,bg
|
|
||||||
Burmese,mm
|
|
||||||
Catalan,ct
|
|
||||||
Chinese (Simp),cn
|
|
||||||
Chinese (Trad),hk
|
|
||||||
Czech,cz
|
|
||||||
Danish,dk
|
|
||||||
Dutch,nl
|
|
||||||
English,gb
|
|
||||||
Filipino,ph
|
|
||||||
Finnish,fi
|
|
||||||
French,fr
|
|
||||||
German,de
|
|
||||||
Greek,gr
|
|
||||||
Hebrew,il
|
|
||||||
Hindi,in
|
|
||||||
Hungarian,hu
|
|
||||||
Indonesian,id
|
|
||||||
Italian,it
|
|
||||||
Japanese,jp
|
|
||||||
Korean,kr
|
|
||||||
Lithuanian,lt
|
|
||||||
Malay,my
|
|
||||||
Mongolian,mn
|
|
||||||
Other,
|
|
||||||
Persian,ir
|
|
||||||
Polish,pl
|
|
||||||
Portuguese (Br),br
|
|
||||||
Portuguese (Pt),pt
|
|
||||||
Romanian,ro
|
|
||||||
Russian,ru
|
|
||||||
Serbo-Croatian,rs
|
|
||||||
Spanish (Es),es
|
|
||||||
Spanish (LATAM),mx
|
|
||||||
Swedish,se
|
|
||||||
Thai,th
|
|
||||||
Turkish,tr
|
|
||||||
Ukrainian,ua
|
|
||||||
Vietnames,vn
|
|
||||||
|
+21
-3
@@ -13,19 +13,22 @@ class Config
|
|||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||||
property scan_interval_minutes : Int32 = 5
|
property scan_interval_minutes : Int32 = 5
|
||||||
property thumbnail_generation_interval_hours : Int32 = 24
|
property thumbnail_generation_interval_hours : Int32 = 24
|
||||||
property db_optimization_interval_hours : Int32 = 24
|
|
||||||
property log_level : String = "info"
|
property log_level : String = "info"
|
||||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||||
home: true
|
home: true
|
||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
home: true
|
home: true
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
|
property page_margin : Int32 = 30
|
||||||
|
property disable_login = false
|
||||||
|
property default_username = ""
|
||||||
|
property auth_proxy_header_name = ""
|
||||||
property mangadex = Hash(String, String | Int32).new
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
"base_url" => "https://mangadex.org",
|
"base_url" => "https://mangadex.org",
|
||||||
"api_url" => "https://mangadex.org/api",
|
"api_url" => "https://mangadex.org/api/v2",
|
||||||
"download_wait_seconds" => 5,
|
"download_wait_seconds" => 5,
|
||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
@@ -49,9 +52,9 @@ class Config
|
|||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
config.preprocess
|
|
||||||
config.path = path
|
config.path = path
|
||||||
config.fill_defaults
|
config.fill_defaults
|
||||||
|
config.preprocess
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
puts "The config file #{cfg_path} does not exist. " \
|
puts "The config file #{cfg_path} does not exist. " \
|
||||||
@@ -85,5 +88,20 @@ 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
|
||||||
|
unless mangadex["api_url"] =~ /\/v2/
|
||||||
|
# `Logger.default` is not available yet
|
||||||
|
Log.setup :debug
|
||||||
|
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
||||||
|
"v1 in your config file. Please update it to either " \
|
||||||
|
"https://mangadex.org/api/v2 or " \
|
||||||
|
"https://api.mangadex.org/v2 to suppress this warning." }
|
||||||
|
mangadex["api_url"] = "https://mangadex.org/api/v2"
|
||||||
|
end
|
||||||
|
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
||||||
|
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,24 +11,25 @@ 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
|
||||||
env.response.print AUTH_MESSAGE
|
env.response.print AUTH_MESSAGE
|
||||||
call_next env
|
end
|
||||||
|
|
||||||
|
def require_auth(env)
|
||||||
|
env.session.string "callback", env.request.path
|
||||||
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
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,44 +50,53 @@ 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 call(env)
|
||||||
if validate_token(env) || validate_auth_header(env)
|
# Skip all authentication if requesting /login, /logout, or a static file
|
||||||
call_next env
|
|
||||||
else
|
|
||||||
env.response.status_code = 401
|
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
|
||||||
env.response.print AUTH_MESSAGE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_auth(env)
|
|
||||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
if request_path_startswith(env, ["/login", "/logout"]) ||
|
||||||
requesting_static_file env
|
requesting_static_file env
|
||||||
return call_next(env)
|
return call_next(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
unless validate_token env
|
# Check user is logged in
|
||||||
env.session.string "callback", env.request.path
|
if validate_token env
|
||||||
return redirect env, "/login"
|
# Skip if the request has a valid token
|
||||||
|
elsif Config.current.disable_login
|
||||||
|
# Check default username if login is disabled
|
||||||
|
unless Storage.default.username_exists Config.current.default_username
|
||||||
|
Logger.warn "Default username #{Config.current.default_username} " \
|
||||||
|
"does not exist"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif !Config.current.auth_proxy_header_name.empty?
|
||||||
|
# Check auth proxy if present
|
||||||
|
username = env.request.headers[Config.current.auth_proxy_header_name]?
|
||||||
|
unless username && Storage.default.username_exists username
|
||||||
|
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
|
||||||
|
"or is not a valid username"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif request_path_startswith env, ["/opds"]
|
||||||
|
# Check auth header if requesting an opds page
|
||||||
|
unless validate_auth_header env
|
||||||
|
return require_basic_auth env
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return require_auth env
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
# Check admin access when requesting an admin page
|
||||||
unless validate_token_admin env
|
if request_path_startswith env, %w(/admin /api/admin /download)
|
||||||
|
unless is_admin? env
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
|
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||||
|
"#{env.request.path}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Let the request go through if it passes the above checks
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
|
||||||
if request_path_startswith env, ["/opds"]
|
|
||||||
handle_opds_auth env
|
|
||||||
else
|
|
||||||
handle_auth env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
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
|
||||||
@size = (File.size @zip_path).humanize_bytes
|
@size = (File.size @zip_path).humanize_bytes
|
||||||
id = storage.get_id @zip_path, false
|
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
||||||
if id.nil?
|
if id.nil?
|
||||||
id = random_str
|
id = random_str
|
||||||
storage.insert_id({
|
storage.insert_entry_id({
|
||||||
path: @zip_path,
|
path: @zip_path,
|
||||||
id: id,
|
id: id,
|
||||||
is_title: false,
|
signature: File.signature(@zip_path).to_s,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@id = id
|
@id = id
|
||||||
|
|||||||
+6
-36
@@ -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
|
||||||
@@ -42,16 +42,6 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
db_interval = Config.current.db_optimization_interval_hours
|
|
||||||
unless db_interval < 1
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
Storage.default.optimize
|
|
||||||
sleep db_interval.hours
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
@@ -68,29 +58,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 +96,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 }
|
||||||
@@ -140,6 +109,7 @@ class Library
|
|||||||
storage.close
|
storage.close
|
||||||
|
|
||||||
Logger.debug "Scan completed"
|
Logger.debug "Scan completed"
|
||||||
|
Storage.default.mark_unavailable
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_continue_reading_entries(username)
|
def get_continue_reading_entries(username)
|
||||||
@@ -262,7 +232,7 @@ class Library
|
|||||||
e.generate_thumbnail
|
e.generate_thumbnail
|
||||||
# Sleep after each generation to minimize the impact on disk IO
|
# Sleep after each generation to minimize the impact on disk IO
|
||||||
# and CPU
|
# and CPU
|
||||||
sleep 0.5.seconds
|
sleep 1.seconds
|
||||||
end
|
end
|
||||||
@thumbnails_count += 1
|
@thumbnails_count += 1
|
||||||
end
|
end
|
||||||
|
|||||||
+52
-22
@@ -1,19 +1,22 @@
|
|||||||
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, signature : UInt64
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id, storage,
|
@entry_display_name_cache : Hash(String, String)?
|
||||||
@library : Library)
|
|
||||||
id = storage.get_id @dir, true
|
def initialize(@dir : String, @parent_id)
|
||||||
|
storage = Storage.default
|
||||||
|
@signature = Dir.signature dir
|
||||||
|
id = storage.get_title_id dir, signature
|
||||||
if id.nil?
|
if id.nil?
|
||||||
id = random_str
|
id = random_str
|
||||||
storage.insert_id({
|
storage.insert_title_id({
|
||||||
path: @dir,
|
path: dir,
|
||||||
id: id,
|
id: id,
|
||||||
is_title: true,
|
signature: signature.to_s,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@id = id
|
@id = id
|
||||||
@@ -27,26 +30,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 is_supported_file 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|
|
||||||
@@ -59,6 +62,7 @@ class Title
|
|||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
|
json.field "signature" { json.number @signature }
|
||||||
json.field "display_name", display_name
|
json.field "display_name", display_name
|
||||||
json.field "cover_url", cover_url
|
json.field "cover_url", cover_url
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
@@ -82,7 +86,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 +104,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 +155,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
|
||||||
|
|
||||||
|
|||||||
+22
-16
@@ -6,26 +6,14 @@ 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
|
||||||
|
|
||||||
format_proc = ->(entry : Log::Entry, io : IO) do
|
format_proc = ->(entry : Log::Entry, io : IO) do
|
||||||
@@ -49,6 +37,24 @@ class Logger
|
|||||||
Log.setup @@severity, @backend
|
Log.setup @@severity, @backend
|
||||||
end
|
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
|
||||||
|
|
||||||
# Ignores @@severity and always log msg
|
# Ignores @@severity and always log msg
|
||||||
def log(msg)
|
def log(msg)
|
||||||
@backend.write Log::Entry.new "", Log::Severity::None, msg,
|
@backend.write Log::Entry.new "", Log::Severity::None, msg,
|
||||||
@@ -61,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,217 +0,0 @@
|
|||||||
require "json"
|
|
||||||
require "csv"
|
|
||||||
require "../rename"
|
|
||||||
|
|
||||||
macro string_properties(names)
|
|
||||||
{% for name in names %}
|
|
||||||
property {{name.id}} = ""
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro parse_strings_from_json(names)
|
|
||||||
{% for name in names %}
|
|
||||||
@{{name.id}} = obj[{{name}}].as_s
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro properties_to_hash(names)
|
|
||||||
{
|
|
||||||
{% for name in names %}
|
|
||||||
"{{name.id}}" => @{{name.id}}.to_s,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class Chapter
|
|
||||||
string_properties ["lang_code", "title", "volume", "chapter"]
|
|
||||||
property manga : Manga
|
|
||||||
property time = Time.local
|
|
||||||
property id : String
|
|
||||||
property full_title = ""
|
|
||||||
property language = ""
|
|
||||||
property pages = [] of {String, String} # filename, url
|
|
||||||
property groups = [] of {Int32, String} # group_id, group_name
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any, @manga,
|
|
||||||
lang : Hash(String, String))
|
|
||||||
self.parse_json json_obj, lang
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "volume", "chapter",
|
|
||||||
"language", "full_title"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "time", @time.to_unix.to_s
|
|
||||||
json.field "manga_title", @manga.title
|
|
||||||
json.field "manga_id", @manga.id
|
|
||||||
json.field "groups" do
|
|
||||||
json.object do
|
|
||||||
@groups.each do |gid, gname|
|
|
||||||
json.field gname, gid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj, lang)
|
|
||||||
parse_strings_from_json ["lang_code", "title", "volume",
|
|
||||||
"chapter"]
|
|
||||||
language = lang[@lang_code]?
|
|
||||||
@language = language if language
|
|
||||||
@time = Time.unix obj["timestamp"].as_i
|
|
||||||
suffixes = ["", "_2", "_3"]
|
|
||||||
suffixes.each do |s|
|
|
||||||
gid = obj["group_id#{s}"].as_i
|
|
||||||
next if gid == 0
|
|
||||||
gname = obj["group_name#{s}"].as_s
|
|
||||||
@groups << {gid, gname}
|
|
||||||
end
|
|
||||||
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
|
||||||
@full_title = rename rename_rule
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
hash = properties_to_hash ["id", "title", "volume", "chapter",
|
|
||||||
"lang_code", "language", "pages"]
|
|
||||||
hash["groups"] = @groups.map { |g| g[1] }.join ","
|
|
||||||
rule.render hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Manga
|
|
||||||
string_properties ["cover_url", "description", "title", "author", "artist"]
|
|
||||||
property chapters = [] of Chapter
|
|
||||||
property id : String
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any)
|
|
||||||
self.parse_json json_obj
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json(with_chapters = true)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "description", "author", "artist",
|
|
||||||
"cover_url"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
if with_chapters
|
|
||||||
json.field "chapters" do
|
|
||||||
json.array do
|
|
||||||
@chapters.each do |c|
|
|
||||||
json.raw c.to_info_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj)
|
|
||||||
parse_strings_from_json ["cover_url", "description", "title", "author",
|
|
||||||
"artist"]
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
rule.render properties_to_hash ["id", "title", "author", "artist"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class API
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
|
||||||
"https://mangadex.org/api/"
|
|
||||||
@lang = {} of String => String
|
|
||||||
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
|
||||||
@lang[row[1]] = row[0]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(url)
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
res = HTTP::Client.get url, headers
|
|
||||||
raise "Failed to get #{url}. [#{res.status_code}] " \
|
|
||||||
"#{res.status_message}" if !res.success?
|
|
||||||
JSON.parse res.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_manga(id)
|
|
||||||
obj = self.get File.join @base_url, "manga/#{id}"
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
manga = Manga.new id, obj["manga"]
|
|
||||||
obj["chapter"].as_h.map do |k, v|
|
|
||||||
chapter = Chapter.new k, v, manga, @lang
|
|
||||||
manga.chapters << chapter
|
|
||||||
end
|
|
||||||
manga
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(chapter : Chapter)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
server = obj["server"].as_s
|
|
||||||
hash = obj["hash"].as_s
|
|
||||||
chapter.pages = obj["page_array"].as_a.map do |fn|
|
|
||||||
{
|
|
||||||
fn.as_s,
|
|
||||||
"#{server}#{hash}/#{fn.as_s}",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(id : String)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
manga_id = ""
|
|
||||||
begin
|
|
||||||
manga_id = obj["manga_id"].as_i.to_s
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
manga = self.get_manga manga_id
|
|
||||||
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
|
||||||
self.get_chapter chapter
|
|
||||||
chapter
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
require "./api"
|
require "mangadex"
|
||||||
require "compress/zip"
|
require "compress/zip"
|
||||||
|
require "../rename"
|
||||||
|
require "./ext"
|
||||||
|
|
||||||
module MangaDex
|
module MangaDex
|
||||||
class PageJob
|
class PageJob
|
||||||
@@ -21,7 +23,7 @@ module MangaDex
|
|||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@api = API.default
|
@client = Client.from_config
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ module MangaDex
|
|||||||
@downloading = true
|
@downloading = true
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
@queue.set_status Queue::JobStatus::Downloading, job
|
||||||
begin
|
begin
|
||||||
chapter = @api.get_chapter(job.id)
|
chapter = @client.chapter job.id
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
@queue.set_status Queue::JobStatus::Error, job
|
||||||
@@ -73,8 +75,8 @@ module MangaDex
|
|||||||
# 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
|
||||||
chapter.pages.each_with_index do |tuple, i|
|
chapter.pages.each_with_index do |url, i|
|
||||||
fn, url = tuple
|
fn = Path.new(URI.parse(url).path).basename
|
||||||
ext = File.extname fn
|
ext = File.extname fn
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||||
page_job = PageJob.new url, fn, writer, @retries
|
page_job = PageJob.new url, fn, writer, @retries
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
private macro properties_to_hash(names)
|
||||||
|
{
|
||||||
|
{% for name in names %}
|
||||||
|
"{{name.id}}" => {{name.id}}.to_s,
|
||||||
|
{% end %}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Monkey-patch the structures in the `mangadex` shard to suit our needs
|
||||||
|
module MangaDex
|
||||||
|
struct Client
|
||||||
|
@@group_cache = {} of String => Group
|
||||||
|
|
||||||
|
def self.from_config : Client
|
||||||
|
self.new base_url: Config.current.mangadex["base_url"].to_s,
|
||||||
|
api_url: Config.current.mangadex["api_url"].to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Manga
|
||||||
|
def rename(rule : Rename::Rule)
|
||||||
|
rule.render properties_to_hash %w(id title author artist)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
hash = JSON.parse(to_json).as_h
|
||||||
|
_chapters = chapters.map do |c|
|
||||||
|
JSON.parse c.to_info_json
|
||||||
|
end
|
||||||
|
hash["chapters"] = JSON::Any.new _chapters
|
||||||
|
hash.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Chapter
|
||||||
|
def rename(rule : Rename::Rule)
|
||||||
|
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
||||||
|
hash["groups"] = groups.map(&.name).join ","
|
||||||
|
rule.render hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_title
|
||||||
|
rule = Rename::Rule.new \
|
||||||
|
Config.current.mangadex["chapter_rename_rule"].to_s
|
||||||
|
rename rule
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
hash = JSON.parse(to_json).as_h
|
||||||
|
hash["language"] = JSON::Any.new language
|
||||||
|
_groups = {} of String => JSON::Any
|
||||||
|
groups.each do |g|
|
||||||
|
_groups[g.name] = JSON::Any.new g.id
|
||||||
|
end
|
||||||
|
hash["groups"] = JSON::Any.new _groups
|
||||||
|
hash["full_title"] = JSON::Any.new full_title
|
||||||
|
hash.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+18
-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.17.0"
|
MANGO_VERSION = "0.20.1"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@@ -53,11 +54,21 @@ 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
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
|
begin
|
||||||
Server.new.start
|
Server.new.start
|
||||||
|
rescue e
|
||||||
|
Logger.fatal e
|
||||||
|
Process.exit 1
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
MainFiber.start_and_block
|
MainFiber.start_and_block
|
||||||
@@ -105,18 +116,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+13
-8
@@ -1,13 +1,14 @@
|
|||||||
require "./router"
|
struct AdminRouter
|
||||||
|
|
||||||
class AdminRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
|
storage = Storage.default
|
||||||
|
missing_count = storage.missing_titles.size +
|
||||||
|
storage.missing_entries.size
|
||||||
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 +33,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 +52,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, \
|
||||||
@@ -68,5 +69,9 @@ class AdminRouter < Router
|
|||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||||
layout "download-manager"
|
layout "download-manager"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/admin/missing" do |env|
|
||||||
|
layout "missing-items"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+263
-43
@@ -1,9 +1,8 @@
|
|||||||
require "./router"
|
|
||||||
require "../mangadex/*"
|
require "../mangadex/*"
|
||||||
require "../upload"
|
require "../upload"
|
||||||
require "koa"
|
require "koa"
|
||||||
|
|
||||||
class APIRouter < Router
|
struct APIRouter
|
||||||
@@api_json : String?
|
@@api_json : String?
|
||||||
|
|
||||||
API_VERSION = "0.1.0"
|
API_VERSION = "0.1.0"
|
||||||
@@ -153,6 +152,7 @@ class APIRouter < Router
|
|||||||
Koa.object "dimensionResult", {
|
Koa.object "dimensionResult", {
|
||||||
"success" => "boolean",
|
"success" => "boolean",
|
||||||
"dimensions" => "$dimensionAry?",
|
"dimensions" => "$dimensionAry?",
|
||||||
|
"margin" => "number",
|
||||||
"error" => "string?",
|
"error" => "string?",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +160,27 @@ class APIRouter < Router
|
|||||||
"ids" => "$strAry",
|
"ids" => "$strAry",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Koa.object "tagsResult", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"tags" => "$strAry?",
|
||||||
|
"error" => "string?",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.object "missing", {
|
||||||
|
"path" => "string",
|
||||||
|
"id" => "string",
|
||||||
|
"signature" => "string",
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.array "missingAry", "$missing"
|
||||||
|
|
||||||
|
Koa.object "missingResult", {
|
||||||
|
"success" => "boolean",
|
||||||
|
"error" => "string?",
|
||||||
|
"entries" => "$missingAry?",
|
||||||
|
"titles" => "$missingAry?",
|
||||||
|
}
|
||||||
|
|
||||||
Koa.describe "Returns a page in a manga entry"
|
Koa.describe "Returns a page in a manga entry"
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.path "eid", desc: "Entry ID"
|
Koa.path "eid", desc: "Entry ID"
|
||||||
@@ -172,7 +193,7 @@ class APIRouter < Router
|
|||||||
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?
|
||||||
@@ -182,7 +203,7 @@ 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
|
||||||
@@ -198,7 +219,7 @@ class APIRouter < Router
|
|||||||
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?
|
||||||
@@ -209,7 +230,7 @@ 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
|
||||||
@@ -222,12 +243,12 @@ class APIRouter < Router
|
|||||||
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 = 404
|
env.response.status_code = 404
|
||||||
e.message
|
e.message
|
||||||
end
|
end
|
||||||
@@ -236,7 +257,7 @@ class APIRouter < Router
|
|||||||
Koa.describe "Returns the entire library with all titles and entries"
|
Koa.describe "Returns the entire library with all titles and entries"
|
||||||
Koa.response 200, ref: "$library"
|
Koa.response 200, ref: "$library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
send_json env, @context.library.to_json
|
send_json env, Library.default.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@@ -244,11 +265,11 @@ class APIRouter < Router
|
|||||||
Koa.response 200, ref: "$scanResult"
|
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
|
||||||
|
|
||||||
@@ -275,9 +296,9 @@ class APIRouter < Router
|
|||||||
delete "/api/admin/user/delete/:username" do |env|
|
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,
|
||||||
@@ -302,7 +323,7 @@ class APIRouter < Router
|
|||||||
put "/api/progress/:tid/:page" do |env|
|
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["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
page = env.params.url["page"].to_i
|
page = env.params.url["page"].to_i
|
||||||
entry_id = env.params.query["eid"]?
|
entry_id = env.params.query["eid"]?
|
||||||
|
|
||||||
@@ -316,7 +337,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,
|
||||||
@@ -334,7 +355,7 @@ class APIRouter < Router
|
|||||||
put "/api/bulk_progress/:action/:tid" do |env|
|
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["tid"]).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
|
||||||
|
|
||||||
@@ -343,7 +364,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,
|
||||||
@@ -363,7 +384,7 @@ class APIRouter < Router
|
|||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, ref: "$result"
|
||||||
put "/api/admin/display_name/:tid/:name" do |env|
|
put "/api/admin/display_name/:tid/:name" do |env|
|
||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["tid"])
|
title = (Library.default.get_title env.params.url["tid"])
|
||||||
.not_nil!
|
.not_nil!
|
||||||
name = env.params.url["name"]
|
name = env.params.url["name"]
|
||||||
entry = env.params.query["eid"]?
|
entry = env.params.query["eid"]?
|
||||||
@@ -374,7 +395,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,
|
||||||
@@ -393,11 +414,10 @@ class APIRouter < Router
|
|||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
api = MangaDex::API.default
|
manga = MangaDex::Client.from_config.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
|
||||||
@@ -413,21 +433,21 @@ class APIRouter < Router
|
|||||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||||
jobs = chapters.map { |chapter|
|
jobs = chapters.map { |chapter|
|
||||||
Queue::Job.new(
|
Queue::Job.new(
|
||||||
chapter["id"].as_s,
|
chapter["id"].as_i64.to_s,
|
||||||
chapter["manga_id"].as_s,
|
chapter["mangaId"].as_i64.to_s,
|
||||||
chapter["full_title"].as_s,
|
chapter["full_title"].as_s,
|
||||||
chapter["manga_title"].as_s,
|
chapter["mangaTitle"].as_s,
|
||||||
Queue::JobStatus::Pending,
|
Queue::JobStatus::Pending,
|
||||||
Time.unix chapter["time"].as_s.to_i
|
Time.unix chapter["timestamp"].as_i64
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -437,8 +457,8 @@ class APIRouter < Router
|
|||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
loop do
|
loop do
|
||||||
socket.send({
|
socket.send({
|
||||||
"jobs" => @context.queue.get_all,
|
"jobs" => Queue.default.get_all,
|
||||||
"paused" => @context.queue.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
}.to_json)
|
}.to_json)
|
||||||
sleep interval.seconds
|
sleep interval.seconds
|
||||||
end
|
end
|
||||||
@@ -451,10 +471,10 @@ class APIRouter < Router
|
|||||||
Koa.response 200, ref: "$jobs"
|
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
|
||||||
@@ -485,20 +505,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
|
||||||
@@ -544,7 +564,7 @@ class APIRouter < Router
|
|||||||
when "cover"
|
when "cover"
|
||||||
title_id = env.params.query["tid"]
|
title_id = env.params.query["tid"]
|
||||||
entry_id = env.params.query["eid"]?
|
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
|
||||||
@@ -628,7 +648,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,
|
||||||
@@ -650,7 +670,7 @@ class APIRouter < Router
|
|||||||
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?
|
||||||
@@ -659,6 +679,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, {
|
||||||
@@ -675,16 +696,215 @@ class APIRouter < Router
|
|||||||
Koa.response 404, "Entry not found"
|
Koa.response 404, "Entry not found"
|
||||||
get "/api/download/:tid/:eid" do |env|
|
get "/api/download/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||||
|
|
||||||
send_attachment env, entry.zip_path
|
send_attachment env, entry.zip_path
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
|
Koa.describe "Lists all missing titles"
|
||||||
|
Koa.response 200, ref: "$missingResult"
|
||||||
|
Koa.tag "admin"
|
||||||
|
get "/api/admin/titles/missing" do |env|
|
||||||
|
begin
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
"titles" => Storage.default.missing_titles,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Lists all missing entries"
|
||||||
|
Koa.response 200, ref: "$missingResult"
|
||||||
|
Koa.tag "admin"
|
||||||
|
get "/api/admin/entries/missing" do |env|
|
||||||
|
begin
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
"entries" => Storage.default.missing_entries,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes all missing titles"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
delete "/api/admin/titles/missing" do |env|
|
||||||
|
begin
|
||||||
|
Storage.default.delete_missing_title
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes all missing entries"
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
delete "/api/admin/entries/missing" do |env|
|
||||||
|
begin
|
||||||
|
Storage.default.delete_missing_entry
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
|
||||||
|
Does nothing if the given `tid` is not found or if the title is not missing.
|
||||||
|
MD
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
delete "/api/admin/titles/missing/:tid" do |env|
|
||||||
|
begin
|
||||||
|
tid = env.params.url["tid"]
|
||||||
|
Storage.default.delete_missing_title tid
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
|
||||||
|
Does nothing if the given `eid` is not found or if the entry is not missing.
|
||||||
|
MD
|
||||||
|
Koa.response 200, ref: "$result"
|
||||||
|
Koa.tag "admin"
|
||||||
|
delete "/api/admin/entries/missing/:eid" do |env|
|
||||||
|
begin
|
||||||
|
eid = env.params.url["eid"]
|
||||||
|
Storage.default.delete_missing_entry eid
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"error" => nil,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
doc = Koa.generate
|
doc = Koa.generate
|
||||||
@@api_json = doc.to_json if doc
|
@@api_json = doc.to_json if doc
|
||||||
|
|
||||||
|
|||||||
+57
-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,21 +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|
|
get "/api" do |env|
|
||||||
|
base_url = Config.current.base_url
|
||||||
render "src/views/api.html.ecr"
|
render "src/views/api.html.ecr"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+4
-6
@@ -1,18 +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
|
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 %}
|
||||||
|
|||||||
+269
-52
@@ -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,13 +15,16 @@ def verify_password(hash, pw)
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
|
@@insert_entry_ids = [] of IDTuple
|
||||||
|
@@insert_title_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,
|
||||||
is_title: Bool)
|
signature: String?)
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
@@ -35,35 +40,14 @@ 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
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
unless @auto_close
|
unless @auto_close
|
||||||
@db = DB.open "sqlite3://#{@path}"
|
@db = DB.open "sqlite3://#{@path}"
|
||||||
@@ -83,13 +67,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
|
||||||
@@ -216,32 +224,121 @@ class Storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_id(path, is_title)
|
def get_title_id(path, signature)
|
||||||
id = nil
|
id = nil
|
||||||
|
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
id = db.query_one? "select id from ids where path = (?)", path,
|
# First attempt to find the matching title in DB using BOTH path
|
||||||
as: {String}
|
# and signature
|
||||||
|
id = db.query_one? "select id from titles where path = (?) and " \
|
||||||
|
"signature = (?) and unavailable = 0",
|
||||||
|
path, signature.to_s, as: String
|
||||||
|
|
||||||
|
should_update = id.nil?
|
||||||
|
# If it fails, try to match using the path only. This could happen
|
||||||
|
# for example when a new entry is added to the title
|
||||||
|
id ||= db.query_one? "select id from titles where path = (?)", path,
|
||||||
|
as: String
|
||||||
|
|
||||||
|
# If it still fails, we will have to rely on the signature values.
|
||||||
|
# This could happen when the user moved or renamed the title, or
|
||||||
|
# a title containing the title
|
||||||
|
unless id
|
||||||
|
# If there are multiple rows with the same signature (this could
|
||||||
|
# happen simply by bad luck, or when the user copied a title),
|
||||||
|
# pick the row that has the most similar path to the give path
|
||||||
|
rows = [] of Tuple(String, String)
|
||||||
|
db.query "select id, path from titles where signature = (?)",
|
||||||
|
signature.to_s do |rs|
|
||||||
|
rs.each do
|
||||||
|
rows << {rs.read(String), rs.read(String)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row = rows.max_by?(&.[1].components_similarity(path))
|
||||||
|
id = row[0] if row
|
||||||
|
end
|
||||||
|
|
||||||
|
# At this point, `id` would still be nil if there's no row matching
|
||||||
|
# either the path or the signature
|
||||||
|
|
||||||
|
# If we did identify a matching title, save the path and signature
|
||||||
|
# values back to the DB
|
||||||
|
if id && should_update
|
||||||
|
db.exec "update titles set path = (?), signature = (?), " \
|
||||||
|
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
id
|
id
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_id(tp : IDTuple)
|
# See the comments in `#get_title_id` to see how this method works.
|
||||||
@insert_ids << tp
|
def get_entry_id(path, signature)
|
||||||
|
id = nil
|
||||||
|
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
id = db.query_one? "select id from ids where path = (?) and " \
|
||||||
|
"signature = (?) and unavailable = 0",
|
||||||
|
path, signature.to_s, as: String
|
||||||
|
|
||||||
|
should_update = id.nil?
|
||||||
|
id ||= db.query_one? "select id from ids where path = (?)", path,
|
||||||
|
as: String
|
||||||
|
|
||||||
|
unless id
|
||||||
|
rows = [] of Tuple(String, String)
|
||||||
|
db.query "select id, path from ids where signature = (?)",
|
||||||
|
signature.to_s do |rs|
|
||||||
|
rs.each do
|
||||||
|
rows << {rs.read(String), rs.read(String)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row = rows.max_by?(&.[1].components_similarity(path))
|
||||||
|
id = row[0] if row
|
||||||
|
end
|
||||||
|
|
||||||
|
if id && should_update
|
||||||
|
db.exec "update ids set path = (?), signature = (?), " \
|
||||||
|
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_entry_id(tp)
|
||||||
|
@@insert_entry_ids << tp
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_title_id(tp)
|
||||||
|
@@insert_title_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_title_ids.each do |tp|
|
||||||
tp[:id], tp[:is_title] ? 1 : 0
|
path = Path.new(tp[:path])
|
||||||
|
.relative_to(Config.current.library_path).to_s
|
||||||
|
conn.exec "insert into titles (id, path, signature, " \
|
||||||
|
"unavailable) values (?, ?, ?, 0)",
|
||||||
|
tp[:id], path, tp[:signature].to_s
|
||||||
|
end
|
||||||
|
@@insert_entry_ids.each do |tp|
|
||||||
|
path = Path.new(tp[:path])
|
||||||
|
.relative_to(Config.current.library_path).to_s
|
||||||
|
conn.exec "insert into ids (id, path, signature, " \
|
||||||
|
"unavailable) values (?, ?, ?, 0)",
|
||||||
|
tp[:id], path, tp[:signature].to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@insert_ids.clear
|
@@insert_entry_ids.clear
|
||||||
|
@@insert_title_ids.clear
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -266,35 +363,155 @@ class Storage
|
|||||||
img
|
img
|
||||||
end
|
end
|
||||||
|
|
||||||
def optimize
|
def get_title_tags(id : String) : Array(String)
|
||||||
|
tags = [] of String
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
Logger.info "Starting DB optimization"
|
|
||||||
get_db do |db|
|
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 natural join titles " \
|
||||||
|
"where unavailable = 0" 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 mark_unavailable
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
# Detect 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 where unavailable = 0" do |rs|
|
||||||
rs.each do
|
rs.each do
|
||||||
path = rs.read String
|
path = rs.read String
|
||||||
trash_ids << rs.read String unless File.exists? path
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
|
trash_ids << rs.read String unless File.exists? fullpath
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Delete dangling IDs
|
unless trash_ids.empty?
|
||||||
db.exec "delete from ids where id in " \
|
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||||
|
end
|
||||||
|
db.exec "update ids set unavailable = 1 where id in " \
|
||||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
||||||
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
|
|
||||||
if trash_ids.size > 0
|
|
||||||
|
|
||||||
# Delete dangling thumbnails
|
# Detect 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 where unavailable = 0" 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)"
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
|
trash_titles << rs.read String unless Dir.exists? fullpath
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Logger.info "DB optimization finished"
|
|
||||||
|
unless trash_titles.empty?
|
||||||
|
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||||
end
|
end
|
||||||
|
db.exec "update titles set unavailable = 1 where id in " \
|
||||||
|
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def get_missing(tablename)
|
||||||
|
ary = [] of IDTuple
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id, path, signature from #{tablename} " \
|
||||||
|
"where unavailable = 1" do |rs|
|
||||||
|
rs.each do
|
||||||
|
ary << {
|
||||||
|
id: rs.read(String),
|
||||||
|
path: rs.read(String),
|
||||||
|
signature: rs.read(String?),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
private def delete_missing(tablename, id : String? = nil)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
if id
|
||||||
|
db.exec "delete from #{tablename} where id = (?) " \
|
||||||
|
"and unavailable = 1", id
|
||||||
|
else
|
||||||
|
db.exec "delete from #{tablename} where unavailable = 1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_entries
|
||||||
|
get_missing "ids"
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_titles
|
||||||
|
get_missing "titles"
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_missing_entry(id = nil)
|
||||||
|
delete_missing "ids", id
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_missing_title(id = nil)
|
||||||
|
delete_missing "titles", id
|
||||||
end
|
end
|
||||||
|
|
||||||
def close
|
def close
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
require "./util"
|
||||||
|
|
||||||
|
class File
|
||||||
|
abstract struct Info
|
||||||
|
def inode : UInt64
|
||||||
|
@stat.st_ino.to_u64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the signature of the file at filename.
|
||||||
|
# When it is not a supported file, returns 0. Otherwise, uses the inode
|
||||||
|
# number as its signature. On most file systems, the inode number is
|
||||||
|
# preserved even when the file is renamed, moved or edited.
|
||||||
|
# Some cases that would cause the inode number to change:
|
||||||
|
# - Reboot/remount on some file systems
|
||||||
|
# - Replaced with a copied file
|
||||||
|
# - Moved to a different device
|
||||||
|
# Since we are also using the relative paths to match ids, we won't lose
|
||||||
|
# information as long as the above changes do not happen together with
|
||||||
|
# a file/folder rename, with no library scan in between.
|
||||||
|
def self.signature(filename) : UInt64
|
||||||
|
if is_supported_file filename
|
||||||
|
File.info(filename).inode
|
||||||
|
else
|
||||||
|
0u64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Dir
|
||||||
|
# Returns the signature of the directory at dirname. See the comments for
|
||||||
|
# `File.signature` for more information.
|
||||||
|
def self.signature(dirname) : UInt64
|
||||||
|
signatures = [File.info(dirname).inode]
|
||||||
|
self.open dirname do |dir|
|
||||||
|
dir.entries.each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dirname, fn
|
||||||
|
if File.directory? path
|
||||||
|
signatures << Dir.signature path
|
||||||
|
else
|
||||||
|
_sig = File.signature path
|
||||||
|
# Only add its signature value to `signatures` when it is a
|
||||||
|
# supported file
|
||||||
|
signatures << _sig if _sig > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Digest::CRC32.checksum(signatures.sort.join).to_u64
|
||||||
|
end
|
||||||
|
end
|
||||||
+54
-1
@@ -1,7 +1,8 @@
|
|||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
ENTRIES_IN_HOME_SECTIONS = 8
|
ENTRIES_IN_HOME_SECTIONS = 8
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
||||||
|
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||||
|
|
||||||
def random_str
|
def random_str
|
||||||
UUID.random.to_s.gsub "-", ""
|
UUID.random.to_s.gsub "-", ""
|
||||||
@@ -22,15 +23,27 @@ end
|
|||||||
|
|
||||||
def register_mime_types
|
def register_mime_types
|
||||||
{
|
{
|
||||||
|
# Comic Archives
|
||||||
".zip" => "application/zip",
|
".zip" => "application/zip",
|
||||||
".rar" => "application/x-rar-compressed",
|
".rar" => "application/x-rar-compressed",
|
||||||
".cbz" => "application/vnd.comicbook+zip",
|
".cbz" => "application/vnd.comicbook+zip",
|
||||||
".cbr" => "application/vnd.comicbook-rar",
|
".cbr" => "application/vnd.comicbook-rar",
|
||||||
|
|
||||||
|
# Favicon
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
|
||||||
|
# FontAwesome fonts
|
||||||
|
".woff" => "font/woff",
|
||||||
|
".woff2" => "font/woff2",
|
||||||
}.each do |k, v|
|
}.each do |k, v|
|
||||||
MIME.register k, v
|
MIME.register k, v
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def is_supported_file(path)
|
||||||
|
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||||
|
end
|
||||||
|
|
||||||
struct Int
|
struct Int
|
||||||
def or(other : Int)
|
def or(other : Int)
|
||||||
if self == 0
|
if self == 0
|
||||||
@@ -67,3 +80,43 @@ def env_is_true?(key : String) : Bool
|
|||||||
return false unless val
|
return false unless val
|
||||||
val.downcase.in? "1", "true"
|
val.downcase.in? "1", "true"
|
||||||
end
|
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
|
||||||
|
|
||||||
|
class String
|
||||||
|
# Returns the similarity (in [0, 1]) of two paths.
|
||||||
|
# For the two paths, separate them into arrays of components, count the
|
||||||
|
# number of matching components backwards, and divide the count by the
|
||||||
|
# number of components of the shorter path.
|
||||||
|
def components_similarity(other : String) : Float64
|
||||||
|
s, l = [self, other]
|
||||||
|
.map { |str| Path.new(str).parts }
|
||||||
|
.sort_by &.size
|
||||||
|
|
||||||
|
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
||||||
|
match / s.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
+39
-14
@@ -1,30 +1,60 @@
|
|||||||
# Web related helper functions/macros
|
# Web related helper functions/macros
|
||||||
|
|
||||||
|
def is_admin?(env) : Bool
|
||||||
|
is_admin = false
|
||||||
|
if !Config.current.auth_proxy_header_name.empty? ||
|
||||||
|
Config.current.disable_login
|
||||||
|
is_admin = Storage.default.username_is_admin get_username env
|
||||||
|
end
|
||||||
|
|
||||||
|
# The token (if exists) takes precedence over other authentication methods.
|
||||||
|
if token = env.session.string? "token"
|
||||||
|
is_admin = Storage.default.verify_admin token
|
||||||
|
end
|
||||||
|
|
||||||
|
is_admin
|
||||||
|
end
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
|
is_admin = is_admin? env
|
||||||
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
|
||||||
|
is_admin = is_admin? env
|
||||||
|
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
|
||||||
|
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
|
||||||
|
env.request.headers[header]
|
||||||
|
else
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
@@ -46,12 +76,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)
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
<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" href="<%= base_url %>admin/missing">Missing Items</a>
|
||||||
|
<% if missing_count > 0 %>
|
||||||
|
<div class="uk-align-right">
|
||||||
|
<span class="uk-badge"><%= missing_count %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
|
<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" :value="themeSetting" @change="themeChanged($event)">
|
||||||
<option>Dark</option>
|
<option>Dark</option>
|
||||||
<option>Light</option>
|
<option>Light</option>
|
||||||
<option>System</option>
|
<option>System</option>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<redoc spec-url="/openapi.json"></redoc>
|
<redoc spec-url="<%= base_url %>openapi.json"></redoc>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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,18 +1,15 @@
|
|||||||
<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/mango.css" />
|
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||||
|
|
||||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
|
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
|
||||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
<script src="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/common.js"></script>
|
<script src="<%= base_url %>js/common.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit-icons.min.js"></script>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()">
|
<div x-data="component()" x-init="init()">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
|
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
|
||||||
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
|
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
|
||||||
@@ -25,14 +25,14 @@
|
|||||||
<td x-text="job.title"></td>
|
<td x-text="job.title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
<template x-if="!job.plugin_id">
|
||||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
<template x-if="job.plugin_id">
|
||||||
<td x-text="job.manga_title"></td>
|
<td x-text="job.manga_title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
<template x-if="!job.plugin_id">
|
||||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<template x-if="job.status_message.length > 0">
|
<template x-if="job.status_message.length > 0">
|
||||||
<div class="uk-inline">
|
<div class="uk-inline">
|
||||||
<span uk-icon="info"></span>
|
<span uk-icon="info"></span>
|
||||||
<div uk-dropdown x-text="job.status_message"></div>
|
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a>
|
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||||
<template x-if="job.status_message.length > 0">
|
<template x-if="job.status_message.length > 0">
|
||||||
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a>
|
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+67
-33
@@ -1,64 +1,77 @@
|
|||||||
<h2 class=uk-title>Download from MangaDex</h2>
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
<div class="uk-grid-small" uk-grid>
|
<div x-data="downloadComponent()" x-init="init()">
|
||||||
|
<div class="uk-grid-small" uk-grid>
|
||||||
<div class="uk-width-3-4">
|
<div class="uk-width-3-4">
|
||||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
<input class="uk-input" type="text" placeholder="MangaDex manga ID or URL" x-model="searchInput" @keydown.enter.debounce="search()">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4">
|
<div class="uk-width-1-4">
|
||||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
||||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
|
||||||
|
<div x-show="data && data.chapters" x-cloak>
|
||||||
|
<div class"uk-grid-small" uk-grid style="margin-top:40px">
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<img id="cover">
|
<img :src="data.mainCover">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<p id="title"></p>
|
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
|
||||||
<p id="artist"></p>
|
<p x-text="`Artist: ${data.artist}`"></p>
|
||||||
<p id="author"></p>
|
<p x-text="`Author: ${data.author}`"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
||||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="lang-select">Language</label>
|
<label class="uk-form-label">Language</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select class="uk-select filter-field" id="lang-select">
|
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<option x-text="lang"></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="group-select">Group</label>
|
<label class="uk-form-label">Group</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select class="uk-select filter-field" id="group-select">
|
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
|
||||||
|
<template x-for="group in groups" :key="group">
|
||||||
|
<option x-text="group"></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
<label class="uk-form-label">Volume</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
<label class="uk-form-label">Chapter</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="selection-controls" class="uk-margin" hidden>
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
|
||||||
|
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
</div>
|
</div>
|
||||||
<p id="filter-notification" hidden></p>
|
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -70,12 +83,33 @@
|
|||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
|
||||||
|
<template x-if="chapters.length <= chaptersLimit">
|
||||||
|
<tbody id="selectable">
|
||||||
|
<template x-for="chp in chapters" :key="chp">
|
||||||
|
<tr class="ui-widget-content">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
|
||||||
|
<td x-text="chp.title"></td>
|
||||||
|
<td x-text="chp.language"></td>
|
||||||
|
<td>
|
||||||
|
<template x-for="grp in Object.entries(chp.groups)">
|
||||||
|
<div>
|
||||||
|
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td x-text="chp.volume"></td>
|
||||||
|
<td x-text="chp.chapter"></td>
|
||||||
|
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.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 %>
|
||||||
@@ -80,9 +82,7 @@
|
|||||||
setTheme();
|
setTheme();
|
||||||
const base_url = "<%= base_url %>";
|
const base_url = "<%= base_url %>";
|
||||||
</script>
|
</script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
|
|
||||||
<%= yield_content "script" %>
|
<%= yield_content "script" %>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
|
<% page = "Login" %>
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -29,8 +30,7 @@
|
|||||||
<script>
|
<script>
|
||||||
setTheme();
|
setTheme();
|
||||||
</script>
|
</script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<div x-data="component()" x-init="load()" x-cloak x-show="!loading">
|
||||||
|
<p x-show="empty" class="uk-text-lead uk-text-center">No missing items found.</p>
|
||||||
|
<div x-show="!empty">
|
||||||
|
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
||||||
|
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
||||||
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Relative Path</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="title in titles" :key="title">
|
||||||
|
<tr :id="`title-${title.id}`">
|
||||||
|
<td>Title</td>
|
||||||
|
<td x-text="title.path"></td>
|
||||||
|
<td x-text="title.id"></td>
|
||||||
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-for="entry in entries" :key="entry">
|
||||||
|
<tr :id="`entry-${entry.id}`">
|
||||||
|
<td>Entry</td>
|
||||||
|
<td x-text="entry.path"></td>
|
||||||
|
<td x-text="entry.id"></td>
|
||||||
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/missing-items.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -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 %>";
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-24
@@ -1,21 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="reader-bg">
|
<html class="reader-bg">
|
||||||
|
|
||||||
|
<% 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>
|
||||||
|
|
||||||
@@ -34,18 +24,19 @@
|
|||||||
<template x-for="item in items">
|
<template x-for="item in items">
|
||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
class="uk-align-center"
|
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||||
|
:style="item.style"
|
||||||
:data-src="item.url"
|
:data-src="item.url"
|
||||||
:width="item.width"
|
:width="item.width"
|
||||||
:height="item.height"
|
:height="item.height"
|
||||||
:id="item.id"
|
:id="item.id"
|
||||||
:onclick="`showControl('${item.id}')`"
|
@click="showControl($event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<%- 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>
|
||||||
|
|
||||||
@@ -55,7 +46,7 @@
|
|||||||
'uk-align-center': true,
|
'uk-align-center': true,
|
||||||
'uk-animation-slide-left': flipAnimation === 'left',
|
'uk-animation-slide-left': flipAnimation === 'left',
|
||||||
'uk-animation-slide-right': flipAnimation === 'right'
|
'uk-animation-slide-right': flipAnimation === 'right'
|
||||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="`
|
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
|
||||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
@@ -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,27 +91,27 @@
|
|||||||
</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>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||||
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/reader.js"></script>
|
<script src="<%= base_url %>js/reader.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
img[data-src][src*='data:image'] { background: white; }
|
img[data-src][src*='data:image'] { background: white; }
|
||||||
#root img { width: 100%; }
|
img:not(.spine) { width: 100%; }
|
||||||
|
.reader-bg { background: black; }
|
||||||
</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="<%= base_url %>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