mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
312 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8ce1cc7f1 | ||
|
|
24c90e7283 | ||
|
|
9ffc34e8e6 | ||
|
|
d1de8b7a4e | ||
|
|
7ae0577e4e | ||
|
|
e9b1bccbc9 | ||
|
|
293fb84e1d | ||
|
|
9c07944390 | ||
|
|
173d69eb26 | ||
|
|
21d8d0e8a7 | ||
|
|
61e85dd49f | ||
|
|
c778364ca2 | ||
|
|
7ecdb1c0dd | ||
|
|
a5a7396edd | ||
|
|
461398d219 | ||
|
|
0d52544617 | ||
|
|
c3736d222c | ||
|
|
2091053221 | ||
|
|
703e6d076b | ||
|
|
1817efe608 | ||
|
|
8814778c22 | ||
|
|
6ab885499c | ||
|
|
91561ecd6b | ||
|
|
3c399fac4e | ||
|
|
a101526672 | ||
|
|
eca47e3d32 | ||
|
|
ab3386546d | ||
|
|
857c11be85 | ||
|
|
b3ea3c6154 | ||
|
|
84168b4f53 | ||
|
|
59528de44d | ||
|
|
a29d6754e8 | ||
|
|
167e207fad | ||
|
|
3b52d72ebf | ||
|
|
dc5edc0c1b | ||
|
|
7fa8ffa0bd | ||
|
|
85b57672e6 | ||
|
|
9b111b0ee8 | ||
|
|
8b1c301950 | ||
|
|
3df4675dd7 | ||
|
|
312de0e7b5 | ||
|
|
d57ccc8f81 | ||
|
|
fea6c04c4f | ||
|
|
77df418390 | ||
|
|
750fbbb8fe | ||
|
|
cfe46b435d | ||
|
|
b2329a79b4 | ||
|
|
2007f13ed6 | ||
|
|
f70be435f9 | ||
|
|
1b32dc3de9 | ||
|
|
b83ccf1ccc | ||
|
|
a68783aa21 | ||
|
|
86beed0c5f | ||
|
|
b6c8386caf | ||
|
|
27cc669012 | ||
|
|
4b302af2a1 | ||
|
|
ab29a9eb80 | ||
|
|
e7538bb7f2 | ||
|
|
ecaec307d6 | ||
|
|
b711072492 | ||
|
|
0f94288bab | ||
|
|
bd2ed1b338 | ||
|
|
1cd777d27d | ||
|
|
1ec8dcbfda | ||
|
|
8fea35fa51 | ||
|
|
234b29bbdd | ||
|
|
edfef80e5c | ||
|
|
45ffa3d428 | ||
|
|
162318cf4a | ||
|
|
d4b58e91d1 | ||
|
|
546bd0138c | ||
|
|
ab799af866 | ||
|
|
3a932d7b0a | ||
|
|
57683d1cfb | ||
|
|
d7afd0969a | ||
|
|
4eda55552b | ||
|
|
f9254c49a1 | ||
|
|
6d834e9164 | ||
|
|
70259d8e50 | ||
|
|
0fa2bfa744 | ||
|
|
cc33fa6595 | ||
|
|
921628ba6d | ||
|
|
1199eb7a03 | ||
|
|
f075511847 | ||
|
|
80344c3bf0 | ||
|
|
8a732804ae | ||
|
|
9df372f784 | ||
|
|
cf7431b8b6 | ||
|
|
974b6cfe9b | ||
|
|
4fbe5b471c | ||
|
|
33e7e31fbc | ||
|
|
72fae7f5ed | ||
|
|
f50a7e3b3e | ||
|
|
66c4037f2b | ||
|
|
2c022a07e7 | ||
|
|
91362dfc7d | ||
|
|
97168b65d8 | ||
|
|
6e04e249e7 | ||
|
|
16397050dd | ||
|
|
3f73591dd4 | ||
|
|
ec25109fa5 | ||
|
|
96f1ef3dde | ||
|
|
b56e16e1e1 | ||
|
|
9769e760a0 | ||
|
|
70ab198a33 | ||
|
|
44a6f822cd | ||
|
|
2c241a96bb | ||
|
|
219d4446d1 | ||
|
|
d330db131e | ||
|
|
de193906a2 | ||
|
|
d13cfc045f | ||
|
|
a3b2cdd372 | ||
|
|
f4d7128b59 | ||
|
|
663c0c0b38 | ||
|
|
57b2f7c625 | ||
|
|
9489d6abfd | ||
|
|
670cf54957 | ||
|
|
2e09efbd62 | ||
|
|
523195d649 | ||
|
|
be47f309b0 | ||
|
|
03e044a1aa | ||
|
|
4eaf271fa4 | ||
|
|
4b464ed361 | ||
|
|
a9520d6f26 | ||
|
|
a151ec486d | ||
|
|
8f1383a818 | ||
|
|
f5933a48d9 | ||
|
|
7734dae138 | ||
|
|
8c90b46114 | ||
|
|
cd48b45f11 | ||
|
|
bdbdf9c94b | ||
|
|
7e36c91ea7 | ||
|
|
9309f51df6 | ||
|
|
a8f729f5c1 | ||
|
|
4e8b561f70 | ||
|
|
e6214ddc5d | ||
|
|
80e13abc4a | ||
|
|
fb43abb950 | ||
|
|
eb3e37b950 | ||
|
|
0a90e3b333 | ||
|
|
4409ed8f45 | ||
|
|
291a340cdd | ||
|
|
0667f01471 | ||
|
|
d5847bb105 | ||
|
|
3d295e961e | ||
|
|
e408398523 | ||
|
|
566cebfcdd | ||
|
|
a190ae3ed6 | ||
|
|
17d7cefa12 | ||
|
|
eaef0556fa | ||
|
|
53226eab61 | ||
|
|
ccf558eaa7 | ||
|
|
0305433e46 | ||
|
|
d2cad6c496 | ||
|
|
371796cce9 | ||
|
|
d9adb49c27 | ||
|
|
f67e4e6cb9 | ||
|
|
60a126024c | ||
|
|
da8a485087 | ||
|
|
d809c21ee1 | ||
|
|
ca1e221b10 | ||
|
|
44d9c51ff9 | ||
|
|
15a54f4f23 | ||
|
|
51806f18db | ||
|
|
79ef7bcd1c | ||
|
|
5cb85ea857 | ||
|
|
9807db6ac0 | ||
|
|
565a535d22 | ||
|
|
c5b6a8b5b9 | ||
|
|
c75c71709f | ||
|
|
11976b15f9 | ||
|
|
847f516a65 | ||
|
|
de410f42b8 | ||
|
|
0fd7caef4b | ||
|
|
5e919d3e19 | ||
|
|
9e90aa17b9 | ||
|
|
0a8fd993e5 | ||
|
|
365f71cd1d | ||
|
|
601346b209 | ||
|
|
e988a8c121 | ||
|
|
bf81a4e48b | ||
|
|
4a09aee177 | ||
|
|
00c9cc1fcd | ||
|
|
51a47b5ddd | ||
|
|
244f97a68e | ||
|
|
8d84a3c502 | ||
|
|
a26b4b3965 | ||
|
|
f2dd20cdec | ||
|
|
64d6cd293c | ||
|
|
08dc0601e8 | ||
|
|
9c983df7e9 | ||
|
|
efc547f5b2 | ||
|
|
995ca3b40f | ||
|
|
864435d3f9 | ||
|
|
64c145cf80 | ||
|
|
6549253ed1 | ||
|
|
d9565718a4 | ||
|
|
400c3024fd | ||
|
|
a703175b3a | ||
|
|
83b122ab75 | ||
|
|
1e7d6ba5b1 | ||
|
|
4d1ad8fb38 | ||
|
|
d544252e3e | ||
|
|
b02b28d3e3 | ||
|
|
d7efe1e553 | ||
|
|
1973564272 | ||
|
|
29923f6dc7 | ||
|
|
4a261d5ff8 | ||
|
|
31d425d462 | ||
|
|
a21681a6d7 | ||
|
|
208019a0b9 | ||
|
|
54e2a54ecb | ||
|
|
2426ef05ec | ||
|
|
25b90a8724 | ||
|
|
cd8944ed2d | ||
|
|
7f0c256fe6 | ||
|
|
46e6e41bfe | ||
|
|
c9f55e7a8e | ||
|
|
741c3a4e20 | ||
|
|
f6da20321d | ||
|
|
2764e955b2 | ||
|
|
00c15014a1 | ||
|
|
c6fdbfd9fd | ||
|
|
e03bf32358 | ||
|
|
bbf1520c73 | ||
|
|
8950c3a1ed | ||
|
|
17837d8a29 | ||
|
|
b4a69425c8 | ||
|
|
a612500b0f | ||
|
|
9bb7144479 | ||
|
|
ee52c52f46 | ||
|
|
daec2bdac6 | ||
|
|
e9a490676b | ||
|
|
757f7c8214 | ||
|
|
eed1a9717e | ||
|
|
8829d2e237 | ||
|
|
eec6ec60bf | ||
|
|
3a82effa40 | ||
|
|
0b3e78bcb7 | ||
|
|
cb4e4437a6 | ||
|
|
6a275286ea | ||
|
|
2743868438 | ||
|
|
d3f26ecbc9 | ||
|
|
f62344806a | ||
|
|
b7b7e6f718 | ||
|
|
05b4e77fa9 | ||
|
|
8aab113aab | ||
|
|
371c8056e7 | ||
|
|
a9a2c9faa8 | ||
|
|
011768ed1f | ||
|
|
c36d2608e8 | ||
|
|
1b25a1fa47 | ||
|
|
df7e2270a4 | ||
|
|
3c3549a489 | ||
|
|
8160b0a18e | ||
|
|
a7eff772be | ||
|
|
bf3900f9a2 | ||
|
|
6fa575cf4f | ||
|
|
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 | ||
|
|
c1c8cca877 | ||
|
|
07965b98b7 | ||
|
|
5779d225f6 | ||
|
|
bf18a14016 | ||
|
|
605dc61777 | ||
|
|
def64d9f98 | ||
|
|
0ba2409c9a | ||
|
|
2b0cf41336 | ||
|
|
c51cb28df2 | ||
|
|
2b079c652d | ||
|
|
68050a9025 | ||
|
|
54cd15d542 | ||
|
|
781de97c68 | ||
|
|
c7be0e0e7c | ||
|
|
667d390be4 | ||
|
|
7f76322377 | ||
|
|
377c4c6554 | ||
|
|
18e8e88c66 |
@@ -95,6 +95,42 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "davidkna",
|
||||
"name": "David Knaack",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
||||
"profile": "https://github.com/davidkna",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lincolnthedev",
|
||||
"name": "i use arch btw",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
|
||||
"profile": "https://lncn.dev",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "BradleyDS2",
|
||||
"name": "BradleyDS2",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4",
|
||||
"profile": "https://github.com/BradleyDS2",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "nduja",
|
||||
"name": "Robbo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4",
|
||||
"profile": "https://github.com/nduja",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -7,3 +7,8 @@ Lint/UnusedArgument:
|
||||
- src/routes/*
|
||||
Metrics/CyclomaticComplexity:
|
||||
Enabled: false
|
||||
Layout/LineLength:
|
||||
Enabled: true
|
||||
MaxLength: 80
|
||||
Excluded:
|
||||
- src/routes/api.cr
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
node_modules
|
||||
lib
|
||||
Dockerfile
|
||||
Dockerfile.arm32v7
|
||||
Dockerfile.arm64v8
|
||||
README.md
|
||||
.all-contributorsrc
|
||||
env.example
|
||||
.github/
|
||||
|
||||
6
.github/autoapproval.yml
vendored
Normal file
6
.github/autoapproval.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
from_owner:
|
||||
- hkalexling
|
||||
required_labels:
|
||||
- autoapprove
|
||||
apply_labels:
|
||||
- autoapproved
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -12,12 +12,12 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: crystallang/crystal:0.35.1-alpine
|
||||
image: crystallang/crystal:1.0.0-alpine
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
- name: Build
|
||||
run: make static || make static
|
||||
- name: Linter
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ public/css/uikit.css
|
||||
public/img/*.svg
|
||||
public/js/*.min.js
|
||||
public/css/*.css
|
||||
public/webfonts
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
FROM crystallang/crystal:0.35.1-alpine AS builder
|
||||
FROM crystallang/crystal:1.0.0-alpine AS builder
|
||||
|
||||
WORKDIR /Mango
|
||||
|
||||
COPY . .
|
||||
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||
RUN make static || make static
|
||||
|
||||
FROM library/alpine
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /Mango/mango .
|
||||
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
||||
|
||||
CMD ["./mango"]
|
||||
CMD ["/usr/local/bin/mango"]
|
||||
|
||||
@@ -2,13 +2,14 @@ FROM arm32v7/ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||
|
||||
COPY mango-arm32v7.o .
|
||||
|
||||
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
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,13 +2,13 @@ FROM arm64v8/ubuntu:18.04
|
||||
|
||||
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||
|
||||
COPY mango-arm64v8.o .
|
||||
|
||||
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||
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"]
|
||||
|
||||
1
Makefile
1
Makefile
@@ -29,7 +29,6 @@ test:
|
||||
check:
|
||||
crystal tool format --check
|
||||
./bin/ameba
|
||||
./dev/linewidth.sh
|
||||
|
||||
arm32v7:
|
||||
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
||||
|
||||
32
README.md
32
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# Mango
|
||||
|
||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
||||
|
||||
Mango is a self-hosted manga server and reader. Its features include
|
||||
|
||||
@@ -13,8 +13,7 @@ Mango is a self-hosted manga server and reader. Its features include
|
||||
- Supports nested folders in library
|
||||
- Automatically stores reading progress
|
||||
- Thumbnail generation
|
||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from third-party sites
|
||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||
|
||||
@@ -52,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango - Manga Server and Web Reader. Version 0.19.1
|
||||
Mango - Manga Server and Web Reader. Version 0.26.0
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -75,34 +74,33 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
||||
|
||||
```yaml
|
||||
---
|
||||
host: 0.0.0.0
|
||||
port: 9000
|
||||
base_url: /
|
||||
session_secret: mango-session-secret
|
||||
library_path: ~/mango/library
|
||||
db_path: ~/mango/mango.db
|
||||
queue_db_path: ~/mango/queue.db
|
||||
scan_interval_minutes: 5
|
||||
thumbnail_generation_interval_hours: 24
|
||||
db_optimization_interval_hours: 24
|
||||
log_level: info
|
||||
upload_path: ~/mango/uploads
|
||||
plugin_path: ~/mango/plugins
|
||||
download_timeout_seconds: 30
|
||||
page_margin: 30
|
||||
library_cache_path: ~/mango/library.yml.gz
|
||||
cache_enabled: true
|
||||
cache_size_mbs: 50
|
||||
cache_log_enabled: true
|
||||
disable_login: false
|
||||
default_username: ""
|
||||
mangadex:
|
||||
base_url: https://mangadex.org
|
||||
api_url: https://mangadex.org/api
|
||||
download_wait_seconds: 5
|
||||
download_retries: 4
|
||||
download_queue_db_path: ~/mango/queue.db
|
||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||
manga_rename_rule: '{title}'
|
||||
auth_proxy_header_name: ""
|
||||
plugin_update_interval_hours: 24
|
||||
```
|
||||
|
||||
- `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 `plugin_update_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
|
||||
- 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.
|
||||
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||
|
||||
### Library Structure
|
||||
|
||||
@@ -173,6 +171,10 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
|
||||
<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>
|
||||
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/BradleyDS2"><img src="https://avatars.githubusercontent.com/u/2174921?v=4?s=100" width="100px;" alt=""/><br /><sub><b>BradleyDS2</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=BradleyDS2" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://github.com/nduja"><img src="https://avatars.githubusercontent.com/u/69299134?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robbo</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=nduja" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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
|
||||
36
gulpfile.js
36
gulpfile.js
@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
|
||||
const minifyCss = require('gulp-minify-css');
|
||||
const less = require('gulp-less');
|
||||
|
||||
// Copy libraries from node_moduels to public/js
|
||||
gulp.task('copy-js', () => {
|
||||
return gulp.src([
|
||||
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
||||
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
||||
'node_modules/uikit/dist/js/uikit.min.js',
|
||||
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
||||
])
|
||||
.pipe(gulp.dest('public/js'));
|
||||
});
|
||||
|
||||
// Copy UIKit SVG icons to public/img
|
||||
gulp.task('copy-uikit-icons', () => {
|
||||
gulp.task('copy-img', () => {
|
||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||
.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
|
||||
gulp.task('less', () => {
|
||||
return gulp.src('public/css/*.less')
|
||||
return gulp.src([
|
||||
'public/css/mango.less',
|
||||
'public/css/tags.less'
|
||||
])
|
||||
.pipe(less())
|
||||
.pipe(gulp.dest('public/css'));
|
||||
});
|
||||
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
|
||||
|
||||
// Copy static files (includeing images) to dist
|
||||
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'
|
||||
})
|
||||
.pipe(gulp.dest('dist'));
|
||||
});
|
||||
|
||||
// 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
|
||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||
|
||||
50
migration/ids_signature.7.cr
Normal file
50
migration/ids_signature.7.cr
Normal file
@@ -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
|
||||
20
migration/md_account.11.cr
Normal file
20
migration/md_account.11.cr
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateMangaDexAccount < MG::Base
|
||||
def up : String
|
||||
<<-SQL
|
||||
CREATE TABLE md_account (
|
||||
username TEXT NOT NULL PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
expire INTEGER NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES users (username)
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
SQL
|
||||
end
|
||||
|
||||
def down : String
|
||||
<<-SQL
|
||||
DROP TABLE md_account;
|
||||
SQL
|
||||
end
|
||||
end
|
||||
33
migration/relative_path.8.cr
Normal file
33
migration/relative_path.8.cr
Normal file
@@ -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
|
||||
31
migration/relative_path_fix.10.cr
Normal file
31
migration/relative_path_fix.10.cr
Normal file
@@ -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
|
||||
94
migration/sort_title.12.cr
Normal file
94
migration/sort_title.12.cr
Normal file
@@ -0,0 +1,94 @@
|
||||
class SortTitle < MG::Base
|
||||
def up : String
|
||||
<<-SQL
|
||||
-- add sort_title column to ids and titles
|
||||
ALTER TABLE ids ADD COLUMN sort_title TEXT;
|
||||
ALTER TABLE titles ADD COLUMN sort_title TEXT;
|
||||
SQL
|
||||
end
|
||||
|
||||
def down : String
|
||||
<<-SQL
|
||||
-- remove sort_title column from ids
|
||||
ALTER TABLE ids RENAME TO tmp;
|
||||
|
||||
CREATE TABLE ids (
|
||||
path TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
unavailable INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO ids
|
||||
SELECT path, id, signature, unavailable
|
||||
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 sort_title column from titles
|
||||
ALTER TABLE titles RENAME TO tmp;
|
||||
|
||||
CREATE TABLE titles (
|
||||
id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
unavailable INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO titles
|
||||
SELECT id, path, signature, unavailable
|
||||
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
|
||||
94
migration/unavailable.9.cr
Normal file
94
migration/unavailable.9.cr
Normal file
@@ -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
|
||||
@@ -1,3 +1,16 @@
|
||||
// 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;
|
||||
@@ -21,9 +34,11 @@
|
||||
.uk-card-body {
|
||||
padding: 20px;
|
||||
.uk-card-title {
|
||||
max-height: 3em;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.uk-card-title:not(.free-height) {
|
||||
max-height: 3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +81,6 @@
|
||||
|
||||
// Dark theme
|
||||
.uk-light {
|
||||
.uk-navbar-dropdown,
|
||||
.uk-modal-header,
|
||||
.uk-modal-body,
|
||||
.uk-modal-footer {
|
||||
@@ -75,6 +89,7 @@
|
||||
.uk-navbar-dropdown,
|
||||
.uk-dropdown {
|
||||
color: #ccc;
|
||||
background: #333;
|
||||
}
|
||||
.uk-nav-header,
|
||||
.uk-description-list > dt {
|
||||
|
||||
@@ -43,3 +43,22 @@
|
||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||
|
||||
.hook-card-default() {
|
||||
.uk-light & {
|
||||
background: @card-secondary-background;
|
||||
color: @card-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.hook-card-default-title() {
|
||||
.uk-light & {
|
||||
color: @card-secondary-title-color;
|
||||
}
|
||||
}
|
||||
|
||||
.hook-card-default-hover() {
|
||||
.uk-light & {
|
||||
background-color: @card-secondary-hover-background;
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
BIN
public/img/icons/icon_x192.png
Normal file
BIN
public/img/icons/icon_x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/img/icons/icon_x512.png
Normal file
BIN
public/img/icons/icon_x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
public/img/icons/icon_x96.png
Normal file
BIN
public/img/icons/icon_x96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -31,6 +31,9 @@ const component = () => {
|
||||
this.scanMs = data.milliseconds;
|
||||
this.scanTitles = data.titles;
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to trigger a scan. Error: ${e}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.scanning = false;
|
||||
});
|
||||
|
||||
@@ -117,14 +117,10 @@ const setTheme = (theme) => {
|
||||
if (theme === 'dark') {
|
||||
$('html').css('background', 'rgb(20, 20, 20)');
|
||||
$('body').addClass('uk-light');
|
||||
$('.uk-card').addClass('uk-card-secondary');
|
||||
$('.uk-card').removeClass('uk-card-default');
|
||||
$('.ui-widget-content').addClass('dark');
|
||||
} else {
|
||||
$('html').css('background', '');
|
||||
$('body').removeClass('uk-light');
|
||||
$('.uk-card').removeClass('uk-card-secondary');
|
||||
$('.uk-card').addClass('uk-card-default');
|
||||
$('.ui-widget-content').removeClass('dark');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const component = () => {
|
||||
jobAction(action, event) {
|
||||
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||
if (event) {
|
||||
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
||||
const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
|
||||
url = `${url}?${$.param({
|
||||
id: id
|
||||
})}`;
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
$(() => {
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('.filter-field').each((i, ele) => {
|
||||
$(ele).change(() => {
|
||||
buildTable();
|
||||
});
|
||||
});
|
||||
});
|
||||
const selectAll = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
};
|
||||
const unselect = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
};
|
||||
const download = () => {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
$('#download-btn').attr('hidden', '');
|
||||
$('#download-spinner').removeAttr('hidden');
|
||||
const ids = selected.map((i, e) => {
|
||||
return $(e).find('td').first().text();
|
||||
}).get();
|
||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||
console.log(ids);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/mangadex/download',
|
||||
data: JSON.stringify({
|
||||
chapters: chapters
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
});
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
});
|
||||
});
|
||||
};
|
||||
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;
|
||||
};
|
||||
60
public/js/missing-items.js
Normal file
60
public/js/missing-items.js
Normal file
@@ -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}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,141 +1,446 @@
|
||||
const loadPlugin = id => {
|
||||
localStorage.setItem('plugin', id);
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||
const newURL = `${url}?${$.param({
|
||||
plugin: id
|
||||
})}`;
|
||||
window.location.href = newURL;
|
||||
};
|
||||
const component = () => {
|
||||
return {
|
||||
plugins: [],
|
||||
info: undefined,
|
||||
pid: undefined,
|
||||
chapters: undefined, // undefined: not searched yet, []: empty
|
||||
manga: undefined, // undefined: not searched yet, []: empty
|
||||
mid: undefined, // id of the selected manga
|
||||
allChapters: [],
|
||||
query: "",
|
||||
mangaTitle: "",
|
||||
searching: false,
|
||||
adding: false,
|
||||
sortOptions: [],
|
||||
showFilters: false,
|
||||
appliedFilters: [],
|
||||
chaptersLimit: 500,
|
||||
listManga: false,
|
||||
subscribing: false,
|
||||
subscriptionName: "",
|
||||
|
||||
$(() => {
|
||||
var storedID = localStorage.getItem('plugin');
|
||||
if (storedID && storedID !== pid) {
|
||||
loadPlugin(storedID);
|
||||
} else {
|
||||
$('#controls').removeAttr('hidden');
|
||||
}
|
||||
init() {
|
||||
const tableObserver = new MutationObserver(() => {
|
||||
console.log("table mutated");
|
||||
$("#selectable").selectable({
|
||||
filter: "tr",
|
||||
});
|
||||
});
|
||||
tableObserver.observe($("table").get(0), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
fetch(`${base_url}api/admin/plugin`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.plugins = data.plugins;
|
||||
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('#plugin-select').val(pid);
|
||||
$('#plugin-select').change(() => {
|
||||
const id = $('#plugin-select').val();
|
||||
loadPlugin(id);
|
||||
});
|
||||
});
|
||||
const pid = localStorage.getItem("plugin");
|
||||
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
||||
return this.loadPlugin(pid);
|
||||
|
||||
let mangaTitle = "";
|
||||
let searching = false;
|
||||
const search = () => {
|
||||
if (searching)
|
||||
return;
|
||||
if (this.plugins.length > 0)
|
||||
this.loadPlugin(this.plugins[0].id);
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to list the available plugins. Error: ${e}`
|
||||
);
|
||||
});
|
||||
},
|
||||
loadPlugin(pid) {
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
||||
plugin: pid,
|
||||
})}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.info = data.info;
|
||||
this.pid = pid;
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to get plugin metadata. Error: ${e}`
|
||||
);
|
||||
});
|
||||
},
|
||||
pluginChanged() {
|
||||
this.loadPlugin(this.pid);
|
||||
localStorage.setItem("plugin", this.pid);
|
||||
},
|
||||
get chapterKeys() {
|
||||
if (this.allChapters.length < 1) return [];
|
||||
return Object.keys(this.allChapters[0]).filter(
|
||||
(k) => !["manga_title"].includes(k)
|
||||
);
|
||||
},
|
||||
searchChapters(query) {
|
||||
this.searching = true;
|
||||
this.allChapters = [];
|
||||
this.sortOptions = [];
|
||||
this.chapters = undefined;
|
||||
this.listManga = false;
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/list?${new URLSearchParams({
|
||||
plugin: this.pid,
|
||||
query: query,
|
||||
})}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
try {
|
||||
this.mangaTitle = data.chapters[0].manga_title;
|
||||
if (!this.mangaTitle) throw new Error();
|
||||
} catch (e) {
|
||||
this.mangaTitle = data.title;
|
||||
}
|
||||
|
||||
const query = $.param({
|
||||
query: $('#search-input').val(),
|
||||
plugin: pid
|
||||
});
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Search failed. Error: ${data.error}`);
|
||||
this.allChapters = data.chapters;
|
||||
this.chapters = data.chapters;
|
||||
})
|
||||
.catch((e) => {
|
||||
alert("danger", `Failed to list chapters. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
searchManga(query) {
|
||||
this.searching = true;
|
||||
this.allChapters = [];
|
||||
this.chapters = undefined;
|
||||
this.manga = undefined;
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
||||
plugin: this.pid,
|
||||
query: query,
|
||||
})}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.manga = data.manga;
|
||||
this.listManga = true;
|
||||
})
|
||||
.catch((e) => {
|
||||
alert("danger", `Search failed. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
search() {
|
||||
const query = this.query.trim();
|
||||
if (!query) return;
|
||||
|
||||
this.manga = undefined;
|
||||
if (this.info.version === 1) {
|
||||
this.searchChapters(query);
|
||||
} else {
|
||||
this.searchManga(query);
|
||||
}
|
||||
},
|
||||
selectAll() {
|
||||
$("tbody > tr").each((i, e) => {
|
||||
$(e).addClass("ui-selected");
|
||||
});
|
||||
},
|
||||
clearSelection() {
|
||||
$("tbody > tr").each((i, e) => {
|
||||
$(e).removeClass("ui-selected");
|
||||
});
|
||||
},
|
||||
download() {
|
||||
const selected = $("tbody > tr.ui-selected").get();
|
||||
if (selected.length === 0) return;
|
||||
|
||||
UIkit.modal
|
||||
.confirm(`Download ${selected.length} selected chapters?`)
|
||||
.then(() => {
|
||||
const ids = selected.map((e) => e.id);
|
||||
const chapters = this.chapters.filter((c) =>
|
||||
ids.includes(c.id)
|
||||
);
|
||||
console.log(chapters);
|
||||
this.adding = true;
|
||||
fetch(`${base_url}api/admin/plugin/download`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
chapters,
|
||||
plugin: this.pid,
|
||||
title: this.mangaTitle,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
alert(
|
||||
"success",
|
||||
`${successCount} of ${
|
||||
successCount + failCount
|
||||
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to add chapters to the download queue. Error: ${e}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.adding = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
thClicked(event) {
|
||||
const idx = parseInt(event.currentTarget.id.split("-")[1]);
|
||||
if (idx === undefined || isNaN(idx)) return;
|
||||
const curOption = this.sortOptions[idx];
|
||||
let option;
|
||||
this.sortOptions = [];
|
||||
switch (curOption) {
|
||||
case 1:
|
||||
option = -1;
|
||||
break;
|
||||
case -1:
|
||||
option = 0;
|
||||
break;
|
||||
default:
|
||||
option = 1;
|
||||
}
|
||||
this.sortOptions[idx] = option;
|
||||
this.sort(this.chapterKeys[idx], option);
|
||||
},
|
||||
// Returns an array of filtered but unsorted chapters. Useful when
|
||||
// reseting the sort options.
|
||||
get filteredChapters() {
|
||||
let ary = this.allChapters.slice();
|
||||
|
||||
console.log("initial size:", ary.length);
|
||||
for (let filter of this.appliedFilters) {
|
||||
if (!filter.value) continue;
|
||||
if (filter.type === "array" && filter.value === "all") continue;
|
||||
if (filter.type.startsWith("number") && isNaN(filter.value))
|
||||
continue;
|
||||
|
||||
if (filter.type === "string") {
|
||||
ary = ary.filter((ch) =>
|
||||
ch[filter.key]
|
||||
.toLowerCase()
|
||||
.includes(filter.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
if (filter.type === "number-min") {
|
||||
ary = ary.filter(
|
||||
(ch) => Number(ch[filter.key]) >= Number(filter.value)
|
||||
);
|
||||
}
|
||||
if (filter.type === "number-max") {
|
||||
ary = ary.filter(
|
||||
(ch) => Number(ch[filter.key]) <= Number(filter.value)
|
||||
);
|
||||
}
|
||||
if (filter.type === "date-min") {
|
||||
ary = ary.filter(
|
||||
(ch) => Number(ch[filter.key]) >= Number(filter.value)
|
||||
);
|
||||
}
|
||||
if (filter.type === "date-max") {
|
||||
ary = ary.filter(
|
||||
(ch) => Number(ch[filter.key]) <= Number(filter.value)
|
||||
);
|
||||
}
|
||||
if (filter.type === "array") {
|
||||
ary = ary.filter((ch) =>
|
||||
ch[filter.key]
|
||||
.map((s) =>
|
||||
typeof s === "string" ? s.toLowerCase() : s
|
||||
)
|
||||
.includes(filter.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
console.log("filtered size:", ary.length);
|
||||
}
|
||||
|
||||
return ary;
|
||||
},
|
||||
// option:
|
||||
// - 1: asending
|
||||
// - -1: desending
|
||||
// - 0: unsorted
|
||||
sort(key, option) {
|
||||
if (option === 0) {
|
||||
this.chapters = this.filteredChapters;
|
||||
return;
|
||||
}
|
||||
mangaTitle = data.title;
|
||||
$('#title-text').text(data.title);
|
||||
buildTable(data.chapters);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {});
|
||||
};
|
||||
|
||||
const buildTable = (chapters) => {
|
||||
$('#table').attr('hidden', '');
|
||||
$('table').empty();
|
||||
|
||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||
$('table').append(thead);
|
||||
|
||||
const rows = chapters.map(ch => {
|
||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||
});
|
||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||
$('table').append(tbody);
|
||||
|
||||
$('#selectable').selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
|
||||
$('#table table').tablesorter();
|
||||
$('#table').removeAttr('hidden');
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const unselect = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
$('#download-btn').attr('hidden', '');
|
||||
$('#download-spinner').removeAttr('hidden');
|
||||
const chapters = selected.map((i, e) => {
|
||||
return {
|
||||
id: $(e).attr('data-id'),
|
||||
title: $(e).attr('data-title')
|
||||
}
|
||||
}).get();
|
||||
console.log(chapters);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/plugin/download',
|
||||
data: JSON.stringify({
|
||||
plugin: pid,
|
||||
chapters: chapters,
|
||||
title: mangaTitle
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
});
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||
const comp = this.compare(a[key], b[key]);
|
||||
return option < 0 ? comp * -1 : comp;
|
||||
});
|
||||
});
|
||||
},
|
||||
compare(a, b) {
|
||||
if (a === b) return 0;
|
||||
|
||||
// try numbers (also covers dates)
|
||||
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
|
||||
|
||||
const preprocessString = (val) => {
|
||||
if (typeof val !== "string") return val;
|
||||
return val.toLowerCase().replace(/\s\s/g, " ").trim();
|
||||
};
|
||||
|
||||
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||
},
|
||||
fieldType(values) {
|
||||
if (values.every((v) => this.numIsDate(v))) return "date";
|
||||
if (values.every((v) => !isNaN(v))) return "number";
|
||||
if (values.every((v) => Array.isArray(v))) return "array";
|
||||
return "string";
|
||||
},
|
||||
get filters() {
|
||||
if (this.allChapters.length < 1) return [];
|
||||
const keys = Object.keys(this.allChapters[0]).filter(
|
||||
(k) => !["manga_title", "id"].includes(k)
|
||||
);
|
||||
return keys.map((k) => {
|
||||
let values = this.allChapters.map((c) => c[k]);
|
||||
const type = this.fieldType(values);
|
||||
|
||||
if (type === "array") {
|
||||
// if the type is an array, return the list of available elements
|
||||
// example: an array of groups or authors
|
||||
values = Array.from(
|
||||
new Set(
|
||||
values.flat().map((v) => {
|
||||
if (typeof v === "string")
|
||||
return v.toLowerCase();
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: k,
|
||||
type: type,
|
||||
values: values,
|
||||
};
|
||||
});
|
||||
},
|
||||
get filterSettings() {
|
||||
return $("#filter-form input:visible, #filter-form select:visible")
|
||||
.get()
|
||||
.map((i) => {
|
||||
const type = i.getAttribute("data-filter-type");
|
||||
let value = i.value.trim();
|
||||
if (type.startsWith("date"))
|
||||
value = value ? Date.parse(value).toString() : "";
|
||||
return {
|
||||
key: i.getAttribute("data-filter-key"),
|
||||
value: value,
|
||||
type: type,
|
||||
};
|
||||
});
|
||||
},
|
||||
applyFilters() {
|
||||
this.appliedFilters = this.filterSettings;
|
||||
this.chapters = this.filteredChapters;
|
||||
this.sortOptions = [];
|
||||
},
|
||||
clearFilters() {
|
||||
$("#filter-form input")
|
||||
.get()
|
||||
.forEach((i) => (i.value = ""));
|
||||
$("#filter-form select").val("all");
|
||||
this.appliedFilters = [];
|
||||
this.chapters = this.filteredChapters;
|
||||
this.sortOptions = [];
|
||||
},
|
||||
mangaSelected(event) {
|
||||
const mid = event.currentTarget.getAttribute("data-id");
|
||||
this.mid = mid;
|
||||
this.searchChapters(mid);
|
||||
},
|
||||
subscribe(modal) {
|
||||
this.subscribing = true;
|
||||
fetch(`${base_url}api/admin/plugin/subscriptions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
filters: this.filterSettings,
|
||||
plugin: this.pid,
|
||||
name: this.subscriptionName.trim(),
|
||||
manga: this.mangaTitle,
|
||||
manga_id: this.mid,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
alert("success", "Subscription created");
|
||||
})
|
||||
.catch((e) => {
|
||||
alert("danger", `Failed to subscribe. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.subscribing = false;
|
||||
UIkit.modal(modal).hide();
|
||||
});
|
||||
},
|
||||
numIsDate(num) {
|
||||
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
|
||||
},
|
||||
renderCell(value) {
|
||||
if (this.numIsDate(value))
|
||||
return `<span>${moment(Number(value)).format(
|
||||
"MMM D, YYYY"
|
||||
)}</span>`;
|
||||
const maxLength = 40;
|
||||
if (value && value.length > maxLength)
|
||||
return `<span>${value.substr(
|
||||
0,
|
||||
maxLength
|
||||
)}...</span><div uk-dropdown>${value}</div>`;
|
||||
return `<span>${value}</span>`;
|
||||
},
|
||||
renderFilterRow(ft) {
|
||||
const key = ft.key;
|
||||
let type = ft.type;
|
||||
switch (type) {
|
||||
case "number-min":
|
||||
type = "number (minimum value)";
|
||||
break;
|
||||
case "number-max":
|
||||
type = "number (maximum value)";
|
||||
break;
|
||||
case "date-min":
|
||||
type = "minimum date";
|
||||
break;
|
||||
case "date-max":
|
||||
type = "maximum date";
|
||||
break;
|
||||
}
|
||||
let value = ft.value;
|
||||
|
||||
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
||||
else if (ft.type.startsWith("date") && value)
|
||||
value = moment(Number(value)).format("MMM D, YYYY");
|
||||
|
||||
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,9 +6,14 @@ const readerComponent = () => {
|
||||
alertClass: 'uk-alert-primary',
|
||||
items: [],
|
||||
curItem: {},
|
||||
enableFlipAnimation: true,
|
||||
flipAnimation: null,
|
||||
longPages: false,
|
||||
lastSavedPage: page,
|
||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||
margin: 30,
|
||||
preloadLookahead: 3,
|
||||
enableRightToLeft: false,
|
||||
|
||||
/**
|
||||
* Initialize the component by fetching the page dimensions
|
||||
@@ -26,7 +31,6 @@ const readerComponent = () => {
|
||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,6 +50,28 @@ const readerComponent = () => {
|
||||
const mode = this.mode;
|
||||
this.updateMode(this.mode, page, nextTick);
|
||||
$('#mode-select').val(mode);
|
||||
|
||||
const savedMargin = localStorage.getItem('margin');
|
||||
if (savedMargin) {
|
||||
this.margin = savedMargin;
|
||||
}
|
||||
|
||||
// Preload Images
|
||||
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||
for (let idx = page + 1; idx <= limit; idx++) {
|
||||
this.preloadImage(this.items[idx - 1].url);
|
||||
}
|
||||
|
||||
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||
|
||||
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
|
||||
if (savedRightToLeft === null) {
|
||||
this.enableRightToLeft = false;
|
||||
} else {
|
||||
this.enableRightToLeft = (savedRightToLeft === 'true');
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||
@@ -54,6 +80,12 @@ const readerComponent = () => {
|
||||
this.msg = errMsg;
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Preload an image, which is expected to be cached
|
||||
*/
|
||||
preloadImage(url) {
|
||||
(new Image()).src = url;
|
||||
},
|
||||
/**
|
||||
* Handles the `change` event for the page selector
|
||||
*/
|
||||
@@ -90,9 +122,9 @@ const readerComponent = () => {
|
||||
if (this.mode === 'continuous') return;
|
||||
|
||||
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||
this.flipPage(false);
|
||||
this.flipPage(false ^ this.enableRightToLeft);
|
||||
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||
this.flipPage(true);
|
||||
this.flipPage(true ^ this.enableRightToLeft);
|
||||
},
|
||||
/**
|
||||
* Flips to the next or the previous page
|
||||
@@ -105,12 +137,18 @@ const readerComponent = () => {
|
||||
|
||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||
|
||||
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||
}
|
||||
|
||||
this.toPage(newIdx);
|
||||
|
||||
if (isNext)
|
||||
this.flipAnimation = 'right';
|
||||
else
|
||||
this.flipAnimation = 'left';
|
||||
if (this.enableFlipAnimation) {
|
||||
if (isNext ^ this.enableRightToLeft)
|
||||
this.flipAnimation = 'right';
|
||||
else
|
||||
this.flipAnimation = 'left';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.flipAnimation = null;
|
||||
@@ -221,10 +259,7 @@ const readerComponent = () => {
|
||||
*/
|
||||
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);
|
||||
this.selectedIndex = idx;
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
},
|
||||
/**
|
||||
@@ -263,19 +298,39 @@ const readerComponent = () => {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Exits the reader, and optionally sets the reading progress tp 100%
|
||||
* Exits the reader, and sets the reading progress tp 100%
|
||||
*
|
||||
* @param {string} exitUrl - The Exit URL
|
||||
* @param {boolean} [markCompleted] - Whether we should mark the
|
||||
* reading progress to 100%
|
||||
*/
|
||||
exitReader(exitUrl, markCompleted = false) {
|
||||
if (!markCompleted) {
|
||||
return this.redirect(exitUrl);
|
||||
}
|
||||
exitReader(exitUrl) {
|
||||
this.saveProgress(this.items.length, () => {
|
||||
this.redirect(exitUrl);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles the `change` event for the entry selector
|
||||
*/
|
||||
entryChanged() {
|
||||
const id = $('#entry-select').val();
|
||||
this.redirect(`${base_url}reader/${tid}/${id}`);
|
||||
},
|
||||
|
||||
marginChanged() {
|
||||
localStorage.setItem('margin', this.margin);
|
||||
this.toPage(this.selectedIndex);
|
||||
},
|
||||
|
||||
preloadLookaheadChanged() {
|
||||
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||
},
|
||||
|
||||
enableFlipAnimationChanged() {
|
||||
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
||||
},
|
||||
|
||||
enableRightToLeftChanged() {
|
||||
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
147
public/js/subscription-manager.js
Normal file
147
public/js/subscription-manager.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const component = () => {
|
||||
return {
|
||||
subscriptions: [],
|
||||
plugins: [],
|
||||
pid: undefined,
|
||||
subscription: undefined, // selected subscription
|
||||
loading: false,
|
||||
|
||||
init() {
|
||||
fetch(`${base_url}api/admin/plugin`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.plugins = data.plugins;
|
||||
|
||||
const pid = localStorage.getItem("plugin");
|
||||
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
||||
this.pid = pid;
|
||||
else if (this.plugins.length > 0)
|
||||
this.pid = this.plugins[0].id;
|
||||
|
||||
this.list(pid);
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to list the available plugins. Error: ${e}`
|
||||
);
|
||||
});
|
||||
},
|
||||
pluginChanged() {
|
||||
localStorage.setItem("plugin", this.pid);
|
||||
this.list(this.pid);
|
||||
},
|
||||
list(pid) {
|
||||
if (!pid) return;
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
|
||||
{
|
||||
plugin: pid,
|
||||
}
|
||||
)}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.subscriptions = data.subscriptions;
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to list subscriptions. Error: ${e}`
|
||||
);
|
||||
});
|
||||
},
|
||||
renderStrCell(str) {
|
||||
const maxLength = 40;
|
||||
if (str.length > maxLength)
|
||||
return `<td><span>${str.substring(
|
||||
0,
|
||||
maxLength
|
||||
)}...</span><div uk-dropdown>${str}</div></td>`;
|
||||
return `<td>${str}</td>`;
|
||||
},
|
||||
renderDateCell(timestamp) {
|
||||
return `<td>${moment
|
||||
.duration(moment.unix(timestamp).diff(moment()))
|
||||
.humanize(true)}</td>`;
|
||||
},
|
||||
selected(event, modal) {
|
||||
const id = event.currentTarget.getAttribute("sid");
|
||||
this.subscription = this.subscriptions.find((s) => s.id === id);
|
||||
UIkit.modal(modal).show();
|
||||
},
|
||||
renderFilterRow(ft) {
|
||||
const key = ft.key;
|
||||
let type = ft.type;
|
||||
switch (type) {
|
||||
case "number-min":
|
||||
type = "number (minimum value)";
|
||||
break;
|
||||
case "number-max":
|
||||
type = "number (maximum value)";
|
||||
break;
|
||||
case "date-min":
|
||||
type = "minimum date";
|
||||
break;
|
||||
case "date-max":
|
||||
type = "maximum date";
|
||||
break;
|
||||
}
|
||||
let value = ft.value;
|
||||
|
||||
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
||||
else if (ft.type.startsWith("date") && value)
|
||||
value = moment(Number(value)).format("MMM D, YYYY");
|
||||
|
||||
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
||||
},
|
||||
actionHandler(event, type) {
|
||||
const id = $(event.currentTarget).closest("tr").attr("sid");
|
||||
if (type !== 'delete') return this.action(id, type);
|
||||
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
|
||||
labels: {
|
||||
ok: 'Yes, delete it',
|
||||
cancel: 'Cancel'
|
||||
}
|
||||
}).then(() => {
|
||||
this.action(id, type);
|
||||
});
|
||||
},
|
||||
action(id, type) {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
|
||||
{
|
||||
plugin: this.pid,
|
||||
subscription: id,
|
||||
}
|
||||
)}`,
|
||||
{
|
||||
method: type === 'delete' ? "DELETE" : 'POST'
|
||||
}
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
if (type === 'update')
|
||||
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
|
||||
})
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to ${type} subscription. Error: ${e}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
this.list(this.pid);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
82
public/js/subscription.js
Normal file
82
public/js/subscription.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const component = () => {
|
||||
return {
|
||||
available: undefined,
|
||||
subscriptions: [],
|
||||
|
||||
init() {
|
||||
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||
.done((data) => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
||||
|
||||
if (this.available) this.getSubscriptions();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
},
|
||||
|
||||
getSubscriptions() {
|
||||
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
||||
return;
|
||||
}
|
||||
this.subscriptions = data.subscriptions;
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
},
|
||||
|
||||
rm(event) {
|
||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
||||
}
|
||||
this.getSubscriptions();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
},
|
||||
|
||||
check(event) {
|
||||
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
},
|
||||
|
||||
formatRange(min, max) {
|
||||
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
||||
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
||||
if (isNaN(min) && isNaN(max)) return 'All';
|
||||
|
||||
if (min === max) return `= ${min}`;
|
||||
return `${min} - ${max}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -60,6 +60,11 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
UIkit.modal($('#modal')).show();
|
||||
}
|
||||
|
||||
UIkit.util.on(document, 'hidden', '#modal', () => {
|
||||
$('#read-btn').off('click');
|
||||
$('#unread-btn').off('click');
|
||||
});
|
||||
|
||||
const updateProgress = (tid, eid, page) => {
|
||||
let url = `${base_url}api/progress/${tid}/${page}`
|
||||
const query = $.param({
|
||||
@@ -90,8 +95,6 @@ const renameSubmit = (name, eid) => {
|
||||
const upload = $('.upload-field');
|
||||
const titleId = upload.attr('data-title-id');
|
||||
|
||||
console.log(name);
|
||||
|
||||
if (name.length === 0) {
|
||||
alert('danger', 'The display name should not be empty');
|
||||
return;
|
||||
@@ -122,15 +125,47 @@ const renameSubmit = (name, eid) => {
|
||||
});
|
||||
};
|
||||
|
||||
const renameSortNameSubmit = (name, eid) => {
|
||||
const upload = $('.upload-field');
|
||||
const titleId = upload.attr('data-title-id');
|
||||
|
||||
const params = {};
|
||||
if (eid) params.eid = eid;
|
||||
if (name) params.name = name;
|
||||
const query = $.param(params);
|
||||
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
|
||||
|
||||
$.ajax({
|
||||
type: 'PUT',
|
||||
url,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to update sort title. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
});
|
||||
};
|
||||
|
||||
const edit = (eid) => {
|
||||
const cover = $('#edit-modal #cover');
|
||||
let url = cover.attr('data-title-cover');
|
||||
let displayName = $('h2.uk-title > span').text();
|
||||
let fileTitle = $('h2.uk-title').attr('data-file-title');
|
||||
let sortTitle = $('h2.uk-title').attr('data-sort-title');
|
||||
|
||||
if (eid) {
|
||||
const item = $(`#${eid}`);
|
||||
url = item.find('img').attr('data-src');
|
||||
displayName = item.find('.uk-card-title').attr('data-title');
|
||||
fileTitle = item.find('.uk-card-title').attr('data-file-title');
|
||||
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
|
||||
$('#title-progress-control').attr('hidden', '');
|
||||
} else {
|
||||
$('#title-progress-control').removeAttr('hidden');
|
||||
@@ -140,14 +175,26 @@ const edit = (eid) => {
|
||||
|
||||
const displayNameField = $('#display-name-field');
|
||||
displayNameField.attr('value', displayName);
|
||||
console.log(displayNameField);
|
||||
displayNameField.attr('placeholder', fileTitle);
|
||||
displayNameField.keyup(event => {
|
||||
if (event.keyCode === 13) {
|
||||
renameSubmit(displayNameField.val(), eid);
|
||||
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||
}
|
||||
});
|
||||
displayNameField.siblings('a.uk-form-icon').click(() => {
|
||||
renameSubmit(displayNameField.val(), eid);
|
||||
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||
});
|
||||
|
||||
const sortTitleField = $('#sort-title-field');
|
||||
sortTitleField.val(sortTitle);
|
||||
sortTitleField.attr('placeholder', fileTitle);
|
||||
sortTitleField.keyup(event => {
|
||||
if (event.keyCode === 13) {
|
||||
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||
}
|
||||
});
|
||||
sortTitleField.siblings('a.uk-form-icon').click(() => {
|
||||
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||
});
|
||||
|
||||
setupUpload(eid);
|
||||
@@ -155,6 +202,16 @@ const edit = (eid) => {
|
||||
UIkit.modal($('#edit-modal')).show();
|
||||
};
|
||||
|
||||
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
|
||||
const displayNameField = $('#display-name-field');
|
||||
displayNameField.off('keyup');
|
||||
displayNameField.off('click');
|
||||
|
||||
const sortTitleField = $('#sort-title-field');
|
||||
sortTitleField.off('keyup');
|
||||
sortTitleField.off('click');
|
||||
});
|
||||
|
||||
const setupUpload = (eid) => {
|
||||
const upload = $('.upload-field');
|
||||
const bar = $('#upload-progress').get(0);
|
||||
@@ -166,7 +223,6 @@ const setupUpload = (eid) => {
|
||||
queryObj['eid'] = eid;
|
||||
const query = $.param(queryObj);
|
||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||
console.log(url);
|
||||
UIkit.upload('.upload-field', {
|
||||
url: url,
|
||||
name: 'file',
|
||||
|
||||
23
public/manifest.json
Normal file
23
public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Mango",
|
||||
"description": "Mango: A self-hosted manga server and web reader",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/img/icons/icon_x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/icon_x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/img/icons/icon_x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"display": "fullscreen",
|
||||
"start_url": "/"
|
||||
}
|
||||
40
shard.lock
40
shard.lock
@@ -2,77 +2,77 @@ version: 2.0
|
||||
shards:
|
||||
ameba:
|
||||
git: https://github.com/crystal-ameba/ameba.git
|
||||
version: 0.12.1
|
||||
version: 0.14.3
|
||||
|
||||
archive:
|
||||
git: https://github.com/hkalexling/archive.cr.git
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
|
||||
baked_file_system:
|
||||
git: https://github.com/schovi/baked_file_system.git
|
||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||
version: 0.10.0
|
||||
|
||||
clim:
|
||||
git: https://github.com/at-grandpa/clim.git
|
||||
version: 0.12.0
|
||||
version: 0.17.1
|
||||
|
||||
db:
|
||||
git: https://github.com/crystal-lang/crystal-db.git
|
||||
version: 0.9.0
|
||||
version: 0.10.1
|
||||
|
||||
duktape:
|
||||
git: https://github.com/jessedoyle/duktape.cr.git
|
||||
version: 0.20.0
|
||||
version: 1.0.0
|
||||
|
||||
exception_page:
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.1.4
|
||||
version: 0.1.5
|
||||
|
||||
http_proxy:
|
||||
git: https://github.com/mamantoha/http_proxy.git
|
||||
version: 0.7.1
|
||||
version: 0.8.0
|
||||
|
||||
image_size:
|
||||
git: https://github.com/hkalexling/image_size.cr.git
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 0.27.0
|
||||
version: 1.0.0
|
||||
|
||||
kemal-session:
|
||||
git: https://github.com/kemalcr/kemal-session.git
|
||||
version: 0.12.1
|
||||
version: 1.0.0
|
||||
|
||||
kilt:
|
||||
git: https://github.com/jeromegn/kilt.git
|
||||
version: 0.4.0
|
||||
version: 0.4.1
|
||||
|
||||
koa:
|
||||
git: https://github.com/hkalexling/koa.git
|
||||
version: 0.5.0
|
||||
version: 0.9.0
|
||||
|
||||
mg:
|
||||
git: https://github.com/hkalexling/mg.git
|
||||
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
||||
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
|
||||
|
||||
myhtml:
|
||||
git: https://github.com/kostya/myhtml.git
|
||||
version: 1.5.1
|
||||
version: 1.5.8
|
||||
|
||||
open_api:
|
||||
git: https://github.com/jreinert/open_api.cr.git
|
||||
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
|
||||
git: https://github.com/hkalexling/open_api.cr.git
|
||||
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
|
||||
|
||||
radix:
|
||||
git: https://github.com/luislavena/radix.git
|
||||
version: 0.3.9
|
||||
version: 0.4.1
|
||||
|
||||
sqlite3:
|
||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||
version: 0.16.0
|
||||
version: 0.18.0
|
||||
|
||||
tallboy:
|
||||
git: https://github.com/epoch/tallboy.git
|
||||
version: 0.9.3
|
||||
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.19.1
|
||||
version: 0.26.0
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -8,7 +8,7 @@ targets:
|
||||
mango:
|
||||
main: src/mango.cr
|
||||
|
||||
crystal: 0.35.1
|
||||
crystal: 1.0.0
|
||||
|
||||
license: MIT
|
||||
|
||||
@@ -21,7 +21,6 @@ dependencies:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
ameba:
|
||||
@@ -30,7 +29,6 @@ dependencies:
|
||||
github: at-grandpa/clim
|
||||
duktape:
|
||||
github: jessedoyle/duktape.cr
|
||||
version: ~> 0.20.0
|
||||
myhtml:
|
||||
github: kostya/myhtml
|
||||
http_proxy:
|
||||
@@ -41,5 +39,6 @@ dependencies:
|
||||
github: hkalexling/koa
|
||||
tallboy:
|
||||
github: epoch/tallboy
|
||||
branch: master
|
||||
mg:
|
||||
github: hkalexling/mg
|
||||
|
||||
@@ -8,9 +8,7 @@ describe Storage do
|
||||
end
|
||||
|
||||
it "deletes user" do
|
||||
with_storage do |storage|
|
||||
storage.delete_user "admin"
|
||||
end
|
||||
with_storage &.delete_user "admin"
|
||||
end
|
||||
|
||||
it "creates new user" do
|
||||
|
||||
@@ -21,7 +21,7 @@ describe "compare_numerically" do
|
||||
it "sorts like the stack exchange post" do
|
||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||
ary.reverse.sort { |a, b|
|
||||
ary.reverse.sort! { |a, b|
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
end
|
||||
@@ -29,18 +29,45 @@ describe "compare_numerically" do
|
||||
# https://github.com/hkalexling/Mango/issues/22
|
||||
it "handles numbers larger than Int32" do
|
||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||
ary.reverse.sort { |a, b|
|
||||
ary.reverse.sort! { |a, b|
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
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
|
||||
it "sorts correctly" do
|
||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||
sorter = ChapterSorter.new ary
|
||||
ary.reverse.sort do |a, b|
|
||||
ary.reverse.sort! do |a, b|
|
||||
sorter.compare a, b
|
||||
end.should eq ary
|
||||
end
|
||||
end
|
||||
|
||||
describe "sanitize_filename" do
|
||||
it "returns a random string for empty sanitized string" do
|
||||
sanitize_filename("..").should_not eq sanitize_filename("..")
|
||||
end
|
||||
it "sanitizes correctly" do
|
||||
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
|
||||
.should eq "マンゴー_()[1_2] 3.14 hello world"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
@@ -4,38 +4,28 @@ class Config
|
||||
include YAML::Serializable
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
property path : String = ""
|
||||
property path = ""
|
||||
property host = "0.0.0.0"
|
||||
property port : Int32 = 9000
|
||||
property base_url : String = "/"
|
||||
property session_secret : String = "mango-session-secret"
|
||||
property library_path : String = File.expand_path "~/mango/library",
|
||||
home: true
|
||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||
property base_url = "/"
|
||||
property session_secret = "mango-session-secret"
|
||||
property library_path = "~/mango/library"
|
||||
property library_cache_path = "~/mango/library.yml.gz"
|
||||
property db_path = "~/mango/mango.db"
|
||||
property queue_db_path = "~/mango/queue.db"
|
||||
property scan_interval_minutes : Int32 = 5
|
||||
property thumbnail_generation_interval_hours : Int32 = 24
|
||||
property db_optimization_interval_hours : Int32 = 24
|
||||
property log_level : String = "info"
|
||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||
home: true
|
||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||
home: true
|
||||
property log_level = "info"
|
||||
property upload_path = "~/mango/uploads"
|
||||
property plugin_path = "~/mango/plugins"
|
||||
property download_timeout_seconds : Int32 = 30
|
||||
property page_margin : Int32 = 30
|
||||
property cache_enabled = true
|
||||
property cache_size_mbs = 50
|
||||
property cache_log_enabled = true
|
||||
property disable_login = false
|
||||
property default_username = ""
|
||||
property mangadex = Hash(String, String | Int32).new
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@mangadex_defaults = {
|
||||
"base_url" => "https://mangadex.org",
|
||||
"api_url" => "https://mangadex.org/api",
|
||||
"download_wait_seconds" => 5,
|
||||
"download_retries" => 4,
|
||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||
home: true),
|
||||
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
||||
"manga_rename_rule" => "{title}",
|
||||
}
|
||||
property auth_proxy_header_name = ""
|
||||
property plugin_update_interval_hours : Int32 = 24
|
||||
|
||||
@@singlet : Config?
|
||||
|
||||
@@ -52,16 +42,16 @@ class Config
|
||||
cfg_path = File.expand_path path, home: true
|
||||
if File.exists? cfg_path
|
||||
config = self.from_yaml File.read cfg_path
|
||||
config.preprocess
|
||||
config.path = path
|
||||
config.fill_defaults
|
||||
config.expand_paths
|
||||
config.preprocess
|
||||
return config
|
||||
end
|
||||
puts "The config file #{cfg_path} does not exist. " \
|
||||
"Dumping the default config there."
|
||||
default = self.allocate
|
||||
default.path = path
|
||||
default.fill_defaults
|
||||
default.expand_paths
|
||||
cfg_dir = File.dirname cfg_path
|
||||
unless Dir.exists? cfg_dir
|
||||
Dir.mkdir_p cfg_dir
|
||||
@@ -71,13 +61,9 @@ class Config
|
||||
default
|
||||
end
|
||||
|
||||
def fill_defaults
|
||||
{% for hash_name in ["mangadex"] %}
|
||||
@{{hash_name.id}}_defaults.map do |k, v|
|
||||
if @{{hash_name.id}}[k]?.nil?
|
||||
@{{hash_name.id}}[k] = v
|
||||
end
|
||||
end
|
||||
def expand_paths
|
||||
{% for p in %w(library library_cache db queue_db upload plugin) %}
|
||||
@{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true
|
||||
{% end %}
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler
|
||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||
|
||||
BASIC = "Basic"
|
||||
BEARER = "Bearer"
|
||||
AUTH = "Authorization"
|
||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||
"You have to login with proper credentials"
|
||||
@@ -15,7 +16,17 @@ class AuthHandler < Kemal::Handler
|
||||
env.response.status_code = 401
|
||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||
env.response.print AUTH_MESSAGE
|
||||
call_next env
|
||||
end
|
||||
|
||||
def require_auth(env)
|
||||
if request_path_startswith env, ["/api"]
|
||||
# Do not redirect API requests
|
||||
env.response.status_code = 401
|
||||
send_text env, "Unauthorized"
|
||||
else
|
||||
env.session.string "callback", env.request.path
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_token(env)
|
||||
@@ -31,13 +42,18 @@ class AuthHandler < Kemal::Handler
|
||||
def validate_auth_header(env)
|
||||
if env.request.headers[AUTH]?
|
||||
if value = env.request.headers[AUTH]
|
||||
if value.size > 0 && value.starts_with?(BASIC)
|
||||
if value.starts_with? BASIC
|
||||
token = verify_user value
|
||||
return false if token.nil?
|
||||
|
||||
env.session.string "token", token
|
||||
return true
|
||||
end
|
||||
if value.starts_with? BEARER
|
||||
session_id = value.split(" ")[1]
|
||||
token = Kemal::Session.get(session_id).try &.string? "token"
|
||||
return !token.nil? && Storage.default.verify_token token
|
||||
end
|
||||
end
|
||||
end
|
||||
false
|
||||
@@ -49,55 +65,55 @@ class AuthHandler < Kemal::Handler
|
||||
Storage.default.verify_user username, password
|
||||
end
|
||||
|
||||
def handle_opds_auth(env)
|
||||
if validate_token(env) || validate_auth_header(env)
|
||||
call_next env
|
||||
else
|
||||
env.response.status_code = 401
|
||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||
env.response.print AUTH_MESSAGE
|
||||
def call(env)
|
||||
# OPTIONS requests do not require authentication
|
||||
if env.request.method === "OPTIONS"
|
||||
return call_next(env)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_auth(env)
|
||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
||||
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||
# or a static file
|
||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||
requesting_static_file env
|
||||
return call_next(env)
|
||||
end
|
||||
|
||||
unless validate_token(env) || Config.current.disable_login
|
||||
env.session.string "callback", env.request.path
|
||||
return redirect env, "/login"
|
||||
# Check user is logged in
|
||||
if validate_token(env) || validate_auth_header(env)
|
||||
# Skip if the request has a valid token (either from cookies or header)
|
||||
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
|
||||
|
||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||
# The token (if exists) takes precedence over the default user option.
|
||||
# this is why we check the default username first before checking the
|
||||
# token.
|
||||
should_reject = true
|
||||
if Config.current.disable_login &&
|
||||
Storage.default.username_is_admin Config.current.default_username
|
||||
should_reject = false
|
||||
end
|
||||
if env.session.string? "token"
|
||||
should_reject = !validate_token_admin(env)
|
||||
end
|
||||
if should_reject
|
||||
# Check admin access when requesting an admin page
|
||||
if request_path_startswith env, %w(/admin /api/admin /download)
|
||||
unless is_admin? env
|
||||
env.response.status_code = 403
|
||||
send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
return
|
||||
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
end
|
||||
end
|
||||
|
||||
# Let the request go through if it passes the above checks
|
||||
call_next env
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if request_path_startswith env, ["/opds"]
|
||||
handle_opds_auth env
|
||||
else
|
||||
handle_auth env
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
8
src/handlers/cors_handler.cr
Normal file
8
src/handlers/cors_handler.cr
Normal file
@@ -0,0 +1,8 @@
|
||||
class CORSHandler < Kemal::Handler
|
||||
def call(env)
|
||||
if request_path_startswith env, ["/api"]
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
end
|
||||
call_next env
|
||||
end
|
||||
end
|
||||
218
src/library/cache.cr
Normal file
218
src/library/cache.cr
Normal file
@@ -0,0 +1,218 @@
|
||||
require "digest"
|
||||
|
||||
require "./entry"
|
||||
require "./title"
|
||||
require "./types"
|
||||
|
||||
# Base class for an entry in the LRU cache.
|
||||
# There are two ways to use it:
|
||||
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
|
||||
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
|
||||
# same type. That is, the input value will be stored as it is without
|
||||
# any transformation.
|
||||
# 2. You can also subclass it and provide custom implementations for
|
||||
# `to_save_t` and `to_return_t`. This allows you to transform and store
|
||||
# the input value to a different type. See `SortedEntriesCacheEntry` as
|
||||
# an example.
|
||||
private class CacheEntry(SaveT, ReturnT)
|
||||
getter key : String, atime : Time
|
||||
|
||||
@value : SaveT
|
||||
|
||||
def initialize(@key : String, value : ReturnT)
|
||||
@atime = @ctime = Time.utc
|
||||
@value = self.class.to_save_t value
|
||||
end
|
||||
|
||||
def value
|
||||
@atime = Time.utc
|
||||
self.class.to_return_t @value
|
||||
end
|
||||
|
||||
def self.to_save_t(value : ReturnT)
|
||||
value
|
||||
end
|
||||
|
||||
def self.to_return_t(value : SaveT)
|
||||
value
|
||||
end
|
||||
|
||||
def instance_size
|
||||
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
|
||||
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||
@value.instance_size
|
||||
end
|
||||
end
|
||||
|
||||
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
||||
def self.to_save_t(value : Array(Entry))
|
||||
value.map &.id
|
||||
end
|
||||
|
||||
def self.to_return_t(value : Array(String))
|
||||
ids_to_entries value
|
||||
end
|
||||
|
||||
private def self.ids_to_entries(ids : Array(String))
|
||||
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
|
||||
entries = [] of Entry
|
||||
begin
|
||||
ids.each do |id|
|
||||
entries << e_map[id]
|
||||
end
|
||||
return entries if ids.size == entries.size
|
||||
rescue
|
||||
end
|
||||
end
|
||||
|
||||
def instance_size
|
||||
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
|
||||
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||
@value.sum(&.bytesize) # elements in Array(String)
|
||||
end
|
||||
|
||||
def self.gen_key(book_id : String, username : String,
|
||||
entries : Array(Entry), opt : SortOptions?)
|
||||
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
"#{sig}:sorted_entries"
|
||||
end
|
||||
end
|
||||
|
||||
class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
||||
def self.to_save_t(value : Array(Title))
|
||||
value.map &.id
|
||||
end
|
||||
|
||||
def self.to_return_t(value : Array(String))
|
||||
value.map { |title_id| Library.default.title_hash[title_id].not_nil! }
|
||||
end
|
||||
|
||||
def instance_size
|
||||
instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself
|
||||
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||
@value.sum(&.bytesize) # elements in Array(String)
|
||||
end
|
||||
|
||||
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
|
||||
(opt ? opt.to_tuple.to_s : "nil"))
|
||||
"#{sig}:sorted_titles"
|
||||
end
|
||||
end
|
||||
|
||||
class String
|
||||
def instance_size
|
||||
instance_sizeof(String) + bytesize
|
||||
end
|
||||
end
|
||||
|
||||
struct Tuple(*T)
|
||||
def instance_size
|
||||
sizeof(T) + # total size of non-reference types
|
||||
self.sum do |e|
|
||||
next 0 unless e.is_a? Reference
|
||||
if e.responds_to? :instance_size
|
||||
e.instance_size
|
||||
else
|
||||
instance_sizeof(typeof(e))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
alias CacheableType = Array(Entry) | Array(Title) | String |
|
||||
Tuple(String, Int32)
|
||||
alias CacheEntryType = SortedEntriesCacheEntry |
|
||||
SortedTitlesCacheEntry |
|
||||
CacheEntry(String, String) |
|
||||
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
||||
|
||||
def generate_cache_entry(key : String, value : CacheableType)
|
||||
if value.is_a? Array(Entry)
|
||||
SortedEntriesCacheEntry.new key, value
|
||||
elsif value.is_a? Array(Title)
|
||||
SortedTitlesCacheEntry.new key, value
|
||||
else
|
||||
CacheEntry(typeof(value), typeof(value)).new key, value
|
||||
end
|
||||
end
|
||||
|
||||
# LRU Cache
|
||||
class LRUCache
|
||||
@@limit : Int128 = Int128.new 0
|
||||
@@should_log = true
|
||||
# key => entry
|
||||
@@cache = {} of String => CacheEntryType
|
||||
|
||||
def self.enabled
|
||||
Config.current.cache_enabled
|
||||
end
|
||||
|
||||
def self.init
|
||||
cache_size = Config.current.cache_size_mbs
|
||||
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
|
||||
@@should_log = Config.current.cache_log_enabled
|
||||
end
|
||||
|
||||
def self.get(key : String)
|
||||
return unless enabled
|
||||
entry = @@cache[key]?
|
||||
if @@should_log
|
||||
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
|
||||
end
|
||||
return entry.value unless entry.nil?
|
||||
end
|
||||
|
||||
def self.set(cache_entry : CacheEntryType)
|
||||
return unless enabled
|
||||
key = cache_entry.key
|
||||
@@cache[key] = cache_entry
|
||||
Logger.debug "LRUCache cached #{key}" if @@should_log
|
||||
remove_least_recent_access
|
||||
end
|
||||
|
||||
def self.invalidate(key : String)
|
||||
return unless enabled
|
||||
@@cache.delete key
|
||||
end
|
||||
|
||||
def self.print
|
||||
return unless @@should_log
|
||||
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||
Logger.debug "---- LRU Cache ----"
|
||||
Logger.debug "Size: #{sum} Bytes"
|
||||
Logger.debug "List:"
|
||||
@@cache.each do |k, v|
|
||||
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
|
||||
end
|
||||
Logger.debug "-------------------"
|
||||
end
|
||||
|
||||
private def self.is_cache_full
|
||||
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||
sum > @@limit
|
||||
end
|
||||
|
||||
private def self.remove_least_recent_access
|
||||
if @@should_log && is_cache_full
|
||||
Logger.debug "Removing entries from LRUCache"
|
||||
end
|
||||
while is_cache_full && @@cache.size > 0
|
||||
min_tuple = @@cache.min_by { |_, entry| entry.atime }
|
||||
min_key = min_tuple[0]
|
||||
min_entry = min_tuple[1]
|
||||
|
||||
Logger.debug " \
|
||||
Target: #{min_key}, \
|
||||
Last Access Time: #{min_entry.atime}" if @@should_log
|
||||
invalidate min_key
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,23 +1,29 @@
|
||||
require "image_size"
|
||||
require "yaml"
|
||||
|
||||
class Entry
|
||||
include YAML::Serializable
|
||||
|
||||
getter zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, encoded_path : String,
|
||||
encoded_title : String, mtime : Time, err_msg : String?
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
|
||||
def initialize(@zip_path, @book)
|
||||
storage = Storage.default
|
||||
@encoded_path = URI.encode @zip_path
|
||||
@title = File.basename @zip_path, File.extname @zip_path
|
||||
@encoded_title = URI.encode @title
|
||||
@size = (File.size @zip_path).humanize_bytes
|
||||
id = storage.get_id @zip_path, false
|
||||
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
is_title: false,
|
||||
storage.insert_entry_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
signature: File.signature(@zip_path).to_s,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@@ -46,19 +52,57 @@ class Entry
|
||||
file.close
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
def build_json(*, slim = false)
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in %w(zip_path title size id) %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "title_id", @book.id
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "pages" { json.number @pages }
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
if err_msg
|
||||
json.field "err_msg", err_msg
|
||||
end
|
||||
json.field "title_id", @book.id
|
||||
json.field "title_title", @book.title
|
||||
json.field "sort_title", sort_title
|
||||
json.field "pages" { json.number @pages }
|
||||
unless slim
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sort_title
|
||||
sort_title_cached = @sort_title
|
||||
return sort_title_cached if sort_title_cached
|
||||
sort_title = @book.entry_sort_title_db id
|
||||
if sort_title
|
||||
@sort_title = sort_title
|
||||
return sort_title
|
||||
end
|
||||
@sort_title = @title
|
||||
@title
|
||||
end
|
||||
|
||||
def set_sort_title(sort_title : String | Nil, username : String)
|
||||
Storage.default.set_entry_sort_title id, sort_title
|
||||
if sort_title == "" || sort_title.nil?
|
||||
@sort_title = nil
|
||||
else
|
||||
@sort_title = sort_title
|
||||
end
|
||||
|
||||
@book.entry_sort_title_cache = nil
|
||||
@book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
|
||||
username
|
||||
end
|
||||
|
||||
def sort_title_db
|
||||
@book.entry_sort_title_db @id
|
||||
end
|
||||
|
||||
def display_name
|
||||
@book.display_name @title
|
||||
end
|
||||
@@ -68,10 +112,18 @@ class Entry
|
||||
end
|
||||
|
||||
def cover_url
|
||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg
|
||||
|
||||
unless @book.entry_cover_url_cache
|
||||
TitleInfo.new @book.dir do |info|
|
||||
@book.entry_cover_url_cache = info.entry_cover_url
|
||||
end
|
||||
end
|
||||
entry_cover_url = @book.entry_cover_url_cache
|
||||
|
||||
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
if entry_cover_url
|
||||
info_url = entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
@@ -86,7 +138,7 @@ class Entry
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
.sort! { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
yield file, entries
|
||||
@@ -96,13 +148,17 @@ class Entry
|
||||
def read_page(page_num)
|
||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||
img = nil
|
||||
sorted_archive_entries do |file, entries|
|
||||
page = entries[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
begin
|
||||
sorted_archive_entries do |file, entries|
|
||||
page = entries[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename),
|
||||
page.filename, data.size
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
|
||||
end
|
||||
img
|
||||
end
|
||||
@@ -134,10 +190,11 @@ class Entry
|
||||
entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
def previous_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
@@ -157,6 +214,12 @@ class Entry
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
|
||||
@book.parents.each do |parent|
|
||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||
end
|
||||
@book.remove_sorted_caches [SortMethod::Progress], username
|
||||
|
||||
TitleInfo.new @book.dir do |info|
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {@title => page}
|
||||
|
||||
@@ -1,20 +1,94 @@
|
||||
class Library
|
||||
struct ThumbnailContext
|
||||
property current : Int32, total : Int32
|
||||
|
||||
def initialize
|
||||
@current = 0
|
||||
@total = 0
|
||||
end
|
||||
|
||||
def progress
|
||||
if total == 0
|
||||
0
|
||||
else
|
||||
current / total
|
||||
end
|
||||
end
|
||||
|
||||
def reset
|
||||
@current = 0
|
||||
@total = 0
|
||||
end
|
||||
|
||||
def increment
|
||||
@current += 1
|
||||
end
|
||||
end
|
||||
|
||||
include YAML::Serializable
|
||||
|
||||
getter dir : String, title_ids : Array(String),
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
getter thumbnail_ctx = ThumbnailContext.new
|
||||
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
register_mime_types
|
||||
def save_instance
|
||||
path = Config.current.library_cache_path
|
||||
Logger.debug "Caching library to #{path}"
|
||||
|
||||
writer = Compress::Gzip::Writer.new path,
|
||||
Compress::Gzip::BEST_COMPRESSION
|
||||
writer.write self.to_yaml.to_slice
|
||||
writer.close
|
||||
end
|
||||
|
||||
def self.load_instance
|
||||
path = Config.current.library_cache_path
|
||||
return unless File.exists? path
|
||||
|
||||
Logger.debug "Loading cached library from #{path}"
|
||||
|
||||
begin
|
||||
Compress::Gzip::Reader.open path do |content|
|
||||
loaded = Library.from_yaml content
|
||||
# We will have to do a full restart in these cases. Otherwise having
|
||||
# two instances of the library will cause some weirdness.
|
||||
if loaded.dir != Config.current.library_path
|
||||
Logger.fatal "Cached library dir #{loaded.dir} does not match " \
|
||||
"current library dir #{Config.current.library_path}. " \
|
||||
"Deleting cache"
|
||||
delete_cache_and_exit path
|
||||
end
|
||||
if loaded.title_ids.size > 0 &&
|
||||
Storage.default.count_titles == 0
|
||||
Logger.fatal "The library cache is inconsistent with the DB. " \
|
||||
"Deleting cache"
|
||||
delete_cache_and_exit path
|
||||
end
|
||||
@@default = loaded
|
||||
Logger.debug "Library cache loaded"
|
||||
end
|
||||
Library.default.register_jobs
|
||||
rescue e
|
||||
Logger.error e
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@dir = Config.current.library_path
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
@entries_count = 0
|
||||
@thumbnails_count = 0
|
||||
register_jobs
|
||||
end
|
||||
|
||||
protected def register_jobs
|
||||
register_mime_types
|
||||
|
||||
scan_interval = Config.current.scan_interval_minutes
|
||||
if scan_interval < 1
|
||||
@@ -25,7 +99,7 @@ class Library
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
Logger.debug "Library initialized in #{ms}ms"
|
||||
sleep scan_interval.minutes
|
||||
end
|
||||
end
|
||||
@@ -42,16 +116,6 @@ class Library
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
db_interval = Config.current.db_optimization_interval_hours
|
||||
unless db_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
Storage.default.optimize
|
||||
sleep db_interval.hours
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def titles
|
||||
@@ -61,11 +125,6 @@ class Library
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function from src/util/util.cr
|
||||
@@ -73,14 +132,41 @@ class Library
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
titles + titles.flat_map &.deep_titles
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
def deep_entries
|
||||
titles.flat_map &.deep_entries
|
||||
end
|
||||
|
||||
def build_json(*, slim = false, depth = -1, sort_context = nil,
|
||||
percentage = false)
|
||||
_titles = if sort_context
|
||||
sorted_titles sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
self.titles
|
||||
end
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
_titles.each do |title|
|
||||
json.raw title.build_json(slim: slim, depth: depth,
|
||||
sort_context: sort_context, percentage: percentage)
|
||||
end
|
||||
end
|
||||
end
|
||||
if percentage && sort_context
|
||||
json.field "title_percentages" do
|
||||
json.array do
|
||||
_titles.each do |title|
|
||||
json.number title.load_percentage sort_context[:username]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -94,6 +180,7 @@ class Library
|
||||
end
|
||||
|
||||
def scan
|
||||
start = Time.local
|
||||
unless Dir.exists? @dir
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
@@ -102,14 +189,38 @@ class Library
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
(Dir.entries @dir)
|
||||
examine_context : ExamineContext = {
|
||||
cached_contents_signature: {} of String => String,
|
||||
deleted_title_ids: [] of String,
|
||||
deleted_entry_ids: [] of String,
|
||||
}
|
||||
|
||||
library_paths = (Dir.entries @dir)
|
||||
.select { |fn| !fn.starts_with? "." }
|
||||
.map { |fn| File.join @dir, fn }
|
||||
@title_ids.select! do |title_id|
|
||||
title = @title_hash[title_id]
|
||||
next false unless library_paths.includes? title.dir
|
||||
existence = title.examine examine_context
|
||||
unless existence
|
||||
examine_context["deleted_title_ids"].concat [title_id] +
|
||||
title.deep_titles.map &.id
|
||||
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||
end
|
||||
existence
|
||||
end
|
||||
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
|
||||
examine_context["deleted_title_ids"].each do |title_id|
|
||||
@title_hash.delete title_id
|
||||
end
|
||||
|
||||
cache = examine_context["cached_contents_signature"]
|
||||
library_paths
|
||||
.select { |path| !(remained_title_dirs.includes? path) }
|
||||
.select { |path| File.directory? path }
|
||||
.map { |path| Title.new path, "" }
|
||||
.map { |path| Title.new path, "", cache }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.tap { |_| @title_ids.clear }
|
||||
.sort! { |a, b| a.sort_title <=> b.sort_title }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
@@ -118,19 +229,27 @@ class Library
|
||||
storage.bulk_insert_ids
|
||||
storage.close
|
||||
|
||||
Logger.debug "Scan completed"
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
|
||||
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
|
||||
examine_context["deleted_title_ids"]
|
||||
|
||||
spawn do
|
||||
save_instance
|
||||
end
|
||||
end
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
.map(&.get_last_read_entry username)
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||
.map { |e|
|
||||
# Get the last read time of the entry. If it hasn't been started, get
|
||||
# the last read time of the previous entry
|
||||
last_read = e.load_last_read username
|
||||
pe = e.previous_entry
|
||||
pe = e.previous_entry username
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
@@ -159,14 +278,14 @@ class Library
|
||||
recently_added = [] of RA
|
||||
last_date_added = nil
|
||||
|
||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
.select { |e| e[:date_added] > 1.month.ago }
|
||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
titles.flat_map(&.deep_entries_with_date_added)
|
||||
.select(&.[:date_added].> 1.month.ago)
|
||||
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
.each do |e|
|
||||
break if recently_added.size > 12
|
||||
last = recently_added.last?
|
||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
|
||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||
last_hash = last.to_h
|
||||
count = last_hash[:grouped_count].as(Int32)
|
||||
@@ -197,39 +316,34 @@ class Library
|
||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||
# when the user hasn't started `Vol. 1` yet
|
||||
titles
|
||||
.select { |t| t.load_percentage(username) == 0 }
|
||||
.select(&.load_percentage(username).== 0)
|
||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||
.shuffle
|
||||
end
|
||||
|
||||
def thumbnail_generation_progress
|
||||
return 0 if @entries_count == 0
|
||||
@thumbnails_count / @entries_count
|
||||
.shuffle!
|
||||
end
|
||||
|
||||
def generate_thumbnails
|
||||
if @thumbnails_count > 0
|
||||
if thumbnail_ctx.current > 0
|
||||
Logger.debug "Thumbnail generation in progress"
|
||||
return
|
||||
end
|
||||
|
||||
Logger.info "Starting thumbnail generation"
|
||||
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
||||
@entries_count = entries.size
|
||||
@thumbnails_count = 0
|
||||
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||
thumbnail_ctx.total = entries.size
|
||||
thumbnail_ctx.current = 0
|
||||
|
||||
# Report generation progress regularly
|
||||
spawn do
|
||||
loop do
|
||||
unless @thumbnails_count == 0
|
||||
unless thumbnail_ctx.current == 0
|
||||
Logger.debug "Thumbnail generation progress: " \
|
||||
"#{(thumbnail_generation_progress * 100).round 1}%"
|
||||
"#{(thumbnail_ctx.progress * 100).round 1}%"
|
||||
end
|
||||
# Generation is completed. We reset the count to 0 to allow subsequent
|
||||
# calls to the function, and break from the loop to stop the progress
|
||||
# report fiber
|
||||
if thumbnail_generation_progress.to_i == 1
|
||||
@thumbnails_count = 0
|
||||
if thumbnail_ctx.progress.to_i == 1
|
||||
thumbnail_ctx.reset
|
||||
break
|
||||
end
|
||||
sleep 10.seconds
|
||||
@@ -241,9 +355,9 @@ class Library
|
||||
e.generate_thumbnail
|
||||
# Sleep after each generation to minimize the impact on disk IO
|
||||
# and CPU
|
||||
sleep 0.5.seconds
|
||||
sleep 1.seconds
|
||||
end
|
||||
@thumbnails_count += 1
|
||||
thumbnail_ctx.increment
|
||||
end
|
||||
Logger.info "Thumbnail generation finished"
|
||||
end
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
require "digest"
|
||||
require "../archive"
|
||||
|
||||
class Title
|
||||
include YAML::Serializable
|
||||
|
||||
getter dir : String, parent_id : String, title_ids : Array(String),
|
||||
entries : Array(Entry), title : String, id : String,
|
||||
encoded_title : String, mtime : Time
|
||||
encoded_title : String, mtime : Time, signature : UInt64,
|
||||
entry_cover_url_cache : Hash(String, String)?
|
||||
setter entry_cover_url_cache : Hash(String, String)?,
|
||||
entry_sort_title_cache : Hash(String, String | Nil)?
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
@[YAML::Field(ignore: true)]
|
||||
@entry_sort_title_cache : Hash(String, String | Nil)?
|
||||
@[YAML::Field(ignore: true)]
|
||||
@entry_display_name_cache : Hash(String, String)?
|
||||
@[YAML::Field(ignore: true)]
|
||||
@entry_cover_url_cache : Hash(String, String)?
|
||||
@[YAML::Field(ignore: true)]
|
||||
@cached_display_name : String?
|
||||
@[YAML::Field(ignore: true)]
|
||||
@cached_cover_url : String?
|
||||
|
||||
def initialize(@dir : String, @parent_id)
|
||||
def initialize(@dir : String, @parent_id, cache = {} of String => String)
|
||||
storage = Storage.default
|
||||
id = storage.get_id @dir, true
|
||||
@signature = Dir.signature dir
|
||||
id = storage.get_title_id dir, signature
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @dir,
|
||||
id: id,
|
||||
is_title: true,
|
||||
storage.insert_title_id({
|
||||
path: dir,
|
||||
id: id,
|
||||
signature: signature.to_s,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@contents_signature = Dir.contents_signature dir, cache
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@title_ids = [] of String
|
||||
@@ -29,13 +48,13 @@ class Title
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id
|
||||
title = Title.new path, @id, cache
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
next
|
||||
end
|
||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||
if is_supported_file path
|
||||
entry = Entry.new path, self
|
||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||
end
|
||||
@@ -43,39 +62,217 @@ class Title
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
mtimes += @entries.map &.mtime
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically Library.default.title_hash[a].title,
|
||||
Library.default.title_hash[b].title
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
sorter = ChapterSorter.new @entries.map &.title
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
sorter.compare a.sort_title, b.sort_title
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
# Utility method used in library rescanning.
|
||||
# - When the title does not exist on the file system anymore, return false
|
||||
# and let it be deleted from the library instance
|
||||
# - When the title exists, but its contents signature is now different from
|
||||
# the cache, it means some of its content (nested titles or entries)
|
||||
# has been added, deleted, or renamed. In this case we update its
|
||||
# contents signature and instance variables
|
||||
# - When the title exists and its contents signature is still the same, we
|
||||
# return true so it can be reused without rescanning
|
||||
def examine(context : ExamineContext) : Bool
|
||||
return false unless Dir.exists? @dir
|
||||
contents_signature = Dir.contents_signature @dir,
|
||||
context["cached_contents_signature"]
|
||||
return true if @contents_signature == contents_signature
|
||||
|
||||
@contents_signature = contents_signature
|
||||
@signature = Dir.signature @dir
|
||||
storage = Storage.default
|
||||
id = storage.get_title_id dir, signature
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_title_id({
|
||||
path: dir,
|
||||
id: id,
|
||||
signature: signature.to_s,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@mtime = File.info(@dir).modification_time
|
||||
|
||||
previous_titles_size = @title_ids.size
|
||||
@title_ids.select! do |title_id|
|
||||
title = Library.default.get_title title_id
|
||||
unless title # for if data consistency broken
|
||||
context["deleted_title_ids"].concat [title_id]
|
||||
next false
|
||||
end
|
||||
existence = title.examine context
|
||||
unless existence
|
||||
context["deleted_title_ids"].concat [title_id] +
|
||||
title.deep_titles.map &.id
|
||||
context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||
end
|
||||
existence
|
||||
end
|
||||
remained_title_dirs = @title_ids.map do |title_id|
|
||||
title = Library.default.get_title! title_id
|
||||
title.dir
|
||||
end
|
||||
|
||||
previous_entries_size = @entries.size
|
||||
@entries.select! do |entry|
|
||||
existence = File.exists? entry.zip_path
|
||||
Fiber.yield
|
||||
context["deleted_entry_ids"] << entry.id unless existence
|
||||
existence
|
||||
end
|
||||
remained_entry_zip_paths = @entries.map &.zip_path
|
||||
|
||||
is_titles_added = false
|
||||
is_entries_added = false
|
||||
Dir.entries(dir).each do |fn|
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
next if remained_title_dirs.includes? path
|
||||
title = Title.new path, @id, context["cached_contents_signature"]
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
is_titles_added = true
|
||||
|
||||
# We think they are removed, but they are here!
|
||||
# Cancel reserved jobs
|
||||
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||
!(revival_title_ids.includes? deleted_title_id)
|
||||
end
|
||||
revival_entry_ids = title.deep_entries.map &.id
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
!(revival_entry_ids.includes? deleted_entry_id)
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
if is_supported_file path
|
||||
next if remained_entry_zip_paths.includes? path
|
||||
entry = Entry.new path, self
|
||||
if entry.pages > 0 || entry.err_msg
|
||||
@entries << entry
|
||||
is_entries_added = true
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
entry.id != deleted_entry_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||
mtimes += @entries.map &.mtime
|
||||
@mtime = mtimes.max
|
||||
|
||||
if is_titles_added || previous_titles_size != @title_ids.size
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically Library.default.title_hash[a].title,
|
||||
Library.default.title_hash[b].title
|
||||
end
|
||||
end
|
||||
if is_entries_added || previous_entries_size != @entries.size
|
||||
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.sort_title, b.sort_title
|
||||
end
|
||||
end
|
||||
|
||||
if @title_ids.size > 0 || @entries.size > 0
|
||||
true
|
||||
else
|
||||
context["deleted_title_ids"].concat [@id]
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||
|
||||
def build_json(*, slim = false, depth = -1,
|
||||
sort_context : SortContext? = nil,
|
||||
percentage = false)
|
||||
_titles = if sort_context
|
||||
sorted_titles sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
self.titles
|
||||
end
|
||||
_entries = if sort_context
|
||||
sorted_entries sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
@entries
|
||||
end
|
||||
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
end
|
||||
json.field "entries" do
|
||||
json.raw @entries.to_json
|
||||
end
|
||||
json.field "parents" do
|
||||
json.array do
|
||||
self.parents.each do |title|
|
||||
json.object do
|
||||
json.field "title", title.title
|
||||
json.field "id", title.id
|
||||
json.field "signature" { json.number @signature }
|
||||
json.field "sort_title", sort_title
|
||||
unless slim
|
||||
json.field "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
unless depth == 0
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
_titles.each do |title|
|
||||
json.raw title.build_json(slim: slim,
|
||||
depth: depth > 0 ? depth - 1 : depth,
|
||||
sort_context: sort_context, percentage: percentage)
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "entries" do
|
||||
json.array do
|
||||
_entries.each do |entry|
|
||||
json.raw entry.build_json(slim: slim)
|
||||
end
|
||||
end
|
||||
end
|
||||
if percentage && sort_context
|
||||
json.field "title_percentages" do
|
||||
json.array do
|
||||
_titles.each do |t|
|
||||
json.number t.load_percentage sort_context[:username]
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "entry_percentages" do
|
||||
json.array do
|
||||
load_percentage_for_all_entries(
|
||||
sort_context[:username],
|
||||
sort_context[:opt]
|
||||
).each do |p|
|
||||
json.number p.nan? ? 0 : p
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
json.field "parents" do
|
||||
json.array do
|
||||
self.parents.each do |title|
|
||||
json.object do
|
||||
json.field "title", title.title
|
||||
json.field "id", title.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -87,15 +284,24 @@ class Title
|
||||
@title_ids.map { |tid| Library.default.get_title! tid }
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
end
|
||||
|
||||
# Helper function from src/util/util.cr
|
||||
sort_titles titles, opt.not_nil!, username
|
||||
end
|
||||
|
||||
# Get all entries, including entries in nested titles
|
||||
def deep_entries
|
||||
return @entries if title_ids.empty?
|
||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||
@entries + titles.flat_map &.deep_entries
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
titles + titles.flat_map &.deep_titles
|
||||
end
|
||||
|
||||
def parents
|
||||
@@ -123,6 +329,48 @@ class Title
|
||||
ary.join " and "
|
||||
end
|
||||
|
||||
def sort_title
|
||||
sort_title_cached = @sort_title
|
||||
return sort_title_cached if sort_title_cached
|
||||
sort_title = Storage.default.get_title_sort_title id
|
||||
if sort_title
|
||||
@sort_title = sort_title
|
||||
return sort_title
|
||||
end
|
||||
@sort_title = @title
|
||||
@title
|
||||
end
|
||||
|
||||
def set_sort_title(sort_title : String | Nil, username : String)
|
||||
Storage.default.set_title_sort_title id, sort_title
|
||||
if sort_title == "" || sort_title.nil?
|
||||
@sort_title = nil
|
||||
else
|
||||
@sort_title = sort_title
|
||||
end
|
||||
|
||||
if parents.size > 0
|
||||
target = parents[-1].titles
|
||||
else
|
||||
target = Library.default.titles
|
||||
end
|
||||
remove_sorted_titles_cache target,
|
||||
[SortMethod::Auto, SortMethod::Title], username
|
||||
end
|
||||
|
||||
def sort_title_db
|
||||
Storage.default.get_title_sort_title id
|
||||
end
|
||||
|
||||
def entry_sort_title_db(entry_id)
|
||||
unless @entry_sort_title_cache
|
||||
@entry_sort_title_cache =
|
||||
Storage.default.get_entries_sort_title @entries.map &.id
|
||||
end
|
||||
|
||||
@entry_sort_title_cache.not_nil![entry_id]?
|
||||
end
|
||||
|
||||
def tags
|
||||
Storage.default.get_title_tags @id
|
||||
end
|
||||
@@ -136,15 +384,19 @@ class Title
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
@entries.find &.id.== eid
|
||||
end
|
||||
|
||||
def display_name
|
||||
cached_display_name = @cached_display_name
|
||||
return cached_display_name unless cached_display_name.nil?
|
||||
|
||||
dn = @title
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.display_name
|
||||
dn = info_dn unless info_dn.empty?
|
||||
end
|
||||
@cached_display_name = dn
|
||||
dn
|
||||
end
|
||||
|
||||
@@ -168,6 +420,7 @@ class Title
|
||||
end
|
||||
|
||||
def set_display_name(dn)
|
||||
@cached_display_name = dn
|
||||
TitleInfo.new @dir do |info|
|
||||
info.display_name = dn
|
||||
info.save
|
||||
@@ -177,12 +430,16 @@ class Title
|
||||
def set_display_name(entry_name : String, dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_display_name[entry_name] = dn
|
||||
@entry_display_name_cache = info.entry_display_name
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
cached_cover_url = @cached_cover_url
|
||||
return cached_cover_url unless cached_cover_url.nil?
|
||||
|
||||
url = "#{Config.current.base_url}img/icons/icon_x192.png"
|
||||
readable_entries = @entries.select &.err_msg.nil?
|
||||
if readable_entries.size > 0
|
||||
url = readable_entries[0].cover_url
|
||||
@@ -193,10 +450,12 @@ class Title
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
@cached_cover_url = url
|
||||
url
|
||||
end
|
||||
|
||||
def set_cover_url(url : String)
|
||||
@cached_cover_url = url
|
||||
TitleInfo.new @dir do |info|
|
||||
info.cover_url = url
|
||||
info.save
|
||||
@@ -206,6 +465,7 @@ class Title
|
||||
def set_cover_url(entry_name : String, url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_cover_url[entry_name] = url
|
||||
@entry_cover_url_cache = info.entry_cover_url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
@@ -215,29 +475,30 @@ class Title
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
titles.each &.read_all username
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 0%
|
||||
def unread_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, 0
|
||||
end
|
||||
titles.each do |t|
|
||||
t.unread_all username
|
||||
end
|
||||
@entries.each &.save_progress(username, 0)
|
||||
titles.each &.unread_all username
|
||||
end
|
||||
|
||||
def deep_read_page_count(username) : Int32
|
||||
load_progress_for_all_entries(username).sum +
|
||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||
key = "#{@id}:#{username}:progress_sum"
|
||||
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||
cached_sum = LRUCache.get key
|
||||
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
|
||||
cached_sum[0] == sig
|
||||
sum = load_progress_for_all_entries(username, nil, true).sum +
|
||||
titles.flat_map(&.deep_read_page_count username).sum
|
||||
LRUCache.set generate_cache_entry key, {sig, sum}
|
||||
sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
entries.sum(&.pages) +
|
||||
titles.flat_map(&.deep_total_page_count).sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
@@ -286,44 +547,46 @@ class Title
|
||||
# use the default (auto, ascending)
|
||||
# When `opt` is not nil, it saves the options to info.json
|
||||
def sorted_entries(username, opt : SortOptions? = nil)
|
||||
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
|
||||
cached_entries = LRUCache.get cache_key
|
||||
return cached_entries if cached_entries.is_a? Array(Entry)
|
||||
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .title?
|
||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
||||
ary = @entries.sort do |a, b|
|
||||
compare_numerically a.sort_title, b.sort_title
|
||||
end
|
||||
when .time_modified?
|
||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
compare_numerically a.sort_title, b.sort_title }
|
||||
when .time_added?
|
||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||
compare_numerically a.title, b.title }
|
||||
compare_numerically a.sort_title, b.sort_title }
|
||||
when .progress?
|
||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||
ary = @entries.zip(percentage_ary)
|
||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||
.map { |tp| tp[0] }
|
||||
compare_numerically a_tp[0].sort_title, b_tp[0].sort_title }
|
||||
.map &.[0]
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||
ary = @entries.sort do |a, b|
|
||||
sorter.compare(a.title, b.title).or \
|
||||
compare_numerically a.title, b.title
|
||||
sorter.compare(a.sort_title, b.sort_title).or \
|
||||
compare_numerically a.sort_title, b.sort_title
|
||||
end
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
LRUCache.set generate_cache_entry cache_key, ary
|
||||
ary
|
||||
end
|
||||
|
||||
@@ -381,13 +644,39 @@ class Title
|
||||
{entry: e, date_added: da_ary[i]}
|
||||
end
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
zip + titles.flat_map &.deep_entries_with_date_added
|
||||
end
|
||||
|
||||
def remove_sorted_entries_cache(sort_methods : Array(SortMethod),
|
||||
username : String)
|
||||
[false, true].each do |ascend|
|
||||
sort_methods.each do |sort_method|
|
||||
sorted_entries_cache_key =
|
||||
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
||||
SortOptions.new(sort_method, ascend)
|
||||
LRUCache.invalidate sorted_entries_cache_key
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_sorted_caches(sort_methods : Array(SortMethod), username : String)
|
||||
remove_sorted_entries_cache sort_methods, username
|
||||
parents.each do |parent|
|
||||
remove_sorted_titles_cache parent.titles, sort_methods, username
|
||||
end
|
||||
remove_sorted_titles_cache Library.default.titles, sort_methods, username
|
||||
end
|
||||
|
||||
def bulk_progress(action, ids : Array(String), username)
|
||||
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
||||
parents.each do |parent|
|
||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||
end
|
||||
remove_sorted_caches [SortMethod::Progress], username
|
||||
|
||||
selected_entries = ids
|
||||
.map { |id|
|
||||
@entries.find { |e| e.id == id }
|
||||
@entries.find &.id.==(id)
|
||||
}
|
||||
.select(Entry)
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
SUPPORTED_IMG_TYPES = %w(
|
||||
image/jpeg
|
||||
image/png
|
||||
image/webp
|
||||
image/apng
|
||||
image/avif
|
||||
image/gif
|
||||
image/svg+xml
|
||||
)
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
@@ -47,6 +55,13 @@ class SortOptions
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
|
||||
def to_json
|
||||
{
|
||||
"method" => method.to_s.underscore,
|
||||
"ascend" => ascend,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
@@ -88,6 +103,18 @@ class TitleInfo
|
||||
@@mutex_hash = {} of String => Mutex
|
||||
|
||||
def self.new(dir, &)
|
||||
key = "#{dir}:info.json"
|
||||
info = LRUCache.get key
|
||||
if info.is_a? String
|
||||
begin
|
||||
instance = TitleInfo.from_json info
|
||||
instance.dir = dir
|
||||
yield instance
|
||||
return
|
||||
rescue
|
||||
end
|
||||
end
|
||||
|
||||
if @@mutex_hash[dir]?
|
||||
mutex = @@mutex_hash[dir]
|
||||
else
|
||||
@@ -101,6 +128,7 @@ class TitleInfo
|
||||
instance = TitleInfo.from_json File.read json_path
|
||||
end
|
||||
instance.dir = dir
|
||||
LRUCache.set generate_cache_entry key, instance.to_json
|
||||
yield instance
|
||||
end
|
||||
end
|
||||
@@ -108,5 +136,12 @@ class TitleInfo
|
||||
def save
|
||||
json_path = File.join @dir, "info.json"
|
||||
File.write json_path, self.to_pretty_json
|
||||
key = "#{@dir}:info.json"
|
||||
LRUCache.set generate_cache_entry key, self.to_json
|
||||
end
|
||||
end
|
||||
|
||||
alias ExamineContext = NamedTuple(
|
||||
cached_contents_signature: Hash(String, String),
|
||||
deleted_title_ids: Array(String),
|
||||
deleted_entry_ids: Array(String))
|
||||
|
||||
@@ -34,7 +34,11 @@ class Logger
|
||||
end
|
||||
|
||||
@backend.formatter = Log::Formatter.new &format_proc
|
||||
Log.setup @@severity, @backend
|
||||
|
||||
Log.setup do |c|
|
||||
c.bind "*", @@severity, @backend
|
||||
c.bind "db.*", :error, @backend
|
||||
end
|
||||
end
|
||||
|
||||
def self.get_severity(level = "") : Log::Severity
|
||||
|
||||
@@ -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,167 +0,0 @@
|
||||
require "./api"
|
||||
require "compress/zip"
|
||||
|
||||
module MangaDex
|
||||
class PageJob
|
||||
property success = false
|
||||
property url : String
|
||||
property filename : String
|
||||
property writer : Compress::Zip::Writer
|
||||
property tries_remaning : Int32
|
||||
|
||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||
end
|
||||
end
|
||||
|
||||
class Downloader < Queue::Downloader
|
||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
||||
.to_i32
|
||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
||||
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
@api = API.default
|
||||
super
|
||||
end
|
||||
|
||||
def pop : Queue::Job?
|
||||
job = nil
|
||||
MainFiber.run do
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id not like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
end
|
||||
job
|
||||
end
|
||||
|
||||
private def download(job : Queue::Job)
|
||||
@downloading = true
|
||||
@queue.set_status Queue::JobStatus::Downloading, job
|
||||
begin
|
||||
chapter = @api.get_chapter(job.id)
|
||||
rescue e
|
||||
Logger.error e
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
unless e.message.nil?
|
||||
@queue.add_message e.message.not_nil!, job
|
||||
end
|
||||
@downloading = false
|
||||
return
|
||||
end
|
||||
@queue.set_pages chapter.pages.size, job
|
||||
lib_dir = @library_path
|
||||
rename_rule = Rename::Rule.new \
|
||||
Config.current.mangadex["manga_rename_rule"].to_s
|
||||
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
|
||||
unless File.exists? manga_dir
|
||||
Dir.mkdir_p manga_dir
|
||||
end
|
||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
||||
|
||||
# Find the number of digits needed to store the number of pages
|
||||
len = Math.log10(chapter.pages.size).to_i + 1
|
||||
|
||||
writer = Compress::Zip::Writer.new zip_path
|
||||
# Create a buffered channel. It works as an FIFO queue
|
||||
channel = Channel(PageJob).new chapter.pages.size
|
||||
spawn do
|
||||
chapter.pages.each_with_index do |tuple, i|
|
||||
fn, url = tuple
|
||||
ext = File.extname fn
|
||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||
page_job = PageJob.new url, fn, writer, @retries
|
||||
Logger.debug "Downloading #{url}"
|
||||
loop do
|
||||
sleep @wait_seconds.seconds
|
||||
download_page page_job
|
||||
break if page_job.success ||
|
||||
page_job.tries_remaning <= 0
|
||||
page_job.tries_remaning -= 1
|
||||
Logger.warn "Failed to download page #{url}. " \
|
||||
"Retrying... Remaining retries: " \
|
||||
"#{page_job.tries_remaning}"
|
||||
end
|
||||
|
||||
channel.send page_job
|
||||
break unless @queue.exists? job
|
||||
end
|
||||
end
|
||||
|
||||
spawn do
|
||||
page_jobs = [] of PageJob
|
||||
chapter.pages.size.times do
|
||||
page_job = channel.receive
|
||||
|
||||
break unless @queue.exists? job
|
||||
|
||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||
"#{page_job.url}"
|
||||
page_jobs << page_job
|
||||
if page_job.success
|
||||
@queue.add_success job
|
||||
else
|
||||
@queue.add_fail job
|
||||
msg = "Failed to download page #{page_job.url}"
|
||||
@queue.add_message msg, job
|
||||
Logger.error msg
|
||||
end
|
||||
end
|
||||
|
||||
unless @queue.exists? job
|
||||
Logger.debug "Download cancelled"
|
||||
@downloading = false
|
||||
next
|
||||
end
|
||||
|
||||
fail_count = page_jobs.count { |j| !j.success }
|
||||
Logger.debug "Download completed. " \
|
||||
"#{fail_count}/#{page_jobs.size} failed"
|
||||
writer.close
|
||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||
".part")
|
||||
File.rename zip_path, filename
|
||||
Logger.debug "cbz File created at #{filename}"
|
||||
|
||||
zip_exception = validate_archive filename
|
||||
if !zip_exception.nil?
|
||||
@queue.add_message "The downloaded archive is corrupted. " \
|
||||
"Error: #{zip_exception}", job
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
elsif fail_count > 0
|
||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
||||
else
|
||||
@queue.set_status Queue::JobStatus::Completed, job
|
||||
end
|
||||
@downloading = false
|
||||
end
|
||||
end
|
||||
|
||||
private def download_page(job : PageJob)
|
||||
Logger.debug "downloading #{job.url}"
|
||||
headers = HTTP::Headers{
|
||||
"User-agent" => "Mangadex.cr",
|
||||
}
|
||||
begin
|
||||
HTTP::Client.get job.url, headers do |res|
|
||||
unless res.success?
|
||||
raise "Failed to download page #{job.url}. " \
|
||||
"[#{res.status_code}] #{res.status_message}"
|
||||
end
|
||||
job.writer.add job.filename, res.body_io
|
||||
end
|
||||
job.success = true
|
||||
rescue e
|
||||
Logger.error e
|
||||
job.success = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
14
src/mango.cr
14
src/mango.cr
@@ -2,13 +2,12 @@ require "./config"
|
||||
require "./queue"
|
||||
require "./server"
|
||||
require "./main_fiber"
|
||||
require "./mangadex/*"
|
||||
require "./plugin/*"
|
||||
require "option_parser"
|
||||
require "clim"
|
||||
require "tallboy"
|
||||
|
||||
MANGO_VERSION = "0.19.1"
|
||||
MANGO_VERSION = "0.26.0"
|
||||
|
||||
# From http://www.network-science.de/ascii/
|
||||
BANNER = %{
|
||||
@@ -56,14 +55,21 @@ class CLI < Clim
|
||||
Config.load(opts.config).set_current
|
||||
|
||||
# Initialize main components
|
||||
LRUCache.init
|
||||
Storage.default
|
||||
Queue.default
|
||||
Library.load_instance
|
||||
Library.default
|
||||
MangaDex::Downloader.default
|
||||
Plugin::Downloader.default
|
||||
Plugin::Updater.default
|
||||
|
||||
spawn do
|
||||
Server.new.start
|
||||
begin
|
||||
Server.new.start
|
||||
rescue e
|
||||
Logger.fatal e
|
||||
Process.exit 1
|
||||
end
|
||||
end
|
||||
|
||||
MainFiber.start_and_block
|
||||
|
||||
@@ -23,11 +23,6 @@ class Plugin
|
||||
job
|
||||
end
|
||||
|
||||
private def process_filename(str)
|
||||
return "_" if str == ".."
|
||||
str.gsub "/", "_"
|
||||
end
|
||||
|
||||
private def download(job : Queue::Job)
|
||||
@downloading = true
|
||||
@queue.set_status Queue::JobStatus::Downloading, job
|
||||
@@ -42,8 +37,8 @@ class Plugin
|
||||
|
||||
pages = info["pages"].as_i
|
||||
|
||||
manga_title = process_filename job.manga_title
|
||||
chapter_title = process_filename info["title"].as_s
|
||||
manga_title = sanitize_filename job.manga_title
|
||||
chapter_title = sanitize_filename info["title"].as_s
|
||||
|
||||
@queue.set_pages pages, job
|
||||
lib_dir = @library_path
|
||||
@@ -68,7 +63,7 @@ class Plugin
|
||||
while page = plugin.next_page
|
||||
break unless @queue.exists? job
|
||||
|
||||
fn = process_filename page["filename"].as_s
|
||||
fn = sanitize_filename page["filename"].as_s
|
||||
url = page["url"].as_s
|
||||
headers = HTTP::Headers.new
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ require "duktape/runtime"
|
||||
require "myhtml"
|
||||
require "xml"
|
||||
|
||||
require "./subscriptions"
|
||||
|
||||
class Plugin
|
||||
class Error < ::Exception
|
||||
end
|
||||
@@ -16,12 +18,19 @@ class Plugin
|
||||
end
|
||||
|
||||
struct Info
|
||||
include JSON::Serializable
|
||||
|
||||
{% for name in ["id", "title", "placeholder"] %}
|
||||
getter {{name.id}} = ""
|
||||
{% end %}
|
||||
getter wait_seconds : UInt64 = 0
|
||||
getter wait_seconds = 0u64
|
||||
getter version = 0u64
|
||||
getter settings = {} of String => String?
|
||||
getter dir : String
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
@json : JSON::Any
|
||||
|
||||
def initialize(@dir)
|
||||
info_path = File.join @dir, "info.json"
|
||||
|
||||
@@ -37,6 +46,16 @@ class Plugin
|
||||
@{{name.id}} = @json[{{name}}].as_s
|
||||
{% end %}
|
||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
||||
|
||||
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
||||
settings_hash.each do |k, v|
|
||||
unless str_value = v.as_s?
|
||||
raise "The settings object can only contain strings or null"
|
||||
end
|
||||
@settings[k] = str_value
|
||||
end
|
||||
end
|
||||
|
||||
unless @id.alphanumeric_underscore?
|
||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||
@@ -114,10 +133,37 @@ class Plugin
|
||||
@info.not_nil!
|
||||
end
|
||||
|
||||
def subscribe(subscription : Subscription)
|
||||
list = SubscriptionList.new info.dir
|
||||
list << subscription
|
||||
list.save
|
||||
end
|
||||
|
||||
def list_subscriptions
|
||||
SubscriptionList.new(info.dir).ary
|
||||
end
|
||||
|
||||
def list_subscriptions_raw
|
||||
SubscriptionList.new(info.dir)
|
||||
end
|
||||
|
||||
def unsubscribe(id : String)
|
||||
list = SubscriptionList.new info.dir
|
||||
list.reject! &.id.== id
|
||||
list.save
|
||||
end
|
||||
|
||||
def check_subscription(id : String)
|
||||
list = list_subscriptions_raw
|
||||
sub = list.find &.id.== id
|
||||
Plugin::Updater.default.check_subscription self, sub.not_nil!
|
||||
list.save
|
||||
end
|
||||
|
||||
def initialize(id : String)
|
||||
Plugin.build_info_ary
|
||||
|
||||
@info = @@info_ary.find { |i| i.id == id }
|
||||
@info = @@info_ary.find &.id.== id
|
||||
if @info.nil?
|
||||
raise Error.new "Plugin with ID #{id} not found"
|
||||
end
|
||||
@@ -138,6 +184,12 @@ class Plugin
|
||||
sbx.push_string path
|
||||
sbx.put_prop_string -2, "storage_path"
|
||||
|
||||
sbx.push_pointer info.dir.as(Void*)
|
||||
path = sbx.require_pointer(-1).as String
|
||||
sbx.pop
|
||||
sbx.push_string path
|
||||
sbx.put_prop_string -2, "info_dir"
|
||||
|
||||
def_helper_functions sbx
|
||||
end
|
||||
|
||||
@@ -152,23 +204,67 @@ class Plugin
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def assert_manga_type(obj : JSON::Any)
|
||||
obj["id"].as_s && obj["title"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Manga type"
|
||||
end
|
||||
|
||||
def assert_chapter_type(obj : JSON::Any)
|
||||
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
||||
obj["manga_title"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Chapter type"
|
||||
end
|
||||
|
||||
def assert_page_type(obj : JSON::Any)
|
||||
obj["url"].as_s && obj["filename"].as_s
|
||||
rescue e
|
||||
raise Error.new "Missing required fields in the Page type"
|
||||
end
|
||||
|
||||
def search_manga(query : String)
|
||||
if info.version == 1
|
||||
raise Error.new "Manga searching is only available for plugins " \
|
||||
"targeting API v2 or above"
|
||||
end
|
||||
json = eval_json "searchManga('#{query}')"
|
||||
begin
|
||||
json.as_a.each do |obj|
|
||||
assert_manga_type obj
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def list_chapters(query : String)
|
||||
json = eval_json "listChapters('#{query}')"
|
||||
begin
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
ary = json["chapters"].as_a
|
||||
ary.each do |obj|
|
||||
id = obj["id"]?
|
||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||
|
||||
unless id.to_s.alphanumeric_underscore?
|
||||
raise "The `id` field can only contain alphanumeric characters " \
|
||||
"and underscores"
|
||||
if info.version > 1
|
||||
# Since v2, listChapters returns an array
|
||||
json.as_a.each do |obj|
|
||||
assert_chapter_type obj
|
||||
end
|
||||
else
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
title = obj["title"]?
|
||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||
ary = json["chapters"].as_a
|
||||
ary.each do |obj|
|
||||
id = obj["id"]?
|
||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||
|
||||
unless id.to_s.alphanumeric_underscore?
|
||||
raise "The `id` field can only contain alphanumeric characters " \
|
||||
"and underscores"
|
||||
end
|
||||
|
||||
title = obj["title"]?
|
||||
if title.nil?
|
||||
raise "Field `title` missing from `listChapters` outputs"
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
@@ -179,10 +275,14 @@ class Plugin
|
||||
def select_chapter(id : String)
|
||||
json = eval_json "selectChapter('#{id}')"
|
||||
begin
|
||||
check_fields ["title", "pages"]
|
||||
if info.version > 1
|
||||
assert_chapter_type json
|
||||
else
|
||||
check_fields ["title", "pages"]
|
||||
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
@@ -194,7 +294,21 @@ class Plugin
|
||||
json = eval_json "nextPage()"
|
||||
return if json.size == 0
|
||||
begin
|
||||
check_fields ["filename", "url"]
|
||||
assert_page_type json
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def new_chapters(manga_id : String, after : Int64)
|
||||
# Converting standard timestamp to milliseconds so plugins can easily do
|
||||
# `new Date(ms_timestamp)` in JS.
|
||||
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
|
||||
begin
|
||||
json.as_a.each do |obj|
|
||||
assert_chapter_type obj
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
@@ -379,6 +493,27 @@ class Plugin
|
||||
end
|
||||
sbx.put_prop_string -2, "storage"
|
||||
|
||||
if info.version > 1
|
||||
sbx.push_proc 1 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
key = env.require_string 0
|
||||
|
||||
env.get_global_string "info_dir"
|
||||
info_dir = env.require_string -1
|
||||
env.pop
|
||||
info = Info.new info_dir
|
||||
|
||||
if value = info.settings[key]?
|
||||
env.push_string value
|
||||
else
|
||||
env.push_undefined
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "settings"
|
||||
end
|
||||
|
||||
sbx.put_prop_string -2, "mango"
|
||||
end
|
||||
end
|
||||
|
||||
115
src/plugin/subscriptions.cr
Normal file
115
src/plugin/subscriptions.cr
Normal file
@@ -0,0 +1,115 @@
|
||||
require "uuid"
|
||||
require "big"
|
||||
|
||||
enum FilterType
|
||||
String
|
||||
NumMin
|
||||
NumMax
|
||||
DateMin
|
||||
DateMax
|
||||
Array
|
||||
|
||||
def self.from_string(str)
|
||||
case str
|
||||
when "string"
|
||||
String
|
||||
when "number-min"
|
||||
NumMin
|
||||
when "number-max"
|
||||
NumMax
|
||||
when "date-min"
|
||||
DateMin
|
||||
when "date-max"
|
||||
DateMax
|
||||
when "array"
|
||||
Array
|
||||
else
|
||||
raise "Unknown filter type with string #{str}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct Filter
|
||||
include JSON::Serializable
|
||||
|
||||
property key : String
|
||||
property value : String | Int32 | Int64 | Float32 | Nil
|
||||
property type : FilterType
|
||||
|
||||
def initialize(@key, @value, @type)
|
||||
end
|
||||
|
||||
def self.from_json(str) : Filter
|
||||
json = JSON.parse str
|
||||
key = json["key"].as_s
|
||||
type = FilterType.from_string json["type"].as_s
|
||||
_value = json["value"]
|
||||
value = _value.as_s? || _value.as_i? || _value.as_i64? ||
|
||||
_value.as_f32? || nil
|
||||
self.new key, value, type
|
||||
end
|
||||
|
||||
def match_chapter(obj : JSON::Any) : Bool
|
||||
return true if value.nil? || value.to_s.empty?
|
||||
raw_value = obj[key]
|
||||
case type
|
||||
when FilterType::String
|
||||
raw_value.as_s.downcase == value.to_s.downcase
|
||||
when FilterType::NumMin, FilterType::DateMin
|
||||
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
|
||||
when FilterType::NumMax, FilterType::DateMax
|
||||
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
|
||||
when FilterType::Array
|
||||
return true if value == "all"
|
||||
raw_value.as_s.downcase.split(",")
|
||||
.map(&.strip).includes? value.to_s.downcase.strip
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# We use class instead of struct so we can update `last_checked` from
|
||||
# `SubscriptionList`
|
||||
class Subscription
|
||||
include JSON::Serializable
|
||||
|
||||
property id : String
|
||||
property plugin_id : String
|
||||
property manga_id : String
|
||||
property manga_title : String
|
||||
property name : String
|
||||
property created_at : Int64
|
||||
property last_checked : Int64
|
||||
property filters = [] of Filter
|
||||
|
||||
def initialize(@plugin_id, @manga_id, @manga_title, @name)
|
||||
@id = UUID.random.to_s
|
||||
@created_at = Time.utc.to_unix
|
||||
@last_checked = Time.utc.to_unix
|
||||
end
|
||||
|
||||
def match_chapter(obj : JSON::Any) : Bool
|
||||
filters.all? &.match_chapter(obj)
|
||||
end
|
||||
end
|
||||
|
||||
struct SubscriptionList
|
||||
@dir : String
|
||||
@path : String
|
||||
|
||||
getter ary = [] of Subscription
|
||||
|
||||
forward_missing_to @ary
|
||||
|
||||
def initialize(@dir)
|
||||
@path = Path[@dir, "subscriptions.json"].to_s
|
||||
if File.exists? @path
|
||||
@ary = Array(Subscription).from_json File.read @path
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
File.write @path, @ary.to_pretty_json
|
||||
end
|
||||
end
|
||||
75
src/plugin/updater.cr
Normal file
75
src/plugin/updater.cr
Normal file
@@ -0,0 +1,75 @@
|
||||
class Plugin
|
||||
class Updater
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
interval = Config.current.plugin_update_interval_hours
|
||||
return if interval <= 0
|
||||
spawn do
|
||||
loop do
|
||||
Plugin.list.map(&.["id"]).each do |pid|
|
||||
check_updates pid
|
||||
end
|
||||
sleep interval.hours
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def check_updates(plugin_id : String)
|
||||
Logger.debug "Checking plugin #{plugin_id} for updates"
|
||||
|
||||
plugin = Plugin.new plugin_id
|
||||
if plugin.info.version == 1
|
||||
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
|
||||
"Skipping update check"
|
||||
return
|
||||
end
|
||||
|
||||
subscriptions = plugin.list_subscriptions_raw
|
||||
subscriptions.each do |sub|
|
||||
check_subscription plugin, sub
|
||||
end
|
||||
subscriptions.save
|
||||
rescue e
|
||||
Logger.error "Error checking plugin #{plugin_id} for updates: " \
|
||||
"#{e.message}"
|
||||
end
|
||||
|
||||
def check_subscription(plugin : Plugin, sub : Subscription)
|
||||
Logger.debug "Checking subscription #{sub.name} for updates"
|
||||
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
|
||||
.as_a.select do |chapter|
|
||||
sub.match_chapter chapter
|
||||
end
|
||||
if matches.empty?
|
||||
Logger.debug "No new chapters found."
|
||||
sub.last_checked = Time.utc.to_unix
|
||||
return
|
||||
end
|
||||
Logger.debug "Found #{matches.size} new chapters. " \
|
||||
"Pushing to download queue"
|
||||
jobs = matches.map { |ch|
|
||||
Queue::Job.new(
|
||||
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
||||
"", # manga_id
|
||||
ch["title"].as_s,
|
||||
sub.manga_title,
|
||||
Queue::JobStatus::Pending,
|
||||
Time.utc
|
||||
)
|
||||
}
|
||||
inserted_count = Queue.default.push jobs
|
||||
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
|
||||
"to the download queue. Plugin ID #{plugin.info.id}, " \
|
||||
"subscription name #{sub.name}"
|
||||
if inserted_count != matches.size
|
||||
Logger.error "Failed to add #{matches.size - inserted_count} " \
|
||||
"chapters to download queue"
|
||||
end
|
||||
sub.last_checked = Time.utc.to_unix
|
||||
rescue e
|
||||
Logger.error "Error when checking updates for subscription " \
|
||||
"#{sub.name}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
14
src/queue.cr
14
src/queue.cr
@@ -70,7 +70,13 @@ class Queue
|
||||
ary = @id.split("-")
|
||||
if ary.size == 2
|
||||
@plugin_id = ary[0]
|
||||
@plugin_chapter_id = ary[1]
|
||||
# This begin-rescue block is for backward compatibility. In earlier
|
||||
# versions we didn't encode the chapter ID
|
||||
@plugin_chapter_id = begin
|
||||
Base64.decode_string ary[1]
|
||||
rescue
|
||||
ary[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,7 +118,7 @@ class Queue
|
||||
use_default
|
||||
|
||||
def initialize(db_path : String? = nil)
|
||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
||||
@path = db_path || Config.current.queue_db_path.to_s
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||
@@ -303,12 +309,12 @@ class Queue
|
||||
end
|
||||
|
||||
def pause
|
||||
@downloaders.each { |d| d.stopped = true }
|
||||
@downloaders.each &.stopped=(true)
|
||||
@paused = true
|
||||
end
|
||||
|
||||
def resume
|
||||
@downloaders.each { |d| d.stopped = false }
|
||||
@downloaders.each &.stopped=(false)
|
||||
@paused = false
|
||||
end
|
||||
|
||||
|
||||
@@ -35,15 +35,15 @@ module Rename
|
||||
|
||||
class Group < Base(Pattern | String)
|
||||
def render(hash : VHash)
|
||||
return "" if @ary.select(&.is_a? Pattern)
|
||||
return "" if @ary.select(Pattern)
|
||||
.any? &.as(Pattern).render(hash).empty?
|
||||
@ary.map do |e|
|
||||
@ary.join do |e|
|
||||
if e.is_a? Pattern
|
||||
e.render hash
|
||||
else
|
||||
e
|
||||
end
|
||||
end.join
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -129,13 +129,13 @@ module Rename
|
||||
end
|
||||
|
||||
def render(hash : VHash)
|
||||
str = @ary.map do |e|
|
||||
str = @ary.join do |e|
|
||||
if e.is_a? String
|
||||
e
|
||||
else
|
||||
e.render hash
|
||||
end
|
||||
end.join.strip
|
||||
end.strip
|
||||
post_process str
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
struct AdminRouter
|
||||
def initialize
|
||||
get "/admin" do |env|
|
||||
storage = Storage.default
|
||||
missing_count = storage.missing_titles.size +
|
||||
storage.missing_entries.size
|
||||
layout "admin"
|
||||
end
|
||||
|
||||
@@ -63,8 +66,15 @@ struct AdminRouter
|
||||
end
|
||||
|
||||
get "/admin/downloads" do |env|
|
||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||
layout "download-manager"
|
||||
end
|
||||
|
||||
get "/admin/subscriptions" do |env|
|
||||
layout "subscription-manager"
|
||||
end
|
||||
|
||||
get "/admin/missing" do |env|
|
||||
layout "missing-items"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
1070
src/routes/api.cr
1070
src/routes/api.cr
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,8 @@ struct MainRouter
|
||||
else
|
||||
redirect env, "/"
|
||||
end
|
||||
rescue
|
||||
rescue e
|
||||
Logger.error e
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
@@ -40,7 +41,7 @@ struct MainRouter
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||
get_sort_opt
|
||||
get_and_save_sort_opt Library.default.dir
|
||||
|
||||
titles = Library.default.sorted_titles username, sort_opt
|
||||
percentage = titles.map &.load_percentage username
|
||||
@@ -58,12 +59,18 @@ struct MainRouter
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
get_sort_opt
|
||||
get_and_save_sort_opt title.dir
|
||||
|
||||
sorted_titles = title.sorted_titles username, sort_opt
|
||||
entries = title.sorted_entries username, sort_opt
|
||||
|
||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
||||
title_percentage = title.titles.map &.load_percentage username
|
||||
title_percentage_map = {} of String => Float64
|
||||
title_percentage.each_with_index do |tp, i|
|
||||
t = title.titles[i]
|
||||
title_percentage_map[t.id] = tp
|
||||
end
|
||||
|
||||
layout "title"
|
||||
rescue e
|
||||
Logger.error e
|
||||
@@ -71,23 +78,8 @@ struct MainRouter
|
||||
end
|
||||
end
|
||||
|
||||
get "/download" do |env|
|
||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||
layout "download"
|
||||
end
|
||||
|
||||
get "/download/plugins" do |env|
|
||||
begin
|
||||
id = env.params.query["plugin"]?
|
||||
plugins = Plugin.list
|
||||
plugin = nil
|
||||
|
||||
if id
|
||||
plugin = Plugin.new id
|
||||
elsif !plugins.empty?
|
||||
plugin = Plugin.new plugins[0][:id]
|
||||
end
|
||||
|
||||
layout "plugin-download"
|
||||
rescue e
|
||||
Logger.error e
|
||||
@@ -103,7 +95,7 @@ struct MainRouter
|
||||
recently_added = Library.default.get_recently_added_entries username
|
||||
start_reading = Library.default.get_start_reading_titles username
|
||||
titles = Library.default.titles
|
||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||
new_user = !titles.any? &.load_percentage(username).> 0
|
||||
empty_library = titles.size == 0
|
||||
layout "home"
|
||||
rescue e
|
||||
@@ -154,6 +146,7 @@ struct MainRouter
|
||||
end
|
||||
|
||||
get "/api" do |env|
|
||||
base_url = Config.current.base_url
|
||||
render "src/views/api.html.ecr"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,11 @@ struct ReaderRouter
|
||||
|
||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
get_sort_opt
|
||||
entries = title.sorted_entries username, sort_opt
|
||||
|
||||
page_idx = env.params.url["page"].to_i
|
||||
if page_idx > entry.pages || page_idx <= 0
|
||||
raise "Page #{page_idx} not found."
|
||||
@@ -37,10 +42,12 @@ struct ReaderRouter
|
||||
|
||||
exit_url = "#{base_url}book/#{title.id}"
|
||||
|
||||
next_entry_url = nil
|
||||
next_entry = entry.next_entry username
|
||||
unless next_entry.nil?
|
||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
||||
next_entry_url = entry.next_entry(username).try do |e|
|
||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||
end
|
||||
|
||||
previous_entry_url = entry.previous_entry(username).try do |e|
|
||||
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||
end
|
||||
|
||||
render "src/views/reader.html.ecr"
|
||||
|
||||
@@ -23,7 +23,17 @@ class Server
|
||||
AdminRouter.new
|
||||
ReaderRouter.new
|
||||
APIRouter.new
|
||||
OPDSRouter.new
|
||||
|
||||
{% for path in %w(/api/* /uploads/* /img/*) %}
|
||||
options {{path}} do |env|
|
||||
cors
|
||||
halt env
|
||||
end
|
||||
{% end %}
|
||||
|
||||
static_headers do |response|
|
||||
response.headers.add("Access-Control-Allow-Origin", "*")
|
||||
end
|
||||
|
||||
Kemal.config.logging = false
|
||||
add_handler LogHandler.new
|
||||
@@ -49,6 +59,7 @@ class Server
|
||||
{% if flag?(:release) %}
|
||||
Kemal.config.env = "production"
|
||||
{% end %}
|
||||
Kemal.config.host_binding = Config.current.host
|
||||
Kemal.config.port = Config.current.port
|
||||
Kemal.run
|
||||
end
|
||||
|
||||
337
src/storage.cr
337
src/storage.cr
@@ -15,14 +15,16 @@ def verify_password(hash, pw)
|
||||
end
|
||||
|
||||
class Storage
|
||||
@@insert_ids = [] of IDTuple
|
||||
@@insert_entry_ids = [] of IDTuple
|
||||
@@insert_title_ids = [] of IDTuple
|
||||
|
||||
@path : String
|
||||
@db : DB::Database?
|
||||
|
||||
alias IDTuple = NamedTuple(path: String,
|
||||
alias IDTuple = NamedTuple(
|
||||
path: String,
|
||||
id: String,
|
||||
is_title: Bool)
|
||||
signature: String?)
|
||||
|
||||
use_default
|
||||
|
||||
@@ -32,7 +34,7 @@ class Storage
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
Logger.info "The DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
MainFiber.run do
|
||||
@@ -46,14 +48,6 @@ class Storage
|
||||
|
||||
user_count = db.query_one "select count(*) from users", as: Int32
|
||||
init_admin if init_user && user_count == 0
|
||||
|
||||
# Verifies that the default username in config is valid
|
||||
if Config.current.disable_login
|
||||
username = Config.current.default_username
|
||||
unless username_exists username
|
||||
raise "Default username #{username} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
unless @auto_close
|
||||
@db = DB.open "sqlite3://#{@path}"
|
||||
@@ -230,24 +224,96 @@ class Storage
|
||||
end
|
||||
end
|
||||
|
||||
def get_id(path, is_title)
|
||||
def get_title_id(path, signature)
|
||||
id = nil
|
||||
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
if is_title
|
||||
id = db.query_one? "select id from titles where path = (?)", path,
|
||||
as: String
|
||||
else
|
||||
id = db.query_one? "select id from ids where path = (?)", path,
|
||||
as: String
|
||||
# First attempt to find the matching title in DB using BOTH path
|
||||
# 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
|
||||
id
|
||||
end
|
||||
|
||||
def insert_id(tp : IDTuple)
|
||||
@@insert_ids << tp
|
||||
# See the comments in `#get_title_id` to see how this method works.
|
||||
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
|
||||
|
||||
def bulk_insert_ids
|
||||
@@ -255,17 +321,85 @@ class Storage
|
||||
get_db do |db|
|
||||
db.transaction do |tran|
|
||||
conn = tran.connection
|
||||
@@insert_ids.each do |tp|
|
||||
if tp[:is_title]
|
||||
conn.exec "insert into titles values (?, ?, null)", tp[:id],
|
||||
tp[:path]
|
||||
else
|
||||
conn.exec "insert into ids values (?, ?)", tp[:path], tp[:id]
|
||||
end
|
||||
@@insert_title_ids.each do |tp|
|
||||
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
|
||||
@@insert_ids.clear
|
||||
@@insert_entry_ids.clear
|
||||
@@insert_title_ids.clear
|
||||
end
|
||||
end
|
||||
|
||||
def get_title_sort_title(title_id : String)
|
||||
sort_title = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
sort_title =
|
||||
db.query_one? "Select sort_title from titles where id = (?)",
|
||||
title_id, as: String | Nil
|
||||
end
|
||||
end
|
||||
sort_title
|
||||
end
|
||||
|
||||
def set_title_sort_title(title_id : String, sort_title : String | Nil)
|
||||
sort_title = nil if sort_title == ""
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.exec "update titles set sort_title = (?) where id = (?)",
|
||||
sort_title, title_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_entry_sort_title(entry_id : String)
|
||||
sort_title = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
sort_title =
|
||||
db.query_one? "Select sort_title from ids where id = (?)",
|
||||
entry_id, as: String | Nil
|
||||
end
|
||||
end
|
||||
sort_title
|
||||
end
|
||||
|
||||
def get_entries_sort_title(ids : Array(String))
|
||||
results = Hash(String, String | Nil).new
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query "select id, sort_title from ids where id in " \
|
||||
"(#{ids.join "," { |id| "'#{id}'" }})" do |rs|
|
||||
rs.each do
|
||||
id = rs.read String
|
||||
sort_title = rs.read String | Nil
|
||||
results[id] = sort_title
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
results
|
||||
end
|
||||
|
||||
def set_entry_sort_title(entry_id : String, sort_title : String | Nil)
|
||||
sort_title = nil if sort_title == ""
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.exec "update ids set sort_title = (?) where id = (?)",
|
||||
sort_title, entry_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -322,7 +456,8 @@ class Storage
|
||||
tags = [] of String
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query "select distinct tag from tags" do |rs|
|
||||
db.query "select distinct tag from tags natural join titles " \
|
||||
"where unavailable = 0" do |rs|
|
||||
rs.each do
|
||||
tags << rs.read String
|
||||
end
|
||||
@@ -354,42 +489,150 @@ class Storage
|
||||
end
|
||||
end
|
||||
|
||||
def optimize
|
||||
# Mark titles and entries that no longer exist on the file system as
|
||||
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
|
||||
# only checks the existence of the candidate titles/entries to speed up
|
||||
# the process.
|
||||
def mark_unavailable(ids_candidates : Array(String)?,
|
||||
titles_candidates : Array(String)?)
|
||||
MainFiber.run do
|
||||
Logger.info "Starting DB optimization"
|
||||
get_db do |db|
|
||||
# Delete dangling entry IDs
|
||||
# Detect dangling entry IDs
|
||||
trash_ids = [] of String
|
||||
db.query "select path, id from ids" do |rs|
|
||||
query = "select path, id from ids where unavailable = 0"
|
||||
unless ids_candidates.nil?
|
||||
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
|
||||
end
|
||||
db.query query do |rs|
|
||||
rs.each do
|
||||
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
|
||||
|
||||
db.exec "delete from ids where id in " \
|
||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
||||
Logger.debug "#{trash_ids.size} dangling entry IDs deleted" \
|
||||
if trash_ids.size > 0
|
||||
unless trash_ids.empty?
|
||||
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||
end
|
||||
db.exec "update ids set unavailable = 1 where id in " \
|
||||
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
||||
|
||||
# Delete dangling title IDs
|
||||
# Detect dangling title IDs
|
||||
trash_titles = [] of String
|
||||
db.query "select path, id from titles" do |rs|
|
||||
query = "select path, id from titles where unavailable = 0"
|
||||
unless titles_candidates.nil?
|
||||
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
|
||||
end
|
||||
db.query query do |rs|
|
||||
rs.each do
|
||||
path = rs.read String
|
||||
trash_titles << rs.read String unless Dir.exists? path
|
||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||
trash_titles << rs.read String unless Dir.exists? fullpath
|
||||
end
|
||||
end
|
||||
|
||||
db.exec "delete from titles where id in " \
|
||||
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
||||
Logger.debug "#{trash_titles.size} dangling title IDs deleted" \
|
||||
if trash_titles.size > 0
|
||||
unless trash_titles.empty?
|
||||
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||
end
|
||||
db.exec "update titles set unavailable = 1 where id in " \
|
||||
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
||||
end
|
||||
Logger.info "DB optimization finished"
|
||||
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
|
||||
|
||||
def save_md_token(username : String, token : String, expire : Time)
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
count = db.query_one "select count(*) from md_account where " \
|
||||
"username = (?)", username, as: Int64
|
||||
if count == 0
|
||||
db.exec "insert into md_account values (?, ?, ?)", username, token,
|
||||
expire.to_unix
|
||||
else
|
||||
db.exec "update md_account set token = (?), expire = (?) " \
|
||||
"where username = (?)", token, expire.to_unix, username
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_md_token(username) : Tuple(String?, Time?)
|
||||
token = nil
|
||||
expires = nil
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query_one? "select token, expire from md_account where " \
|
||||
"username = (?)", username do |res|
|
||||
token = res.read String
|
||||
expires = Time.unix res.read Int64
|
||||
end
|
||||
end
|
||||
end
|
||||
{token, expires}
|
||||
end
|
||||
|
||||
def count_titles : Int32
|
||||
count = 0
|
||||
MainFiber.run do
|
||||
get_db do |db|
|
||||
db.query "select count(*) from titles" do |rs|
|
||||
rs.each do
|
||||
count = rs.read Int32
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
count
|
||||
end
|
||||
|
||||
def close
|
||||
MainFiber.run do
|
||||
unless @db.nil?
|
||||
|
||||
@@ -73,7 +73,7 @@ class ChapterSorter
|
||||
.select do |key|
|
||||
keys[key].count >= str_ary.size / 2
|
||||
end
|
||||
.sort do |a_key, b_key|
|
||||
.sort! do |a_key, b_key|
|
||||
a = keys[a_key]
|
||||
b = keys[b_key]
|
||||
# Sort keys by the number of times they appear
|
||||
|
||||
@@ -11,7 +11,7 @@ end
|
||||
def split_by_alphanumeric(str)
|
||||
arr = [] of String
|
||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||
arr += match.captures.select { |s| s != "" }
|
||||
arr += match.captures.select &.!= ""
|
||||
end
|
||||
arr
|
||||
end
|
||||
|
||||
79
src/util/signature.cr
Normal file
79
src/util/signature.cr
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
|
||||
# Returns the contents signature of the directory at dirname for checking
|
||||
# to rescan.
|
||||
# Rescan conditions:
|
||||
# - When a file added, moved, removed, renamed (including which in nested
|
||||
# directories)
|
||||
def self.contents_signature(dirname, cache = {} of String => String) : String
|
||||
return cache[dirname] if cache[dirname]?
|
||||
Fiber.yield
|
||||
signatures = [] of String
|
||||
self.open dirname do |dir|
|
||||
dir.entries.sort.each do |fn|
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dirname, fn
|
||||
if File.directory? path
|
||||
signatures << Dir.contents_signature path, cache
|
||||
else
|
||||
# Only add its signature value to `signatures` when it is a
|
||||
# supported file
|
||||
signatures << fn if is_supported_file fn
|
||||
end
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
hash = Digest::SHA1.hexdigest(signatures.join)
|
||||
cache[dirname] = hash
|
||||
hash
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,8 @@
|
||||
IMGS_PER_PAGE = 5
|
||||
ENTRIES_IN_HOME_SECTIONS = 8
|
||||
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
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
@@ -22,15 +23,32 @@ end
|
||||
|
||||
def register_mime_types
|
||||
{
|
||||
# Comic Archives
|
||||
".zip" => "application/zip",
|
||||
".rar" => "application/x-rar-compressed",
|
||||
".cbz" => "application/vnd.comicbook+zip",
|
||||
".cbr" => "application/vnd.comicbook-rar",
|
||||
|
||||
# Favicon
|
||||
".ico" => "image/x-icon",
|
||||
|
||||
# FontAwesome fonts
|
||||
".woff" => "font/woff",
|
||||
".woff2" => "font/woff2",
|
||||
|
||||
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
|
||||
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||
".apng" => "image/apng",
|
||||
".avif" => "image/avif",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
end
|
||||
|
||||
def is_supported_file(path)
|
||||
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||
end
|
||||
|
||||
struct Int
|
||||
def or(other : Int)
|
||||
if self == 0
|
||||
@@ -69,26 +87,88 @@ def env_is_true?(key : String) : Bool
|
||||
end
|
||||
|
||||
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
||||
ary = titles
|
||||
cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt
|
||||
cached_titles = LRUCache.get cache_key
|
||||
return cached_titles if cached_titles.is_a? Array(Title)
|
||||
|
||||
case opt.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.sort_title, b.sort_title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
ary = titles.sort do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
compare_numerically a.sort_title, b.sort_title
|
||||
end
|
||||
when .title?
|
||||
ary = titles.sort do |a, b|
|
||||
compare_numerically a.sort_title, b.sort_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 }
|
||||
ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title }
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
LRUCache.set generate_cache_entry cache_key, ary
|
||||
ary
|
||||
end
|
||||
|
||||
def remove_sorted_titles_cache(titles : Array(Title),
|
||||
sort_methods : Array(SortMethod),
|
||||
username : String)
|
||||
[false, true].each do |ascend|
|
||||
sort_methods.each do |sort_method|
|
||||
sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
|
||||
titles, SortOptions.new(sort_method, ascend)
|
||||
LRUCache.invalidate sorted_titles_cache_key
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
# Does the followings:
|
||||
# - turns space-like characters into the normal whitespaces ( )
|
||||
# - strips and collapses spaces
|
||||
# - removes ASCII control characters
|
||||
# - replaces slashes (/) with underscores (_)
|
||||
# - removes leading dots (.)
|
||||
# - removes the following special characters: \:*?"<>|
|
||||
#
|
||||
# If the sanitized string is empty, returns a random string instead.
|
||||
def sanitize_filename(str : String) : String
|
||||
sanitized = str
|
||||
.gsub(/\s+/, " ")
|
||||
.strip
|
||||
.gsub(/\//, "_")
|
||||
.gsub(/^[\.\s]+/, "")
|
||||
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
||||
sanitized.size > 0 ? sanitized : random_str
|
||||
end
|
||||
|
||||
def delete_cache_and_exit(path : String)
|
||||
File.delete path
|
||||
Logger.fatal "Invalid library cache deleted. Mango needs to " \
|
||||
"perform a full reset to recover from this. " \
|
||||
"Pleae restart Mango. This is NOT a bug."
|
||||
Logger.fatal "Exiting"
|
||||
exit 1
|
||||
end
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# Web related helper functions/macros
|
||||
|
||||
# This macro defines `is_admin` when used
|
||||
macro check_admin_access
|
||||
def is_admin?(env) : Bool
|
||||
is_admin = false
|
||||
# The token (if exists) takes precedence over the default user option.
|
||||
# this is why we check the default username first before checking the
|
||||
# token.
|
||||
if Config.current.disable_login
|
||||
is_admin = Storage.default.
|
||||
username_is_admin Config.current.default_username
|
||||
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)
|
||||
base_url = Config.current.base_url
|
||||
check_admin_access
|
||||
is_admin = is_admin? env
|
||||
begin
|
||||
page = {{name}}
|
||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||
@@ -32,35 +32,69 @@ end
|
||||
macro send_error_page(msg)
|
||||
message = {{msg}}
|
||||
base_url = Config.current.base_url
|
||||
check_admin_access
|
||||
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)
|
||||
cors
|
||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||
end
|
||||
|
||||
def get_token_from_auth_header(env) : String?
|
||||
value = env.request.headers["Authorization"]
|
||||
if value && value.starts_with? "Bearer"
|
||||
session_id = value.split(" ")[1]
|
||||
return Kemal::Session.get(session_id).try &.string? "token"
|
||||
end
|
||||
end
|
||||
|
||||
macro get_username(env)
|
||||
begin
|
||||
token = env.session.string "token"
|
||||
(Storage.default.verify_token token).not_nil!
|
||||
# Check if we can get the session id from the cookie
|
||||
token = env.session.string? "token"
|
||||
if token.nil?
|
||||
# If not, check if we can get the session id from the auth header
|
||||
token = get_token_from_auth_header env
|
||||
end
|
||||
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
|
||||
(Storage.default.verify_token token.not_nil!).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
|
||||
|
||||
macro cors
|
||||
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
|
||||
"DELETE,OPTIONS"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
|
||||
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
|
||||
"Authorization"
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
end
|
||||
|
||||
def send_json(env, json)
|
||||
cors
|
||||
env.response.content_type = "application/json"
|
||||
env.response.print json
|
||||
end
|
||||
|
||||
def send_text(env, text)
|
||||
cors
|
||||
env.response.content_type = "text/plain"
|
||||
env.response.print text
|
||||
end
|
||||
|
||||
def send_attachment(env, path)
|
||||
cors
|
||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||
end
|
||||
|
||||
@@ -70,7 +104,7 @@ def redirect(env, path)
|
||||
end
|
||||
|
||||
def hash_to_query(hash)
|
||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||
hash.join "&" { |k, v| "#{k}=#{v}" }
|
||||
end
|
||||
|
||||
def request_path_startswith(env, ary)
|
||||
@@ -105,6 +139,26 @@ macro get_sort_opt
|
||||
end
|
||||
end
|
||||
|
||||
macro get_and_save_sort_opt(dir)
|
||||
sort_method = env.params.query["sort"]?
|
||||
|
||||
if sort_method
|
||||
is_ascending = true
|
||||
|
||||
ascend = env.params.query["ascend"]?
|
||||
if ascend && ascend.to_i? == 0
|
||||
is_ascending = false
|
||||
end
|
||||
|
||||
sort_opt = SortOptions.new sort_method, is_ascending
|
||||
|
||||
TitleInfo.new {{dir}} do |info|
|
||||
info.sort_by[username] = sort_opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module HTTP
|
||||
class Client
|
||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
|
||||
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
|
||||
<li>
|
||||
<a class="uk-link-reset" 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>
|
||||
@@ -19,7 +27,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<span>Theme</span>
|
||||
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
|
||||
<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>Light</option>
|
||||
<option>System</option>
|
||||
@@ -32,5 +40,6 @@
|
||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/admin.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
<% if page == "home" && item.is_a? Entry %>
|
||||
<%= "uk-margin-remove-bottom" %>
|
||||
<% end %>
|
||||
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
|
||||
" data-title="<%= HTML.escape(item.display_name) %>"
|
||||
data-file-title="<%= HTML.escape(item.title || "") %>"
|
||||
data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %>
|
||||
</h3>
|
||||
<% if page == "home" && item.is_a? Entry %>
|
||||
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||
<script src="<%= base_url %>js/dots.js"></script>
|
||||
@@ -4,13 +4,11 @@
|
||||
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
|
||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
|
||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||
<link rel="manifest" href="<%= base_url %>manifest.json">
|
||||
|
||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
|
||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
|
||||
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
|
||||
|
||||
1
src/views/components/jquery-ui.html.ecr
Normal file
1
src/views/components/jquery-ui.html.ecr
Normal file
@@ -0,0 +1 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
1
src/views/components/moment.html.ecr
Normal file
1
src/views/components/moment.html.ecr
Normal file
@@ -0,0 +1 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
2
src/views/components/uikit.html.ecr
Normal file
2
src/views/components/uikit.html.ecr
Normal file
@@ -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>
|
||||
@@ -24,16 +24,10 @@
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
|
||||
</template>
|
||||
|
||||
<template x-if="job.plugin_id">
|
||||
<td x-text="job.manga_title"></td>
|
||||
</template>
|
||||
<template x-if="!job.plugin_id">
|
||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||
</template>
|
||||
|
||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||
@@ -49,11 +43,10 @@
|
||||
</td>
|
||||
|
||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||
|
||||
<td>
|
||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||
<template x-if="job.status_message.length > 0">
|
||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -61,9 +54,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<h2 class=uk-title>Download from MangaDex</h2>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-3-4">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||
</div>
|
||||
<div class="uk-width-1-4">
|
||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
||||
<div class="uk-width-1-4@s">
|
||||
<img id="cover">
|
||||
</div>
|
||||
<div class="uk-width-1-4@s">
|
||||
<p id="title"></p>
|
||||
<p id="artist"></p>
|
||||
<p id="author"></p>
|
||||
</div>
|
||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="lang-select">Language</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="lang-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="group-select">Group</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select filter-field" id="group-select">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
||||
<div class="uk-form-controls">
|
||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="selection-controls" class="uk-margin" hidden>
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
</div>
|
||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||
</div>
|
||||
<p id="filter-notification" hidden></p>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Language</th>
|
||||
<th>Group</th>
|
||||
<th>Volume</th>
|
||||
<th>Chapter</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script>
|
||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/download.js"></script>
|
||||
<% end %>
|
||||
@@ -77,7 +77,7 @@
|
||||
<%- end -%>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/title.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<li class="uk-parent">
|
||||
<a href="#">Download</a>
|
||||
<ul class="uk-nav-sub">
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
@@ -33,11 +33,11 @@
|
||||
</div>
|
||||
<div class="uk-position-top">
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div class="uk-navbar-left uk-hidden@s">
|
||||
<div class="uk-navbar-left uk-hidden@m">
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@s">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||
<div class="uk-navbar-left uk-visible@m">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icons/icon.png" style="width:90px;height:90px;"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
@@ -49,17 +49,17 @@
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li class="uk-nav-header">Source</li>
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li class="uk-nav-divider"></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="uk-navbar-right uk-visible@s">
|
||||
<div class="uk-navbar-right uk-visible@m">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||
@@ -80,11 +80,9 @@
|
||||
</div>
|
||||
<script>
|
||||
setTheme();
|
||||
const base_url = "<%= base_url %>";
|
||||
const base_url = "<%= base_url %>";
|
||||
</script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
|
||||
<%= render_component "uikit" %>
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<% hash = {
|
||||
"auto" => "Auto",
|
||||
"title" => "Name",
|
||||
"time_modified" => "Date Modified",
|
||||
"progress" => "Progress"
|
||||
} %>
|
||||
@@ -24,7 +25,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
<script>
|
||||
setTheme();
|
||||
</script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
<%= render_component "uikit" %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
40
src/views/missing-items.html.ecr
Normal file
40
src/views/missing-items.html.ecr
Normal file
@@ -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 %>
|
||||
@@ -1,75 +1,214 @@
|
||||
<% if plugins.empty? %>
|
||||
<div class="uk-container uk-text-center">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<h2 class=uk-title>Download with Plugins</h2>
|
||||
|
||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="component()" x-init="init()" x-cloak>
|
||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
<div class="uk-width-expand">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="plugin-select" class="uk-select">
|
||||
<% plugins.each do |p| %>
|
||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<div x-show="plugins.length > 0" style="width:100%">
|
||||
<h2 class=uk-title>Download with Plugins
|
||||
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||
</h2>
|
||||
|
||||
<template x-if="info !== undefined">
|
||||
<div>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||
<div class="uk-margin">
|
||||
<div class="uk-form-controls">
|
||||
<label class="uk-form-label"> </label>
|
||||
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-expand">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||
<template x-for="p in plugins" :key="p">
|
||||
<option :value="p.id" x-text="p.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label"> </label>
|
||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
||||
<dl class="uk-description-list" id="toggle" hidden>
|
||||
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
|
||||
<dd x-text="entry[1]"></dd>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </label>
|
||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||
</template>
|
||||
|
||||
<template x-if="manga">
|
||||
<div class="uk-margin">
|
||||
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||
<p x-show="manga.length > 0">
|
||||
<span x-text="`${manga.length} manga found`"></span>
|
||||
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
|
||||
</p>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
|
||||
<template x-for="m in manga" :key="m.id">
|
||||
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top uk-inline">
|
||||
<img uk-img :data-src="m.cover_url">
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
|
||||
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="uk-margin-large-top" x-show="chapters !== undefined">
|
||||
<h3 x-text="mangaTitle"></h3>
|
||||
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
|
||||
|
||||
<div class="uk-margin">
|
||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
|
||||
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
|
||||
</div>
|
||||
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
|
||||
</div>
|
||||
|
||||
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
|
||||
<template x-for="field in filters">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">
|
||||
<span x-text="field.key"></span>
|
||||
<template x-if="field.type === 'number'">
|
||||
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
|
||||
</template>
|
||||
</label>
|
||||
|
||||
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min">
|
||||
</div>
|
||||
<div class="uk-width-1-2@s">
|
||||
<input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-max">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
|
||||
|
||||
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
|
||||
<option value="all">All</option>
|
||||
<template x-for="v in field.values" :key="v">
|
||||
<option x-text="v" :value="v"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
|
||||
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
|
||||
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
|
||||
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
|
||||
</form>
|
||||
|
||||
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
|
||||
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
|
||||
|
||||
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||
<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 class="uk-overflow-auto">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<template x-for="(k, idx) in chapterKeys" :key="k">
|
||||
<th :id="`th-${idx}`" @click="thClicked($event)">
|
||||
<span x-text="k"></span>
|
||||
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
|
||||
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
|
||||
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="selectable">
|
||||
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
|
||||
<template x-for="ch in chapters" :key="ch">
|
||||
<tr class="ui-widget-content" :id="ch.id">
|
||||
<template x-for="k in chapterKeys" :key="k">
|
||||
<td x-html="renderCell(ch[k])"></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="uk-description-list" id="toggle" hidden>
|
||||
<% plugin.not_nil!.info.each do |k, v| %>
|
||||
<dt><%= k %></dt>
|
||||
<dd><%= v.to_s %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
|
||||
<div id="table" class="uk-margin-large-top" hidden>
|
||||
<h3 id="title-text"></h3>
|
||||
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
<div uk-modal="container:false" x-ref="modal">
|
||||
<div class="uk-modal-dialog">
|
||||
<div class="uk-modal-header">
|
||||
<h2 class="uk-modal-title">Subscription Confirmation</h2>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<p>A subscription with the following filters with be created. All <strong>FUTURE</strong> chapters matching the filters will be automatically downloaded.</p>
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="ft in filterSettings" :key="ft">
|
||||
<tr x-html="renderFilterRow(ft)"></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Enter a meaningful name for the subscription to continue:</p>
|
||||
<input class="uk-input" type="text" x-model="subscriptionName">
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
|
||||
<button class="uk-button uk-button-primary" type="button" :disabled="subscriptionName.trim().length === 0" @click="subscribe($refs.modal)">Confirm</button>
|
||||
</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>
|
||||
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<% if plugin %>
|
||||
<script>
|
||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
||||
</script>
|
||||
<% end %>
|
||||
<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/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
||||
<%= render_component "jquery-ui" %>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
<div
|
||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||
<template x-for="item in items">
|
||||
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||
<img
|
||||
uk-img
|
||||
class="uk-align-center"
|
||||
:style="item.style"
|
||||
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||
:data-src="item.url"
|
||||
:width="item.width"
|
||||
:height="item.height"
|
||||
:id="item.id"
|
||||
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||
@click="showControl($event)"
|
||||
/>
|
||||
</template>
|
||||
<%- if next_entry_url -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
||||
<%- else -%>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
|
||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
@@ -50,10 +50,13 @@
|
||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||
margin-bottom:0;
|
||||
max-width:100%;
|
||||
max-height:100%;
|
||||
object-fit: contain;
|
||||
`" />
|
||||
|
||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
||||
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
|
||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false ^ enableRightToLeft)"></div>
|
||||
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true ^ enableRightToLeft)"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -68,18 +71,19 @@
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
<p id="progress-label"></p>
|
||||
<p x-text="`Progress: ${selectedIndex}/${items.length} (${(selectedIndex/items.length * 100).toFixed(1)}%)`"></p>
|
||||
</div>
|
||||
<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">
|
||||
<select id="page-select" class="uk-select" @change="pageChanged()">
|
||||
<select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
|
||||
<%- (1..entry.pages).each do |p| -%>
|
||||
<option value="<%= p %>"><%= p %></option>
|
||||
<%- end -%>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||
<div class="uk-form-controls">
|
||||
@@ -89,9 +93,59 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin" x-show="mode === 'continuous'">
|
||||
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||
<label class="uk-form-label" for="enable-right-to-left">Right to Left</label>
|
||||
<div class="uk-form-controls">
|
||||
<input id="enable-right-to-left" class="uk-checkbox" type="checkbox" x-model="enableRightToLeft" @change="enableRightToLeftChanged()">
|
||||
</div>
|
||||
</div>
|
||||
<hr class="uk-divider-icon">
|
||||
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="entry-select">Jump to Entry</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="entry-select" class="uk-select" @change="entryChanged()">
|
||||
<% entries.each do |e| %>
|
||||
<option value="<%= e.id %>"
|
||||
<% if e.id == entry.id %>
|
||||
selected
|
||||
<% end %>>
|
||||
<%= e.title %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-modal-footer uk-text-right">
|
||||
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||
<% if previous_entry_url %>
|
||||
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
||||
<% end %>
|
||||
<% if next_entry_url %>
|
||||
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
||||
<% end %>
|
||||
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,15 +157,14 @@
|
||||
const eid = "<%= entry.id %>";
|
||||
</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/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
<script src="<%= base_url %>js/reader.js"></script>
|
||||
</body>
|
||||
|
||||
<style>
|
||||
img[data-src][src*='data:image'] { background: white; }
|
||||
img { width: 100%; }
|
||||
img:not(.spine) { width: 100%; }
|
||||
.reader-bg { background: black; }
|
||||
</style>
|
||||
|
||||
|
||||
101
src/views/subscription-manager.html.ecr
Normal file
101
src/views/subscription-manager.html.ecr
Normal file
@@ -0,0 +1,101 @@
|
||||
<h2 class=uk-title>Subscription Manager</h2>
|
||||
<div x-data="component()" x-init="init()">
|
||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="plugins.length > 0" style="width:100%">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||
<template x-for="p in plugins" :key="p">
|
||||
<option :value="p.id" x-text="p.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p x-show="subscriptions.length === 0" class="uk-text-meta">No subscriptions found.</p>
|
||||
|
||||
<div class="uk-overflow-auto" x-show="subscriptions.length > 0">
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Plugin ID</th>
|
||||
<th>Manga Title</th>
|
||||
<th>Created At</th>
|
||||
<th>Last Checked</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="sub in subscriptions" :key="sub">
|
||||
<tr :sid="sub.id" @click="selected($event, $refs.modal)">
|
||||
<td x-html="renderStrCell(sub.name)"></td>
|
||||
<td x-html="renderStrCell(sub.plugin_id)"></td>
|
||||
<td x-html="renderStrCell(sub.manga_title)"></td>
|
||||
<td x-html="renderDateCell(sub.created_at)"></td>
|
||||
<td x-html="renderDateCell(sub.last_checked)"></td>
|
||||
<td>
|
||||
<a @click.prevent.stop="actionHandler($event, 'delete')" uk-icon="trash" uk-tooltip="Delete" :disabled="loading"></a>
|
||||
<a @click.prevent.stop="actionHandler($event, 'update')" uk-icon="refresh" uk-tooltip="Check for updates" :disabled="loading"></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div uk-modal="container:false" x-ref="modal" class="uk-flex-top">
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical uk-overflow-auto">
|
||||
<div class="uk-modal-header">
|
||||
<h2 class="uk-modal-title">Subscription Details</h2>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<dl>
|
||||
<dt>Name</dt>
|
||||
<dd x-html="subscription && subscription.name"></dd>
|
||||
<dt>Subscription ID</dt>
|
||||
<dd x-html="subscription && subscription.id"></dd>
|
||||
<dt>Plugin ID</dt>
|
||||
<dd x-html="subscription && subscription.plugin_id"></dd>
|
||||
<dt>Manga Title</dt>
|
||||
<dd x-html="subscription && subscription.manga_title"></dd>
|
||||
<dt>Manga ID</dt>
|
||||
<dd x-html="subscription && subscription.manga_id"></dd>
|
||||
<dt>Filters</dt>
|
||||
</dl>
|
||||
<table class="uk-table uk-table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="ft in (subscription && subscription.filters || [])" :key="ft">
|
||||
<tr x-html="renderFilterRow(ft)"></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="uk-text-right">
|
||||
<button class="uk-button uk-button-default uk-modal-close" type="button">OK</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/subscription-manager.js"></script>
|
||||
<% end %>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<script src="<%= base_url %>js/search.js"></script>
|
||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||
<% end %>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||
<h2 class=uk-title data-file-title="<%= HTML.escape(title.title) %>" data-sort-title="<%= HTML.escape(title.sort_title_db || "") %>">
|
||||
<span><%= title.display_name %></span>
|
||||
|
||||
<% if is_admin %>
|
||||
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||
@@ -59,8 +60,8 @@
|
||||
</div>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% title.titles.each_with_index do |item, i| %>
|
||||
<% progress = title_percentage[i] %>
|
||||
<% sorted_titles.each do |item| %>
|
||||
<% progress = title_percentage_map[item.id] %>
|
||||
<%= render_component "card" %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -89,6 +90,13 @@
|
||||
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="sort-title">Sort Title</label>
|
||||
<div class="uk-inline">
|
||||
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
||||
<input class="uk-input" type="text" name="sort-title" id="sort-title-field">
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label">Cover Image</label>
|
||||
<div class="uk-grid">
|
||||
@@ -123,9 +131,9 @@
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<%= render_component "dots-scripts" %>
|
||||
<%= render_component "dots" %>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="/css/tags.css" rel="stylesheet" />
|
||||
<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/title.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user