Compare commits

...

149 Commits

Author SHA1 Message Date
Alex Ling 8829d2e237 Merge pull request #173 from hkalexling/rc/0.21.0 2021-03-11 00:44:49 +08:00
Alex Ling eec6ec60bf Warn about old API url (#174) 2021-03-10 05:47:25 +00:00
Alex Ling 3a82effa40 Update config in README 2021-03-09 18:01:03 +00:00
Alex Ling cb4e4437a6 Update MD API URL (closes #174) 2021-03-09 16:43:46 +00:00
Alex Ling 2743868438 Remove outdated MD API link in warning 2021-03-06 17:03:48 +00:00
Alex Ling f62344806a Bump version to 0.21.0 2021-03-06 06:16:07 +00:00
Alex Ling b7b7e6f718 Fix typo [skip ci] 2021-03-05 17:04:23 +00:00
Alex Ling 05b4e77fa9 Entry selector on reader page (closes #168) 2021-03-05 17:02:45 +00:00
Alex Ling 8aab113aab Expiration date should be nil when theres no token 2021-03-05 11:01:00 +00:00
Alex Ling 371c8056e7 Wording 2021-03-05 10:57:23 +00:00
Alex Ling a9a2c9faa8 Finish search for MD 2021-03-05 04:58:56 +00:00
Alex Ling 011768ed1f Rename the dots-scripts component to dots 2021-03-05 04:58:56 +00:00
Alex Ling c36d2608e8 Make uk-card adaptive to dark/light mode 2021-03-05 04:58:56 +00:00
Alex Ling 1b25a1fa47 Update Koa 2021-03-05 04:58:56 +00:00
Alex Ling df7e2270a4 Add MangaDex login page 2021-03-05 04:58:56 +00:00
Alex Ling 3c3549a489 Merge pull request #172 from hkalexling/hotfix/bind-localhost 2021-03-04 13:47:59 +08:00
Alex Ling 8160b0a18e Bump version to 0.20.2 2021-03-04 04:49:37 +00:00
Alex Ling a7eff772be Update example config in README 2021-03-04 04:48:51 +00:00
Alex Ling bf3900f9a2 Add host to config 2021-03-03 17:35:39 +00:00
Alex Ling 6fa575cf4f Bind localhost when a proxy auth header is set 2021-03-03 16:28:31 +00:00
Alex Ling 604c5d49a6 Merge pull request #166 from hkalexling/dev 2021-02-28 19:38:02 +08:00
Alex Ling 7449d19075 Bump version to 0.20.1 2021-02-26 10:35:34 +00:00
Alex Ling c5c9305a0b Merge pull request #162 from hkalexling/all-contributors/add-davidkna
docs: add davidkna as a contributor
2021-02-14 23:30:02 +08:00
allcontributors[bot] fdceab9060 docs: update .all-contributorsrc [skip ci] 2021-02-14 15:28:33 +00:00
allcontributors[bot] c18591c5cf docs: update README.md [skip ci] 2021-02-14 15:28:32 +00:00
Alex Ling bb5cb9b94c Merge pull request #161 from davidkna/docker-usr-local
Move binary in docker image to /usr/local
2021-02-14 23:26:38 +08:00
David Knaack fb499a5caf Move binary in docker image to /usr/local 2021-02-14 11:42:00 +01:00
Alex Ling 154d85e197 Use only woff and woff2 2021-02-11 08:40:24 +00:00
Alex Ling 933617503e Optimize the static files
- Use webfont version of FontAwesome
- Use CDN for UIKit JS files
2021-02-10 16:24:34 +00:00
Alex Ling 31c6893bbb Display book spines in original size (fixes #152) 2021-02-06 13:37:25 +00:00
Alex Ling 171125e8ac Merge pull request #159 from Leeingnyo/fix/favicon-500-error
Fix HTTP 500 Error when accessing the favicon
2021-02-06 16:34:56 +08:00
Leeingnyo d81334026b add MIME type of ico file
The server returns 500 error when requested '/favion.ico'
The handler worked fine, but send_file has failed with
- Missing MIME type for extension ".ico"
so I register mime type for .ico file
2021-02-06 16:58:49 +09:00
Alex Ling 2b3b2eb8ba Fill default configs before pre-processing 2021-02-03 05:27:41 +00:00
Alex Ling ffd5f4454b Merge branch 'feature/auth-proxy' into dev 2021-02-03 05:23:00 +00:00
Alex Ling cb25d7ba00 Merge branch 'feature/mangadex-api-upgrade' into dev 2021-02-03 05:22:35 +00:00
Alex Ling 3abd2924d0 Merge pull request #156 from hkalexling/dev
v0.20.0
2021-02-02 12:16:05 +08:00
Alex Ling 21233df754 Fix group filter on the download page 2021-02-01 11:37:00 +00:00
Alex Ling c61eb7554e Update the mangadex shard 2021-02-01 11:35:16 +00:00
Alex Ling edd9a2e093 Add MutationObserver polyfill 2021-01-31 15:32:38 +00:00
Alex Ling 1f50785e8f Rewrite MangaDex download page with Alpine 2021-01-31 12:48:37 +00:00
Alex Ling 70d418d1a1 Upgrade to MangaDex API v2 2021-01-30 17:08:04 +00:00
Alex Ling 45e20c94f9 Merge branch 'dev' into feature/auth-proxy 2021-01-30 10:55:27 +00:00
Alex Ling ca8e9a164e Fix the /api page error when using base URL 2021-01-30 10:54:21 +00:00
Alex Ling 4da263c594 Rewrite auth_handler
Make sure the OPDS pages are accessible without login when login is
disabled
2021-01-30 10:54:03 +00:00
Alex Ling d67a24809b Allow proxy authentication (#141) 2021-01-30 07:43:02 +00:00
Alex Ling cd268af9dd Fix tags.css base URL 2021-01-30 07:39:54 +00:00
Alex Ling 135fa9fde6 Update sample config in README [skip ci] 2021-01-29 14:42:58 +00:00
Alex Ling 77333aaafd Bump version to 0.20.0 2021-01-29 10:27:31 +00:00
Alex Ling 1fad530331 Fix admin page theme setting syncing (#155) 2021-01-29 08:40:50 +00:00
Alex Ling a1bd87098c Escape single quotes in migration 8 2021-01-28 12:41:51 +00:00
Alex Ling a389fa7178 Allow delete all missing items (#151) 2021-01-28 09:55:41 +00:00
Alex Ling b5db508005 Fix relative path mismatch (#151) 2021-01-28 04:04:42 +00:00
Alex Ling 30178c42ef Merge branch 'master' into dev 2021-01-27 09:47:49 +00:00
Alex Ling b712db9e8f Merge pull request #154 from hkalexling/hkalexling-patch-1
Update autoapproval.yml
2021-01-27 16:28:05 +08:00
Alex Ling dd9c75d1c9 Update autoapproval.yml 2021-01-27 16:17:33 +08:00
Alex Ling 2d150c3bf2 Create autoapproval.yml 2021-01-27 16:14:47 +08:00
Alex Ling 40f74ea375 Merge pull request #153 from hkalexling/hotfix/reader-bg
Fix incorrect background color on reader page
2021-01-27 15:19:04 +08:00
Alex Ling adf260bc35 Bump version to v0.19.1 2021-01-27 06:33:45 +00:00
Alex Ling 432d6f0cd5 Run CI for hotfix/* branches 2021-01-27 06:33:45 +00:00
Alex Ling 3de314ae9a Fix incorrect background color on reader page 2021-01-27 06:33:45 +00:00
Alex Ling c1c8cca877 Use Ameba to enforce max line width
Didn't know Ameba supports this!
2021-01-27 04:18:47 +00:00
Alex Ling 07965b98b7 Force File::Info#inode to return UInt64 2021-01-27 03:42:51 +00:00
Alex Ling 5779d225f6 Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-27 03:23:08 +00:00
Alex Ling bf18a14016 Use inode number 2021-01-27 03:19:58 +00:00
Alex Ling 605dc61777 Merge pull request #150 from Leeingnyo/fix/allow-uppercase-extensions
Make file extension check case-insensitive
2021-01-26 19:16:12 +08:00
Alex Ling def64d9f98 Rename interesting files to supported files 2021-01-26 10:55:50 +00:00
Leeingnyo 0ba2409c9a add tests about is_interesting_file 2021-01-26 04:18:09 +09:00
Leeingnyo 2b0cf41336 add and apply util method is_interesting_file 2021-01-26 04:17:32 +09:00
Leeingnyo c51cb28df2 make filename extension downcase for comparing 2021-01-25 23:13:35 +09:00
Alex Ling 2b079c652d Fix duplicating options on the download page 2021-01-20 08:02:07 +00:00
Alex Ling 68050a9025 Fix incorrect dropdown color in dark mode 2021-01-20 05:20:03 +00:00
Alex Ling 54cd15d542 Mark items unavailable and retire DB optimization
This prepares us for the moving metadata to DB in the future
2021-01-19 15:09:38 +00:00
Alex Ling 781de97c68 Make thumbnail generation slower
This reduces the IO stress
2021-01-19 15:06:27 +00:00
Alex Ling c7be0e0e7c Separate insert_id into titles and entries 2021-01-19 09:08:31 +00:00
Alex Ling 667d390be4 Signature matching 2021-01-19 08:43:45 +00:00
Alex Ling 7f76322377 Merge branch 'dev' into feature/signature 2021-01-18 06:54:38 +00:00
Alex Ling 377c4c6554 Stop the process when the server fails to start 2021-01-18 06:44:10 +00:00
Alex Ling 952aa0c6ca Fix linter 2021-01-17 15:59:42 +00:00
Alex Ling bd81c2e005 Fix incorrect migration SQL 2021-01-17 15:58:13 +00:00
Alex Ling b471ed2fa0 Upgrade MG 2021-01-17 15:49:10 +00:00
Alex Ling 7507ab64ad Bump version to v0.19.0 2021-01-17 08:34:35 +00:00
Alex Ling e4587d36bc Fix linter 2021-01-17 08:25:01 +00:00
Alex Ling 7d6d3640ad Disable the tagging UI for non-admin users 2021-01-17 08:16:40 +00:00
Alex Ling 3071d44e32 Fix admin API bypassing 2021-01-17 08:10:43 +00:00
Alex Ling 7a09c9006a Set up foreign keys 2021-01-17 04:47:06 +00:00
Alex Ling 959560c7a7 Add titles and move insert_ids to class variable
This fixes the bug where the new ids are not saved
2021-01-17 04:45:55 +00:00
Alex Ling ff679b30d8 Capitalize the UNIQUE keyword 2021-01-17 04:41:05 +00:00
Alex Ling f7a360c2d8 Proper DB migration 2021-01-16 17:11:57 +00:00
Alex Ling 1065b430e3 Rewrite tagging UI with suggestions (#146) 2021-01-14 13:08:50 +00:00
Alex Ling 5abf7032a5 Use less 2021-01-14 13:04:57 +00:00
Alex Ling 18e8e88c66 Initial work on title signature 2021-01-14 08:23:39 +00:00
Alex Ling 44336c546a Bump version to v0.18.3 2021-01-12 10:14:12 +00:00
Alex Ling a4c6e6611c Try WSS first, and fallback to WS (#144) 2021-01-12 10:13:06 +00:00
Alex Ling 0b457a2797 Merge branch 'master' of https://github.com/hkalexling/Mango 2021-01-11 15:37:34 +00:00
Alex Ling 653751bede Merge branch 'dev' 2021-01-11 15:37:06 +00:00
Alex Ling a02bf4a81e Bump version to v0.18.2 2021-01-11 15:22:51 +00:00
Alex Ling 5271d12f4c Respect base URL in WS connections 2021-01-11 15:05:58 +00:00
Alex Ling c2e2f0b9b3 Merge pull request #143 from hkalexling/all-contributors/add-h45h74x
docs: add h45h74x as a contributor
2021-01-11 19:32:47 +08:00
allcontributors[bot] 72d319902e docs: update .all-contributorsrc [skip ci] 2021-01-11 11:31:21 +00:00
allcontributors[bot] bbd0fd68cb docs: update README.md [skip ci] 2021-01-11 11:31:20 +00:00
Alex Ling 0fb1e1598d Remove sourcerer.io HoF and use all-contributors
[skip ci]
RIP sourcerer.io https://github.com/sourcerer-io/sourcerer-app/issues/632
2021-01-11 11:28:30 +00:00
Alex Ling 4645582f5d Bump version to v0.18.1 2021-01-11 05:29:28 +00:00
Alex Ling ac9c51dd33 Remove non-existing #root from css selectors (#142) 2021-01-11 05:28:44 +00:00
Alex Ling f51d27860a Validate input index before flipping page 2021-01-09 15:49:34 +00:00
Alex Ling 4a7439a1ea Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-09 06:40:49 +00:00
Alex Ling 00e19399d7 Check login is disabled before accessing default username 2021-01-09 06:35:26 +00:00
Alex Ling cb723acef7 Update config in README 2021-01-09 06:35:11 +00:00
Alex Ling 794bed12bd Merge pull request #139 from h45h74x/feature/plugin-helper-function-post
Added post helper function
2021-01-09 14:30:52 +08:00
Simon bae8220e75 Added post helper function 2021-01-08 21:17:58 +01:00
Alex Ling 0cc5e1626b Fix broken buttons on download manager page 2021-01-08 11:38:51 +00:00
Alex Ling da0ca665a6 Mark entry as read when exiting reader at the end 2021-01-08 11:38:25 +00:00
Alex Ling a91cf21aa9 Bump version to v0.18.0 2021-01-07 16:27:22 +00:00
Alex Ling 39b2636711 Sort tags in title 2021-01-07 16:21:23 +00:00
Alex Ling 2618d8412b Update the API doc to include margin in dimensions 2021-01-07 16:06:43 +00:00
Alex Ling 445ebdf357 Merge pull request #136 from h45h74x/feature/adjustable-page-gaps
Feature/adjustable page gaps
2021-01-07 01:11:34 +08:00
Simon 60134dc364 Formatting 2021-01-06 17:44:02 +01:00
Simon aa70752244 Moved margin value to the dimensions API 2021-01-06 17:30:55 +01:00
Simon 0f39535097 Added new entry in example config 2021-01-06 15:28:09 +01:00
Simon e086bec9da Added adjustable page gaps via config 2021-01-06 15:27:48 +01:00
Alex Ling dcdcf29114 Sort tags on the tags page 2021-01-05 07:34:31 +00:00
Alex Ling c5c73ddff3 Rewrite download-manager.js 2021-01-01 09:19:16 +00:00
Alex Ling f18ee4284f Rewrite admin.js with Alpine component 2021-01-01 09:04:53 +00:00
Alex Ling 0fbc11386e Fix broken "Exit Reader" button 2021-01-01 09:04:18 +00:00
Alex Ling a68282b4bf Rewrite reader.js with a reusable alpine function 2020-12-31 16:21:00 +00:00
Alex Ling e64908ad06 Remove the outdated styleModal call 2020-12-31 14:08:14 +00:00
Alex Ling af0913df64 Dynamic HTML title 2020-12-31 14:08:14 +00:00
Alex Ling 5685dd1cc5 Use tallboy to draw CLI table 2020-12-30 16:44:23 +00:00
Alex Ling af2fd2a66a Remove the Context and Router classes 2020-12-30 15:58:51 +00:00
Alex Ling db2a51a26b Clean up library classes 2020-12-30 15:23:38 +00:00
Alex Ling cf930418cb Update rename spec 2020-12-30 12:53:48 +00:00
Alex Ling 911848ad11 Merge branch 'feature/tagging' into dev 2020-12-30 11:15:44 +00:00
Alex Ling 93f745aecb Only admins can add or delete tags 2020-12-30 11:13:43 +00:00
Alex Ling 981a1f0226 Add /tags to nav bar 2020-12-30 11:13:43 +00:00
Alex Ling 8188456788 Finish tagging 2020-12-30 11:13:43 +00:00
Alex Ling 1eace2c64c Add the /tags/:tag page 2020-12-30 11:13:43 +00:00
Alex Ling c6ee5409f8 Trim input tag 2020-12-30 11:13:43 +00:00
Alex Ling b05ed57762 Add API endpoints for tags 2020-12-30 11:13:43 +00:00
Alex Ling 0f1d1099f6 Add unique constraint to tags and error handling 2020-12-30 11:13:43 +00:00
Alex Ling 40a24f4247 Add tags to the web UI 2020-12-30 11:13:43 +00:00
Alex Ling a6862e86d4 Update alpine 2020-12-30 11:13:43 +00:00
Alex Ling bfc1b697bd Add tag related methods for Title 2020-12-30 11:13:43 +00:00
Alex Ling 276f62cb76 Update DB for tags 2020-12-30 11:13:43 +00:00
Alex Ling 45a81ad5f6 Display the entries and sub-titles count 2020-12-30 11:13:43 +00:00
Alex Ling ce88acb9e5 Simplify the request_path_startswith helper method 2020-12-30 11:13:43 +00:00
Alex Ling bd34b803f1 Tokens take precedence over default user setting 2020-12-30 11:13:43 +00:00
Alex Ling 2559f65f35 Display the entries and sub-titles count 2020-12-29 04:33:55 +00:00
Alex Ling 93c21ea659 Simplify the request_path_startswith helper method 2020-12-28 16:29:29 +00:00
Alex Ling 85ad38c321 Allow disable login 2020-12-28 16:13:51 +00:00
Alex Ling b6a204f5bd Escape illegal filename characters in Windows 2020-12-28 15:20:09 +00:00
86 changed files with 3602 additions and 1922 deletions
+111
View File
@@ -0,0 +1,111 @@
{
"projectName": "Mango",
"projectOwner": "hkalexling",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "none",
"contributors": [
{
"login": "hkalexling",
"name": "Alex Ling",
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
"profile": "https://github.com/hkalexling/",
"contributions": [
"code",
"doc",
"infra"
]
},
{
"login": "jaredlt",
"name": "jaredlt",
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
"profile": "https://github.com/jaredlt",
"contributions": [
"code",
"ideas",
"design"
]
},
{
"login": "shincurry",
"name": "ココロ",
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
"profile": "https://windisco.com/",
"contributions": [
"infra"
]
},
{
"login": "noirscape",
"name": "Valentijn",
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
"profile": "https://catgirlsin.space/",
"contributions": [
"infra"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc",
"ideas"
]
},
{
"login": "XavierSchiller",
"name": "Xavier",
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
"profile": "https://github.com/XavierSchiller",
"contributions": [
"infra"
]
},
{
"login": "WROIATE",
"name": "Jarao",
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
"profile": "https://github.com/WROIATE",
"contributions": [
"infra"
]
},
{
"login": "Leeingnyo",
"name": "이인용",
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
"profile": "https://github.com/Leeingnyo",
"contributions": [
"code"
]
},
{
"login": "h45h74x",
"name": "Simon",
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
"profile": "http://h45h74x.eu.org",
"contributions": [
"code"
]
},
{
"login": "davidkna",
"name": "David Knaack",
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
"profile": "https://github.com/davidkna",
"contributions": [
"infra"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}
+5
View File
@@ -7,3 +7,8 @@ Lint/UnusedArgument:
- src/routes/* - src/routes/*
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Enabled: false Enabled: false
Layout/LineLength:
Enabled: true
MaxLength: 80
Excluded:
- src/routes/api.cr
+6
View File
@@ -0,0 +1,6 @@
from_owner:
- hkalexling
required_labels:
- autoapprove
apply_labels:
- autoapproved
+1 -1
View File
@@ -2,7 +2,7 @@ name: Build
on: on:
push: push:
branches: [ master, dev ] branches: [ master, dev, hotfix/* ]
pull_request: pull_request:
branches: [ master, dev ] branches: [ master, dev ]
+2
View File
@@ -12,3 +12,5 @@ mango
public/css/uikit.css public/css/uikit.css
public/img/*.svg public/img/*.svg
public/js/*.min.js public/js/*.min.js
public/css/*.css
public/webfonts
+2 -2
View File
@@ -10,6 +10,6 @@ FROM library/alpine
WORKDIR / WORKDIR /
COPY --from=builder /Mango/mango . COPY --from=builder /Mango/mango /usr/local/bin/mango
CMD ["./mango"] CMD ["/usr/local/bin/mango"]
+3 -2
View File
@@ -9,6 +9,7 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
COPY mango-arm32v7.o . COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["/usr/local/bin/mango"]
CMD ["./mango"]
+2 -2
View File
@@ -9,6 +9,6 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
COPY mango-arm64v8.o . COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"] CMD ["/usr/local/bin/mango"]
-1
View File
@@ -29,7 +29,6 @@ test:
check: check:
crystal tool format --check crystal tool format --check
./bin/ameba ./bin/ameba
./dev/linewidth.sh
arm32v7: arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7 crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
+32 -5
View File
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.17.1 Mango - Manga Server and Web Reader. Version 0.21.0
Usage: Usage:
@@ -75,6 +75,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
host: 0.0.0.0
port: 9000 port: 9000
base_url: / base_url: /
session_secret: mango-session-secret session_secret: mango-session-secret
@@ -82,23 +83,27 @@ library_path: ~/mango/library
db_path: ~/mango/mango.db db_path: ~/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24 thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins plugin_path: ~/mango/plugins
download_timeout_seconds: 30 download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
auth_proxy_header_name: ""
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://api.mangadex.org/v2
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
download_queue_db_path: /home/alex_ling/mango/queue.db download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}' chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}' manga_rename_rule: '{title}'
``` ```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
### Library Structure ### Library Structure
@@ -153,5 +158,27 @@ Mobile UI:
## Contributors ## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions. Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
</table>
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7) <!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \
|| exit 0
+20 -16
View File
@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css'); const minifyCss = require('gulp-minify-css');
const less = require('gulp-less'); const less = require('gulp-less');
// Copy libraries from node_moduels to public/js gulp.task('copy-img', () => {
gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg') return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img')); .pipe(gulp.dest('public/img'));
}); });
gulp.task('copy-font', () => {
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.pipe(gulp.dest('public/webfonts'));
});
// Copy files from node_modules
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less // Compile less
gulp.task('less', () => { gulp.task('less', () => {
return gulp.src('public/css/*.less') return gulp.src([
'public/css/mango.less',
'public/css/tags.less'
])
.pipe(less()) .pipe(less())
.pipe(gulp.dest('public/css')); .pipe(gulp.dest('public/css'));
}); });
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
// Copy static files (includeing images) to dist // Copy static files (includeing images) to dist
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], { return gulp.src([
'public/*.*',
'public/img/*',
'public/webfonts/*',
'public/js/*.min.js'
], {
base: 'public' base: 'public'
}) })
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
// Set up the public folder for development // Set up the public folder for development
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less')); gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
// Set up the dist folder for deployment // Set up the dist folder for deployment
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files')); gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
+85
View File
@@ -0,0 +1,85 @@
class ForeignKeys < MG::Base
def up : String
<<-SQL
-- add foreign key to tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag),
FOREIGN KEY (id) REFERENCES titles (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
-- add foreign key to thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
FOREIGN KEY (id) REFERENCES ids (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
-- remove foreign key from thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
-- remove foreign key from tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
SQL
end
end
+19
View File
@@ -0,0 +1,19 @@
class CreateIds < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
is_title INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
DROP TABLE ids;
SQL
end
end
+50
View 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
View 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
View 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
View 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
+19
View File
@@ -0,0 +1,19 @@
class CreateTags < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
SQL
end
def down : String
<<-SQL
DROP TABLE tags;
SQL
end
end
+20
View File
@@ -0,0 +1,20 @@
class CreateThumbnails < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
DROP TABLE thumbnails;
SQL
end
end
+56
View File
@@ -0,0 +1,56 @@
class CreateTitles < MG::Base
def up : String
<<-SQL
-- create titles
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT
);
CREATE UNIQUE INDEX titles_id_idx on titles (id);
CREATE UNIQUE INDEX titles_path_idx on titles (path);
-- migrate data from ids to titles
INSERT INTO titles
SELECT id, path, null
FROM ids
WHERE is_title = 1;
DELETE FROM ids
WHERE is_title = 1;
-- remove the is_title column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL
);
INSERT INTO ids
SELECT path, id
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
-- insert the is_title column
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
-- migrate data from titles to ids
INSERT INTO ids
SELECT path, id, 1
FROM titles;
-- remove titles
DROP TABLE titles;
SQL
end
end
+94
View 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
+20
View File
@@ -0,0 +1,20 @@
class CreateUsers < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL,
password TEXT NOT NULL,
token TEXT,
admin INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
SQL
end
def down : String
<<-SQL
DROP TABLE users;
SQL
end
end
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0", "gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
-154
View File
@@ -1,154 +0,0 @@
.uk-alert-close {
color: black !important;
}
.uk-card-body {
padding: 20px;
}
.uk-card-media-top {
width: 100%;
height: 250px;
}
@media (min-width: 600px) {
.uk-card-media-top {
height: 300px;
}
}
.uk-card-media-top>img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title {
max-height: 3em;
}
.acard:hover {
cursor: pointer;
}
.uk-list li:not(.nopointer) {
cursor: pointer;
}
#scan-status {
cursor: auto;
}
.reader-bg {
background-color: black;
}
.break-word {
word-wrap: break-word;
}
.uk-logo>img {
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;
}
#selectable .ui-selecting {
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
}
.uk-light #selectable .ui-selecting {
background: #5E5731;
}
.uk-light #selectable .ui-selected {
background: #9D9252;
}
td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
+139
View File
@@ -0,0 +1,139 @@
// UIKit
@import "./uikit.less";
// FontAwesome
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
@font-face {
src: url('@{fa-font-path}/fa-solid-900.woff2');
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
}
// Item cards
.item .uk-card {
cursor: pointer;
.uk-card-media-top {
width: 100%;
height: 250px;
@media (min-width: 600px) {
height: 300px;
}
img {
height: 100%;
width: 100%;
object-fit: cover;
&.grayscale {
filter: grayscale(100%);
}
}
}
.uk-card-body {
padding: 20px;
.uk-card-title {
font-size: 1rem;
}
.uk-card-title:not(.free-height) {
max-height: 3em;
}
}
}
// jQuery selectable
#selectable {
.ui-selecting {
background: #EEE6B9;
}
.ui-selected {
background: #F4E487;
}
.uk-light & {
.ui-selecting {
background: #5E5731;
}
.ui-selected {
background: #9D9252;
}
}
}
// Edit modal
#edit-modal {
.uk-grid > div {
height: 300px;
}
#cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#cover-upload {
height: 100%;
box-sizing: border-box;
}
.uk-modal-body .uk-inline {
width: 100%;
}
}
// Dark theme
.uk-light {
.uk-modal-header,
.uk-modal-body,
.uk-modal-footer {
background: #222;
}
.uk-navbar-dropdown,
.uk-dropdown {
color: #ccc;
background: #333;
}
.uk-nav-header,
.uk-description-list > dt {
color: #555;
}
}
// Alpine magic
[x-cloak] {
display: none;
}
// Batch select bar on title page
#select-bar-controls {
a {
transform: scale(1.5, 1.5);
&:hover {
color: orange;
}
}
}
// Totop button
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
}
// Misc
.uk-alert-close {
color: black !important;
}
.break-word {
word-wrap: break-word;
}
.uk-search {
width: 100%;
}
+58
View File
@@ -0,0 +1,58 @@
@light-gray: #e5e5e5;
@gray: #666666;
@black: #141414;
@blue: rgb(30, 135, 240);
@white1: rgba(255, 255, 255, .1);
@white2: rgba(255, 255, 255, .2);
@white7: rgba(255, 255, 255, .7);
.select2-container--default {
.select2-selection--multiple {
border: 1px solid @light-gray;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: @blue;
color: white;
border: none;
border-radius: 2px;
}
}
.select2-dropdown {
.select2-results__option--highlighted.select2-results__option--selectable {
background-color: @blue;
}
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @light-gray
}
}
}
.uk-light {
.select2-container--default {
.select2-selection {
background-color: @white1;
}
.select2-selection--multiple {
border: 1px solid @white2;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: white;
color: @gray;
border: none;
}
.select2-search__field {
color: @white7;
}
}
}
.select2-dropdown {
background-color: @black;
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @white2;
}
}
}
+19
View File
@@ -43,3 +43,22 @@
@internal-list-bullet-image: "../img/list-bullet.svg"; @internal-list-bullet-image: "../img/list-bullet.svg";
@internal-accordion-open-image: "../img/accordion-open.svg"; @internal-accordion-open-image: "../img/accordion-open.svg";
@internal-accordion-close-image: "../img/accordion-close.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;
}
}
+52 -65
View File
@@ -1,68 +1,55 @@
$(() => { const component = () => {
const setting = loadThemeSetting(); return {
$('#theme-select').val(capitalize(setting)); progress: 1.0,
$('#theme-select').change((e) => { generating: false,
const newSetting = $(e.currentTarget).val().toLowerCase(); scanning: false,
saveThemeSetting(newSetting); scanTitles: 0,
setTheme(); scanMs: -1,
}); themeSetting: '',
getProgress(); init() {
setInterval(getProgress, 5000); this.getProgress();
}); setInterval(() => {
this.getProgress();
}, 5000);
/** const setting = loadThemeSetting();
* Capitalize String this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
* },
* @function capitalize themeChanged(event) {
* @param {string} str - The string to be capitalized const newSetting = $(event.currentTarget).val().toLowerCase();
* @return {string} The capitalized string saveThemeSetting(newSetting);
*/ setTheme();
const capitalize = (str) => { },
return str.charAt(0).toUpperCase() + str.slice(1); scan() {
if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
}; };
/**
* Get the thumbnail generation progress from the API
*
* @function getProgress
*/
const getProgress = () => {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
setProp('progress', data.progress);
const generating = data.progress > 0
setProp('generating', generating);
});
};
/**
* Trigger the thumbnail generation
*
* @function generateThumbnails
*/
const generateThumbnails = () => {
setProp('generating', true);
setProp('progress', 0.0);
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(getProgress);
};
/**
* Trigger the scan
*
* @function scan
*/
const scan = () => {
setProp('scanning', true);
setProp('scanMs', -1);
setProp('scanTitles', 0);
$.post(`${base_url}api/admin/scan`)
.then(data => {
setProp('scanMs', data.milliseconds);
setProp('scanTitles', data.titles);
})
.always(() => {
setProp('scanning', false);
});
}
-4
View File
@@ -117,14 +117,10 @@ const setTheme = (theme) => {
if (theme === 'dark') { if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)'); $('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light'); $('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark'); $('.ui-widget-content').addClass('dark');
} else { } else {
$('html').css('background', ''); $('html').css('background', '');
$('body').removeClass('uk-light'); $('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark'); $('.ui-widget-content').removeClass('dark');
} }
}; };
+113 -121
View File
@@ -1,124 +1,116 @@
/** const component = () => {
* Get the current queue and update the view return {
* jobs: [],
* @function load paused: undefined,
*/ loading: false,
const load = () => { toggling: false,
try { ws: undefined,
setProp('loading', true);
} catch {} wsConnect(secure = true) {
$.ajax({ const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
type: 'GET', console.log(`Connecting to ${url}`);
url: base_url + 'api/admin/mangadex/queue', this.ws = new WebSocket(url);
dataType: 'json' this.ws.onmessage = event => {
}) const data = JSON.parse(event.data);
.done(data => { this.jobs = data.jobs;
if (!data.success && data.error) { this.paused = data.paused;
alert('danger', `Failed to fetch download queue. Error: ${data.error}`); };
return; this.ws.onclose = () => {
if (this.ws.failed)
return this.wsConnect(false);
alert('danger', 'Socket connection closed');
};
this.ws.onerror = () => {
if (secure)
return this.ws.failed = true;
alert('danger', 'Socket connection failed');
};
},
init() {
this.wsConnect();
this.load();
},
load() {
this.loading = true;
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
this.jobs = data.jobs;
this.paused = data.paused;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-')[1];
url = `${url}?${$.param({
id: id
})}`;
} }
setProp('jobs', data.jobs); console.log(url);
setProp('paused', data.paused); $.ajax({
}) type: 'POST',
.fail((jqXHR, status) => { url: url,
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); dataType: 'json'
}) })
.always(() => { .done(data => {
setProp('loading', false); if (!data.success && data.error) {
}); alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
}; return;
}
/** this.load();
* Perform an action on either a specific job or the entire queue })
* .fail((jqXHR, status) => {
* @function jobAction alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
* @param {string} action - The action to perform. Should be either 'delete' or 'retry' });
* @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue },
*/ toggle() {
const jobAction = (action, id) => { this.toggling = true;
let url = `${base_url}api/admin/mangadex/queue/${action}`; const action = this.paused ? 'resume' : 'pause';
if (id !== undefined) const url = `${base_url}api/admin/mangadex/queue/${action}`;
url += '?' + $.param({ $.ajax({
id: id type: 'POST',
}); url: url,
console.log(url); dataType: 'json'
$.ajax({ })
type: 'POST', .fail((jqXHR, status) => {
url: url, alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
dataType: 'json' })
}) .always(() => {
.done(data => { this.load();
if (!data.success && data.error) { this.toggling = false;
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`); });
return; },
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
} }
load(); return cls;
}) }
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
/**
* Pause/resume the download
*
* @function toggle
*/
const toggle = () => {
setProp('toggling', true);
const action = getProp('paused') ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
load();
setProp('toggling', false);
});
};
/**
* Get the uk-label class name for a given job status
*
* @function statusClass
* @param {string} status - The job status
* @return {string} The class name string
*/
const statusClass = status => {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
};
$(() => {
const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`);
ws.onmessage = event => {
const data = JSON.parse(event.data);
setProp('jobs', data.jobs);
setProp('paused', data.paused);
}; };
ws.onerror = err => { };
alert('danger', `Socket connection failed. Error: ${err}`);
};
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
});
+271 -289
View File
@@ -1,305 +1,287 @@
$(() => { const downloadComponent = () => {
$('#search-input').keypress(event => { return {
if (event.which === 13) { chaptersLimit: 1000,
search(); loading: false,
} addingToDownload: false,
}); searchAvailable: false,
$('.filter-field').each((i, ele) => { searchInput: '',
$(ele).change(() => { data: {},
buildTable(); chapters: [],
}); mangaAry: undefined, // undefined: not searching; []: searched but no result
}); candidateManga: {},
}); langChoice: 'All',
const selectAll = () => { groupChoice: 'All',
$('tbody > tr').each((i, e) => { chapterRange: '',
$(e).addClass('ui-selected'); volumeRange: '',
});
}; get languages() {
const unselect = () => { const set = new Set();
$('tbody > tr').each((i, e) => { if (this.data.chapters) {
$(e).removeClass('ui-selected'); this.data.chapters.forEach(chp => {
}); set.add(chp.language);
}; });
const download = () => { }
const selected = $('tbody > tr.ui-selected'); const ary = [...set].sort();
if (selected.length === 0) return; ary.unshift('All');
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => { return ary;
$('#download-btn').attr('hidden', ''); },
$('#download-spinner').removeAttr('hidden');
const ids = selected.map((i, e) => { get groups() {
return $(e).find('td').first().text(); const set = new Set();
}).get(); if (this.data.chapters) {
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0); this.data.chapters.forEach(chp => {
console.log(ids); Object.keys(chp.groups).forEach(g => {
$.ajax({ set.add(g);
type: 'POST', });
url: base_url + 'api/admin/mangadex/download', });
data: JSON.stringify({ }
chapters: chapters const ary = [...set].sort();
}), ary.unshift('All');
contentType: "application/json", return ary;
dataType: 'json' },
})
.done(data => { init() {
console.log(data); const tableObserver = new MutationObserver(() => {
if (data.error) { console.log('table mutated');
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`); $("#selectable").selectable({
filter: 'tr'
});
});
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true
});
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
this.searchAvailable = true;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
filtersUpdated() {
if (!this.data.chapters)
this.chapters = [];
const filters = {
chapter: this.parseRange(this.chapterRange),
volume: this.parseRange(this.volumeRange),
lang: this.langChoice,
group: this.groupChoice
};
console.log('filters:', filters);
let _chapters = this.data.chapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
_chapters = _chapters.filter(c => {
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return; return;
} }
const successCount = parseInt(data.success); if (k === 'lang') {
const failCount = parseInt(data.fail); _chapters = _chapters.filter(c => c.language === v);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { return;
window.location.href = base_url + 'admin/downloads'; }
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;
}); });
})
.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');
}); });
}); console.log('filtered chapters:', _chapters);
}; this.chapters = _chapters;
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 === "") { search() {
toggleSpinner(); if (this.loading || this.searchInput === '') return;
return; this.data = {};
} this.mangaAry = undefined;
var int_id = -1; var int_id = -1;
try {
try { const path = new URL(this.searchInput).pathname;
const path = new URL(input).pathname; const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path); int_id = parseInt(match[1]);
int_id = parseInt(match[1]); } catch (e) {
} catch (e) { int_id = parseInt(this.searchInput);
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; if (!isNaN(int_id) && int_id > 0) {
$('#cover').attr("src", cover); // The input is a positive integer. We treat it as an ID.
$('#title').text("Title: " + data.title); this.loading = true;
$('#artist').text("Artist: " + data.artist); $.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
$('#author').text("Author: " + data.author); .done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
return;
}
$('#manga-details').removeAttr('hidden'); this.data = data;
this.chapters = data.chapters;
this.mangaAry = undefined;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
} else {
if (!this.searchAvailable) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
return;
}
console.log(data.chapters); // Search as a search term
globalChapters = data.chapters; this.loading = true;
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
query: this.searchInput
})}`)
.done((data) => {
if (data.error) {
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
return;
}
let langs = new Set(); this.mangaAry = data.manga;
let group_names = new Set(); this.data = {};
data.chapters.forEach(chp => { })
Object.entries(chp.groups).forEach(([k, v]) => { .fail((jqXHR, status) => {
group_names.add(k); alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); })
langs.add(chp.language); .always(() => {
this.loading = false;
});
}
},
parseRange(str) {
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
const matches = str.match(regex);
var num;
if (!matches) {
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
return [null, null];
}
switch (matches[1]) {
case '<':
return [null, num - 1];
case '<=':
return [null, num];
case '>':
return [num + 1, null];
case '>=':
return [num, null];
}
} else if (typeof matches[3] !== 'undefined') {
// a single number
num = parseInt(matches[3]);
if (isNaN(num)) {
return [null, null];
}
return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23
num = parseInt(matches[4]);
const n2 = parseInt(matches[5]);
if (isNaN(num) || isNaN(n2) || num > n2) {
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
},
unescapeHTML(str) {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
}); });
},
const comp = (a, b) => { clearSelection() {
var ai; $('tbody > tr').each((i, e) => {
var bi; $(e).removeClass('ui-selected');
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; },
download() {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
const ids = selected.map((i, e) => {
return parseInt($(e).find('td').first().text());
}).get();
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
this.addingToDownload = true;
$.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(() => {
this.addingToDownload = false;
});
});
},
chooseManga(manga) {
this.candidateManga = manga;
UIkit.modal($('#modal').get(0)).show();
},
confirmManga(id) {
UIkit.modal($('#modal').get(0)).hide();
this.searchInput = id;
this.search();
} }
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;
}; };
+61
View File
@@ -0,0 +1,61 @@
const component = () => {
return {
username: '',
password: '',
expires: undefined,
loading: true,
loggingIn: false,
init() {
this.loading = true;
$.ajax({
type: 'GET',
url: `${base_url}api/admin/mangadex/expires`,
contentType: "application/json",
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
return;
}
this.expires = data.expires;
this.loading = false;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
login() {
if (!(this.username && this.password)) return;
this.loggingIn = true;
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/login`,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
username: this.username,
password: this.password
})
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to log in. Error: ${data.error}`);
return;
}
this.expires = data.expires;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loggingIn = false;
});
},
get expired() {
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
}
};
};
+60
View 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}`);
});
}
};
};
+263 -274
View File
@@ -1,293 +1,282 @@
let lastSavedPage = page; const readerComponent = () => {
let items = []; return {
let longPages = false; loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null,
longPages: false,
lastSavedPage: page,
selectedIndex: 0, // 0: not selected; 1: the first page
$(() => { /**
getPages(); * Initialize the component by fetching the page dimensions
*/
init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
$('#page-select').change(() => { this.items = dimensions.map((d, i) => {
const p = parseInt($('#page-select').val()); return {
toPage(p); id: i + 1,
}); url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
};
});
$('#mode-select').change(() => { const avgRatio = this.items.reduce((acc, cur) => {
const mode = $('#mode-select').val(); return acc + cur.height / cur.width
const curIdx = parseInt($('#page-select').val()); }, 0) / this.items.length;
updateMode(mode, curIdx); console.log(avgRatio);
}); this.longPages = avgRatio > 2;
}); this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
$(window).resize(() => { // Here we save a copy of this.mode, and use the copy as
const mode = getProp('mode'); // the model-select value. This is because `updateMode`
if (mode === 'continuous') return; // might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
})
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
const wideScreen = $(window).width() > $(window).height(); this.updateMode(mode, curIdx, nextTick);
const propMode = wideScreen ? 'height' : 'width'; },
setProp('mode', propMode); /**
}); * Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
/** const wideScreen = $(window).width() > $(window).height();
* Update the reader mode this.mode = wideScreen ? 'height' : 'width';
* },
* @function updateMode /**
* @param {string} mode - The mode. Can be one of the followings: * Handles the window `keydown` event
* {'continuous', 'paged', 'height', 'width'} *
* @param {number} targetPage - The one-based index of the target page * @param {Event} event - The triggering event
*/ */
const updateMode = (mode, targetPage) => { keyHandler(event) {
localStorage.setItem('mode', mode); if (this.mode === 'continuous') return;
// The mode to be put into the `mode` prop. It can't be `screen` if (event.key === 'ArrowLeft' || event.key === 'k')
let propMode = mode; this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (mode === 'paged') { if (newIdx <= 0 || newIdx > this.items.length) return;
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
setProp('mode', propMode); this.toPage(newIdx);
if (mode === 'continuous') { if (isNext)
waitForPage(items.length, () => { this.flipAnimation = 'right';
setupScroller(); else
}); this.flipAnimation = 'left';
}
waitForPage(targetPage, () => { setTimeout(() => {
setTimeout(() => { this.flipAnimation = null;
toPage(targetPage); }, 500);
}, 100);
});
};
/** this.replaceHistory(newIdx);
* Get dimension of the pages in the entry from the API and update the view },
*/ /**
const getPages = () => { * Jumps to a specific page
$.get(`${base_url}api/dimensions/${tid}/${eid}`) *
.then(data => { * @param {number} idx - One-based index of the page
if (!data.success && data.error) */
throw new Error(resp.error); toPage(idx) {
const dimensions = data.dimensions; if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
items = dimensions.map((d, i) => { } else {
return { if (idx >= 1 && idx <= this.items.length) {
id: i + 1, this.curItem = this.items[idx - 1];
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, }
width: d.width,
height: d.height
};
});
const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
const storedMode = localStorage.getItem('mode') || 'continuous';
setProp('mode', storedMode);
updateMode(storedMode, page);
$('#mode-select').val(storedMode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
};
/**
* Check if a page exists every 100ms. If so, invoke the callback function.
*
* @function waitForPage
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {string} idx - One-based index of the current page
*/
const showControl = (idx) => {
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
}
/**
* Redirect to a URL
*
* @function redirect
* @param {string} url - The target URL
*/
const redirect = (url) => {
window.location.replace(url);
}
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const replaceHistory = (idx) => {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
} }
}); this.replaceHistory(idx);
}); UIkit.modal($('#modal-sections')).hide();
}; },
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
/** const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
* Update the backend reading progress if: $.ajax({
* 1) the current page is more than five pages away from the last method: 'PUT',
* saved page, or url: url,
* 2) the average height/width ratio of the pages is over 2, or dataType: 'json'
* 3) the current page is the first page, or })
* 4) the current page is the last page .done(data => {
* if (data.error)
* @function saveProgress alert('danger', data.error);
* @param {number} idx - One-based index of the page if (cb) cb();
* @param {function} cb - Callback })
*/ .fail((jqXHR, status) => {
const saveProgress = (idx, cb) => { alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
idx = parseInt(idx); });
if (Math.abs(idx - lastSavedPage) >= 5 || }
longPages || },
idx === 1 || idx === items.length /**
) { * Updates the reader mode
lastSavedPage = idx; *
console.log('saving progress', idx); * @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`; // The mode to be put into the `mode` prop. It can't be `screen`
$.ajax({ let propMode = mode;
method: 'PUT',
url: url, if (mode === 'paged') {
dataType: 'json' const wideScreen = $(window).width() > $(window).height();
}) propMode = wideScreen ? 'height' : 'width';
.done(data => { }
if (data.error)
alert('danger', data.error); this.mode = propMode;
if (cb) cb();
}) if (mode === 'continuous') {
.fail((jqXHR, status) => { nextTick(() => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
}); });
} },
}; /**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
/** this.curItem = this.items[current - 1];
* Mark progress to 100% and redirect to the next entry this.replaceHistory(current);
* Used as the onclick handler for the "Next Entry" button }
* });
* @function nextEntry });
* @param {string} nextUrl - URL of the next entry },
*/ /**
const nextEntry = (nextUrl) => { * Marks progress as 100% and jumps to the next entry
saveProgress(items.length, () => { *
redirect(nextUrl); * @param {string} nextUrl - URL of the next entry
}); */
}; nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
*/
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
},
/** /**
* Show the next or the previous page * Handles the `change` event for the entry selector
* */
* @function flipPage entryChanged() {
* @param {bool} isNext - Whether we are going to the next page const id = $('#entry-select').val();
*/ this.redirect(`${base_url}reader/${tid}/${id}`);
const flipPage = (isNext) => { }
const curItem = getProp('curItem'); };
const idx = parseInt(curItem.id); }
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
toPage(newIdx);
if (isNext)
setProp('flipAnimation', 'right');
else
setProp('flipAnimation', 'left');
setTimeout(() => {
setProp('flipAnimation', null);
}, 500);
replaceHistory(newIdx);
saveProgress(newIdx);
};
/**
* Handle the global keydown events
*
* @function keyHandler
* @param {event} event - The $event object
*/
const keyHandler = (event) => {
const mode = getProp('mode');
if (mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
flipPage(true);
};
+82
View File
@@ -252,3 +252,85 @@ const bulkProgress = (action, el) => {
deselectAll(); deselectAll();
}); });
}; };
const tagsComponent = () => {
return {
isAdmin: false,
tags: [],
tid: $('.upload-field').attr('data-title-id'),
loading: true,
load(admin) {
this.isAdmin = admin;
$('.tag-select').select2({
tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin,
templateSelection(state) {
const a = document.createElement('a');
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
a.setAttribute('class', 'uk-link-reset');
a.onclick = event => {
event.stopPropagation();
};
a.innerText = state.text;
return a;
}
});
this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', data => {
this.tags = data.tags;
allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op);
});
$('.tag-select').on('select2:select', e => {
this.onAdd(e);
});
$('.tag-select').on('select2:unselect', e => {
this.onDelete(e);
});
$('.tag-select').on('change', () => {
this.onChange();
});
$('.tag-select').trigger('change');
this.loading = false;
});
});
},
onChange() {
this.tags = $('.tag-select').select2('data').map(o => o.text);
},
onAdd(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT');
},
onDelete(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE');
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success) {
if (cb) cb(data);
} else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};
+13 -1
View File
@@ -50,7 +50,15 @@ shards:
koa: koa:
git: https://github.com/hkalexling/koa.git git: https://github.com/hkalexling/koa.git
version: 0.5.0 version: 0.7.0
mangadex:
git: https://github.com/hkalexling/mangadex.git
version: 0.8.0+git.commit.24e6fb51afd043721139355854e305b43bf98c43
mg:
git: https://github.com/hkalexling/mg.git
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
myhtml: myhtml:
git: https://github.com/kostya/myhtml.git git: https://github.com/kostya/myhtml.git
@@ -68,3 +76,7 @@ shards:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3
+7 -1
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.17.1 version: 0.21.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -39,3 +39,9 @@ dependencies:
github: hkalexling/image_size.cr github: hkalexling/image_size.cr
koa: koa:
github: hkalexling/koa github: hkalexling/koa
tallboy:
github: epoch/tallboy
mg:
github: hkalexling/mg
mangadex:
github: hkalexling/mangadex
+8 -8
View File
@@ -40,11 +40,6 @@ describe Rule do
rule.render({"a" => "a", "b" => "b"}).should eq "a" rule.render({"a" => "a", "b" => "b"}).should eq "a"
end end
it "allows `|` outside of patterns" do
rule = Rule.new "hello|world"
rule.render({} of String => String).should eq "hello|world"
end
it "raises on escaped characters" do it "raises on escaped characters" do
expect_raises Exception do expect_raises Exception do
Rule.new "hello/world" Rule.new "hello/world"
@@ -69,8 +64,13 @@ describe Rule do
rule.render({} of String => String).should eq "testing" rule.render({} of String => String).should eq "testing"
end end
it "escapes slash" do it "escapes illegal characters" do
rule = Rule.new "{id}" rule = Rule.new "{a}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world" rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
end
it "strips trailing spaces and dots" do
rule = Rule.new "hello. world. .."
rule.render({} of String => String).should eq "hello. world"
end end
end end
+17
View File
@@ -35,6 +35,23 @@ describe "compare_numerically" do
end end
end end
describe "is_supported_file" do
it "returns true when the filename has a supported extension" do
filename = "manga.cbz"
is_supported_file(filename).should eq true
end
it "returns true when the filename does not have a supported extension" do
filename = "info.json"
is_supported_file(filename).should eq false
end
it "is case insensitive" do
filename = "manga.ZiP"
is_supported_file(filename).should eq true
end
end
describe "chapter_sort" do describe "chapter_sort" do
it "sorts correctly" do it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"] ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
-41
View File
@@ -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
1 Arabic sa
2 Bengali bd
3 Bulgarian bg
4 Burmese mm
5 Catalan ct
6 Chinese (Simp) cn
7 Chinese (Trad) hk
8 Czech cz
9 Danish dk
10 Dutch nl
11 English gb
12 Filipino ph
13 Finnish fi
14 French fr
15 German de
16 Greek gr
17 Hebrew il
18 Hindi in
19 Hungarian hu
20 Indonesian id
21 Italian it
22 Japanese jp
23 Korean kr
24 Lithuanian lt
25 Malay my
26 Mongolian mn
27 Other
28 Persian ir
29 Polish pl
30 Portuguese (Br) br
31 Portuguese (Pt) pt
32 Romanian ro
33 Russian ru
34 Serbo-Croatian rs
35 Spanish (Es) es
36 Spanish (LATAM) mx
37 Swedish se
38 Thai th
39 Turkish tr
40 Ukrainian ua
41 Vietnames vn
+30 -3
View File
@@ -5,6 +5,7 @@ class Config
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
property path : String = "" property path : String = ""
property host : String = "0.0.0.0"
property port : Int32 = 9000 property port : Int32 = 9000
property base_url : String = "/" property base_url : String = "/"
property session_secret : String = "mango-session-secret" property session_secret : String = "mango-session-secret"
@@ -13,19 +14,22 @@ class Config
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
property scan_interval_minutes : Int32 = 5 property scan_interval_minutes : Int32 = 5
property thumbnail_generation_interval_hours : Int32 = 24 property thumbnail_generation_interval_hours : Int32 = 24
property db_optimization_interval_hours : Int32 = 24
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
property plugin_path : String = File.expand_path "~/mango/plugins", property plugin_path : String = File.expand_path "~/mango/plugins",
home: true home: true
property download_timeout_seconds : Int32 = 30 property download_timeout_seconds : Int32 = 30
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@mangadex_defaults = { @mangadex_defaults = {
"base_url" => "https://mangadex.org", "base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api", "api_url" => "https://api.mangadex.org/v2",
"download_wait_seconds" => 5, "download_wait_seconds" => 5,
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
@@ -49,9 +53,9 @@ class Config
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path config.path = path
config.fill_defaults config.fill_defaults
config.preprocess
return config return config
end end
puts "The config file #{cfg_path} does not exist. " \ puts "The config file #{cfg_path} does not exist. " \
@@ -85,5 +89,28 @@ class Config
unless base_url.ends_with? "/" unless base_url.ends_with? "/"
@base_url += "/" @base_url += "/"
end end
if disable_login && default_username.empty?
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
# `Logger.default` is not available yet
Log.setup :debug
unless mangadex["api_url"] =~ /\/v2/
Log.warn { "It looks like you are using the deprecated MangaDex API " \
"v1 in your config file. Please update it to " \
"https://api.mangadex.org/v2 to suppress this warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
if mangadex["api_url"] =~ /\/api\/v2/
Log.warn { "It looks like you are using the outdated MangaDex API " \
"url (mangadex.org/api/v2) in your config file. Please " \
"update it to https://api.mangadex.org/v2 to suppress this " \
"warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
end end
end end
+41 -31
View File
@@ -11,24 +11,25 @@ class AuthHandler < Kemal::Handler
"You have to login with proper credentials" "You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\"" HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def require_basic_auth(env) def require_basic_auth(env)
env.response.status_code = 401 env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE env.response.print AUTH_MESSAGE
call_next env end
def require_auth(env)
env.session.string "callback", env.request.path
redirect env, "/login"
end end
def validate_token(env) def validate_token(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_token token !token.nil? && Storage.default.verify_token token
end end
def validate_token_admin(env) def validate_token_admin(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_admin token !token.nil? && Storage.default.verify_admin token
end end
def validate_auth_header(env) def validate_auth_header(env)
@@ -49,44 +50,53 @@ class AuthHandler < Kemal::Handler
def verify_user(value) def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1]) username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":") .split(":")
@storage.verify_user username, password Storage.default.verify_user username, password
end end
def handle_opds_auth(env) def call(env)
if validate_token(env) || validate_auth_header(env) # Skip all authentication if requesting /login, /logout, or a static file
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
if request_path_startswith(env, ["/login", "/logout"]) || if request_path_startswith(env, ["/login", "/logout"]) ||
requesting_static_file env requesting_static_file env
return call_next(env) return call_next(env)
end end
unless validate_token env # Check user is logged in
env.session.string "callback", env.request.path if validate_token env
return redirect env, "/login" # Skip if the request has a valid token
elsif Config.current.disable_login
# Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username
Logger.warn "Default username #{Config.current.default_username} " \
"does not exist"
return require_auth env
end
elsif !Config.current.auth_proxy_header_name.empty?
# Check auth proxy if present
username = env.request.headers[Config.current.auth_proxy_header_name]?
unless username && Storage.default.username_exists username
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
"or is not a valid username"
return require_auth env
end
elsif request_path_startswith env, ["/opds"]
# Check auth header if requesting an opds page
unless validate_auth_header env
return require_basic_auth env
end
else
return require_auth env
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] # Check admin access when requesting an admin page
unless validate_token_admin env if request_path_startswith env, %w(/admin /api/admin /download)
unless is_admin? env
env.response.status_code = 403 env.response.status_code = 403
return send_error_page "HTTP 403: You are not authorized to visit " \
"#{env.request.path}"
end end
end end
# Let the request go through if it passes the above checks
call_next env call_next env
end end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end end
+12 -10
View File
@@ -1,22 +1,23 @@
require "image_size" require "image_size"
class Entry class Entry
property zip_path : String, book : Title, title : String, getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String, size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String? encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book, storage) def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path @encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path @title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes @size = (File.size @zip_path).humanize_bytes
id = storage.get_id @zip_path, false id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil? if id.nil?
id = random_str id = random_str
storage.insert_id({ storage.insert_entry_id({
path: @zip_path, path: @zip_path,
id: id, id: id,
is_title: false, signature: File.signature(@zip_path).to_s,
}) })
end end
@id = id @id = id
@@ -133,10 +134,11 @@ class Entry
entries[idx + 1] entries[idx + 1]
end end
def previous_entry def previous_entry(username)
idx = @book.entries.index self entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == 0 return nil if idx.nil? || idx == 0
@book.entries[idx - 1] entries[idx - 1]
end end
def date_added def date_added
+7 -37
View File
@@ -1,5 +1,5 @@
class Library class Library
property dir : String, title_ids : Array(String), getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
use_default use_default
@@ -42,16 +42,6 @@ class Library
end end
end end
end end
db_interval = Config.current.db_optimization_interval_hours
unless db_interval < 1
spawn do
loop do
Storage.default.optimize
sleep db_interval.hours
end
end
end
end end
def titles def titles
@@ -68,29 +58,8 @@ class Library
end end
end end
# This is a hack to bypass a compiler bug # Helper function from src/util/util.cr
ary = titles sort_titles titles, opt.not_nil!, username
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end end
def deep_titles def deep_titles
@@ -127,7 +96,7 @@ class Library
.select { |fn| !fn.starts_with? "." } .select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn } .map { |fn| File.join @dir, fn }
.select { |path| File.directory? path } .select { |path| File.directory? path }
.map { |path| Title.new path, "", storage, self } .map { |path| Title.new path, "" }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear } .tap { |_| @title_ids.clear }
@@ -140,6 +109,7 @@ class Library
storage.close storage.close
Logger.debug "Scan completed" Logger.debug "Scan completed"
Storage.default.mark_unavailable
end end
def get_continue_reading_entries(username) def get_continue_reading_entries(username)
@@ -151,7 +121,7 @@ class Library
# Get the last read time of the entry. If it hasn't been started, get # Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry # the last read time of the previous entry
last_read = e.load_last_read username last_read = e.load_last_read username
pe = e.previous_entry pe = e.previous_entry username
if last_read.nil? && pe if last_read.nil? && pe
last_read = pe.load_last_read username last_read = pe.load_last_read username
end end
@@ -262,7 +232,7 @@ class Library
e.generate_thumbnail e.generate_thumbnail
# Sleep after each generation to minimize the impact on disk IO # Sleep after each generation to minimize the impact on disk IO
# and CPU # and CPU
sleep 0.5.seconds sleep 1.seconds
end end
@thumbnails_count += 1 @thumbnails_count += 1
end end
+46 -21
View File
@@ -1,20 +1,22 @@
require "../archive" require "../archive"
class Title class Title
property dir : String, parent_id : String, title_ids : Array(String), getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time, encoded_title : String, mtime : Time, signature : UInt64
entry_display_name_cache : Hash(String, String)?
def initialize(@dir : String, @parent_id, storage, @entry_display_name_cache : Hash(String, String)?
@library : Library)
id = storage.get_id @dir, true def initialize(@dir : String, @parent_id)
storage = Storage.default
@signature = Dir.signature dir
id = storage.get_title_id dir, signature
if id.nil? if id.nil?
id = random_str id = random_str
storage.insert_id({ storage.insert_title_id({
path: @dir, path: dir,
id: id, id: id,
is_title: true, signature: signature.to_s,
}) })
end end
@id = id @id = id
@@ -28,26 +30,26 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, storage, library title = Title.new path, @id
next if title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
next next
end end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path if is_supported_file path
entry = Entry.new path, self, storage entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
mtimes = [@mtime] mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime } mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime } mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max @mtime = mtimes.max
@title_ids.sort! do |a, b| @title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title, compare_numerically Library.default.title_hash[a].title,
@library.title_hash[b].title Library.default.title_hash[b].title
end end
sorter = ChapterSorter.new @entries.map { |e| e.title } sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b| @entries.sort! do |a, b|
@@ -60,6 +62,7 @@ class Title
{% for str in ["dir", "title", "id"] %} {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "signature" { json.number @signature }
json.field "display_name", display_name json.field "display_name", display_name
json.field "cover_url", cover_url json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix } json.field "mtime" { json.number @mtime.to_unix }
@@ -83,7 +86,7 @@ class Title
end end
def titles def titles
@title_ids.map { |tid| @library.get_title! tid } @title_ids.map { |tid| Library.default.get_title! tid }
end end
# Get all entries, including entries in nested titles # Get all entries, including entries in nested titles
@@ -101,15 +104,37 @@ class Title
ary = [] of Title ary = [] of Title
tid = @parent_id tid = @parent_id
while !tid.empty? while !tid.empty?
title = @library.get_title! tid title = Library.default.get_title! tid
ary << title ary << title
tid = title.parent_id tid = title.parent_id
end end
ary.reverse ary.reverse
end end
def size # Returns a string the describes the content of the title
@entries.size + @title_ids.size # e.g., - 3 titles and 1 entry
# - 4 entries
# - 1 title
def content_label
ary = [] of String
tsize = titles.size
esize = entries.size
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
ary.join " and "
end
def tags
Storage.default.get_title_tags @id
end
def add_tag(tag)
Storage.default.add_tag @id, tag
end
def delete_tag(tag)
Storage.default.delete_tag @id, tag
end end
def get_entry(eid) def get_entry(eid)
+22 -16
View File
@@ -6,26 +6,14 @@ class Logger
SEVERITY_IDS = [0, 4, 5, 2, 3] SEVERITY_IDS = [0, 4, 5, 2, 3]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta] COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
getter raw_log = Log.for ""
@@severity : Log::Severity = :info @@severity : Log::Severity = :info
use_default use_default
def initialize def initialize
level = Config.current.log_level @@severity = Logger.get_severity
{% begin %}
case level.downcase
when "off"
@@severity = :none
{% for lvl, i in LEVELS %}
when {{lvl}}
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
@log = Log.for("")
@backend = Log::IOBackend.new @backend = Log::IOBackend.new
format_proc = ->(entry : Log::Entry, io : IO) do format_proc = ->(entry : Log::Entry, io : IO) do
@@ -49,6 +37,24 @@ class Logger
Log.setup @@severity, @backend Log.setup @@severity, @backend
end end
def self.get_severity(level = "") : Log::Severity
if level.empty?
level = Config.current.log_level
end
{% begin %}
case level.downcase
when "off"
return Log::Severity::None
{% for lvl, i in LEVELS %}
when {{lvl}}
return Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
end
# Ignores @@severity and always log msg # Ignores @@severity and always log msg
def log(msg) def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg, @backend.write Log::Entry.new "", Log::Severity::None, msg,
@@ -61,7 +67,7 @@ class Logger
{% for lvl in LEVELS %} {% for lvl in LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg } raw_log.{{lvl.id}} { msg }
end end
def self.{{lvl.id}}(msg) def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg default.not_nil!.{{lvl.id}} msg
-217
View File
@@ -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
+7 -5
View File
@@ -1,5 +1,7 @@
require "./api" require "mangadex"
require "compress/zip" require "compress/zip"
require "../rename"
require "./ext"
module MangaDex module MangaDex
class PageJob class PageJob
@@ -21,7 +23,7 @@ module MangaDex
use_default use_default
def initialize def initialize
@api = API.default @client = Client.from_config
super super
end end
@@ -46,7 +48,7 @@ module MangaDex
@downloading = true @downloading = true
@queue.set_status Queue::JobStatus::Downloading, job @queue.set_status Queue::JobStatus::Downloading, job
begin begin
chapter = @api.get_chapter(job.id) chapter = @client.chapter job.id
rescue e rescue e
Logger.error e Logger.error e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
@@ -73,8 +75,8 @@ module MangaDex
# Create a buffered channel. It works as an FIFO queue # Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size channel = Channel(PageJob).new chapter.pages.size
spawn do spawn do
chapter.pages.each_with_index do |tuple, i| chapter.pages.each_with_index do |url, i|
fn, url = tuple fn = Path.new(URI.parse(url).path).basename
ext = File.extname fn ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}" fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries page_job = PageJob.new url, fn, writer, @retries
+60
View File
@@ -0,0 +1,60 @@
private macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => {{name.id}}.to_s,
{% end %}
}
end
# Monkey-patch the structures in the `mangadex` shard to suit our needs
module MangaDex
struct Client
@@group_cache = {} of String => Group
def self.from_config : Client
self.new base_url: Config.current.mangadex["base_url"].to_s,
api_url: Config.current.mangadex["api_url"].to_s
end
end
struct Manga
def rename(rule : Rename::Rule)
rule.render properties_to_hash %w(id title author artist)
end
def to_info_json
hash = JSON.parse(to_json).as_h
_chapters = chapters.map do |c|
JSON.parse c.to_info_json
end
hash["chapters"] = JSON::Any.new _chapters
hash.to_json
end
end
struct Chapter
def rename(rule : Rename::Rule)
hash = properties_to_hash %w(id title volume chapter lang_code language)
hash["groups"] = groups.map(&.name).join ","
rule.render hash
end
def full_title
rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
rename rule
end
def to_info_json
hash = JSON.parse(to_json).as_h
hash["language"] = JSON::Any.new language
_groups = {} of String => JSON::Any
groups.each do |g|
_groups[g.name] = JSON::Any.new g.id
end
hash["groups"] = JSON::Any.new _groups
hash["full_title"] = JSON::Any.new full_title
hash.to_json
end
end
end
+20 -14
View File
@@ -3,11 +3,12 @@ require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "./plugin/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "tallboy"
MANGO_VERSION = "0.17.1" MANGO_VERSION = "0.21.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -53,11 +54,21 @@ class CLI < Clim
ARGV.clear ARGV.clear
Config.load(opts.config).set_current Config.load(opts.config).set_current
# Initialize main components
Storage.default
Queue.default
Library.default
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
spawn do spawn do
Server.new.start begin
Server.new.start
rescue e
Logger.fatal e
Process.exit 1
end
end end
MainFiber.start_and_block MainFiber.start_and_block
@@ -105,18 +116,13 @@ class CLI < Clim
password.not_nil!, opts.admin password.not_nil!, opts.admin
when "list" when "list"
users = storage.list_users users = storage.list_users
name_length = users.map(&.[0].size).max? || 0 table = Tallboy.table do
l_cell_width = ["username".size, name_length].max header ["username", "admin access"]
r_cell_width = "admin access".size users.each do |name, admin|
header = " #{"username".ljust l_cell_width} | admin access " row [name, admin]
puts "-" * header.size end
puts header
puts "-" * header.size
users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \
"#{admin.to_s.ljust r_cell_width} "
end end
puts "-" * header.size puts table
when nil when nil
puts opts.help_string puts opts.help_string
else else
+42
View File
@@ -257,6 +257,48 @@ class Plugin
end end
sbx.put_prop_string -2, "get" sbx.put_prop_string -2, "get"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
body = env.require_string 1
headers = HTTP::Headers.new
if env.get_top == 3
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.post url, headers, body
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "post"
sbx.push_proc 2 do |ptr| sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
+5 -1
View File
@@ -139,9 +139,13 @@ module Rename
post_process str post_process str
end end
# Post-processes the generated file/folder name
# - Handles the rare case where the string is `..`
# - Removes trailing spaces and periods
# - Replace illegal characters with `_`
private def post_process(str) private def post_process(str)
return "_" if str == ".." return "_" if str == ".."
str.gsub "/", "_" str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
end end
end end
end end
+17 -8
View File
@@ -1,13 +1,14 @@
require "./router" struct AdminRouter
class AdminRouter < Router
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
storage = Storage.default
missing_count = storage.missing_titles.size +
storage.missing_entries.size
layout "admin" layout "admin"
end end
get "/admin/user" do |env| get "/admin/user" do |env|
users = @context.storage.list_users users = Storage.default.list_users
username = get_username env username = get_username env
layout "user" layout "user"
end end
@@ -32,11 +33,11 @@ class AdminRouter < Router
# would not contain `admin` # would not contain `admin`
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
@context.storage.new_user username, password, admin Storage.default.new_user username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"error" => e.message}) query: hash_to_query({"error" => e.message})
@@ -51,12 +52,12 @@ class AdminRouter < Router
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"] original_username = env.params.url["original_username"]
@context.storage.update_user \ Storage.default.update_user \
original_username, username, password, admin original_username, username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \ query: hash_to_query({"username" => original_username, \
@@ -68,5 +69,13 @@ class AdminRouter < Router
mangadex_base_url = Config.current.mangadex["base_url"] mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
get "/admin/missing" do |env|
layout "missing-items"
end
get "/admin/mangadex" do |env|
layout "mangadex"
end
end end
end end
+501 -196
View File
@@ -1,9 +1,8 @@
require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa" require "koa"
class APIRouter < Router struct APIRouter
@@api_json : String? @@api_json : String?
API_VERSION = "0.1.0" API_VERSION = "0.1.0"
@@ -11,7 +10,7 @@ class APIRouter < Router
macro s(fields) macro s(fields)
{ {
{% for field in fields %} {% for field in fields %}
{{field}} => "string", {{field}} => String,
{% end %} {% end %}
} }
end end
@@ -34,145 +33,56 @@ class APIRouter < Router
MD MD
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}" Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
Koa.global_tag "admin", desc: <<-MD Koa.define_tag "admin", desc: <<-MD
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints. These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
MD MD
Koa.binary "binary", desc: "A binary file" Koa.schema "entry", {
Koa.array "entryAry", "$entry", desc: "An array of entries" "pages" => Int32,
Koa.array "titleAry", "$title", desc: "An array of titles" "mtime" => Int64,
Koa.array "strAry", "string", desc: "An array of strings" }.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book"
entry_schema = { Koa.schema "title", {
"pages" => "integer", "mtime" => Int64,
"mtime" => "integer", "entries" => ["entry"],
}.merge s %w(zip_path title size id title_id display_name cover_url) "titles" => ["title"],
Koa.object "entry", entry_schema, desc: "An entry in a book" "parents" => [String],
}.merge(s %w(dir title id display_name cover_url)),
title_schema = {
"mtime" => "integer",
"entries" => "$entryAry",
"titles" => "$titleAry",
"parents" => "$strAry",
}.merge s %w(dir title id display_name cover_url)
Koa.object "title", title_schema,
desc: "A manga title (a collection of entries and sub-titles)" desc: "A manga title (a collection of entries and sub-titles)"
Koa.object "library", { Koa.schema "result", {
"dir" => "string", "success" => Bool,
"titles" => "$titleAry", "error" => String?,
}, desc: "A library containing a list of top-level titles"
Koa.object "scanResult", {
"milliseconds" => "integer",
"titles" => "integer",
} }
Koa.object "progressResult", { Koa.schema("mdChapter", {
"progress" => "number", "id" => Int64,
} "group" => {} of String => String,
}.merge(s %w(title volume chapter language full_title time
manga_title manga_id)),
desc: "A MangaDex chapter")
Koa.object "result", { Koa.schema "mdManga", {
"success" => "boolean", "id" => Int64,
"error" => "string?", "chapters" => ["mdChapter"],
} }.merge(s %w(title description author artist cover_url)),
desc: "A MangaDex manga"
mc_schema = {
"groups" => "object",
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
Koa.array "chapterAry", "$mangadexChapter"
mm_schema = {
"chapers" => "$chapterAry",
}.merge s %w(id title description author artist cover_url)
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
Koa.object "chaptersObj", {
"chapters" => "$chapterAry",
}
Koa.object "successFailCount", {
"success" => "integer",
"fail" => "integer",
}
job_schema = {
"pages" => "integer",
"success_count" => "integer",
"fail_count" => "integer",
"time" => "integer",
}.merge s %w(id manga_id title manga_title status_message status)
Koa.object "job", job_schema, desc: "A download job in the queue"
Koa.array "jobAry", "$job"
Koa.object "jobs", {
"success" => "boolean",
"paused" => "boolean",
"jobs" => "$jobAry",
}
Koa.object "binaryUpload", {
"file" => "$binary",
}
Koa.object "pluginListBody", {
"plugin" => "string",
"query" => "string",
}
Koa.object "pluginChapter", {
"id" => "string",
"title" => "string",
}
Koa.array "pluginChapterAry", "$pluginChapter"
Koa.object "pluginList", {
"success" => "boolean",
"chapters" => "$pluginChapterAry?",
"title" => "string?",
"error" => "string?",
}
Koa.object "pluginDownload", {
"plugin" => "string",
"title" => "string",
"chapters" => "$pluginChapterAry",
}
Koa.object "dimension", {
"width" => "integer",
"height" => "integer",
}
Koa.array "dimensionAry", "$dimension"
Koa.object "dimensionResult", {
"success" => "boolean",
"dimensions" => "$dimensionAry?",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.describe "Returns a page in a manga entry" Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)" Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
Koa.response 200, ref: "$binary", media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.tag "reader"
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -182,7 +92,7 @@ class APIRouter < Router
send_img env, img send_img env, img
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
@@ -191,14 +101,15 @@ class APIRouter < Router
Koa.describe "Returns the cover image of a manga entry" Koa.describe "Returns the cover image of a manga entry"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.response 200, ref: "$binary", media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.tag "library"
get "/api/cover/:tid/:eid" do |env| get "/api/cover/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -209,7 +120,7 @@ class APIRouter < Router
send_img env, img send_img env, img
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
@@ -217,44 +128,54 @@ class APIRouter < Router
Koa.describe "Returns the book with title `tid`" Koa.describe "Returns the book with title `tid`"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.response 200, ref: "$title" Koa.response 200, schema: "title"
Koa.response 404, "Title not found" Koa.response 404, "Title not found"
Koa.tag "library"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
e.message e.message
end end
end end
Koa.describe "Returns the entire library with all titles and entries" Koa.describe "Returns the entire library with all titles and entries"
Koa.response 200, ref: "$library" Koa.response 200, schema: {
"dir" => String,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library" do |env| get "/api/library" do |env|
send_json env, @context.library.to_json send_json env, Library.default.to_json
end end
Koa.describe "Triggers a library scan" Koa.describe "Triggers a library scan"
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.response 200, ref: "$scanResult" Koa.response 200, schema: {
"milliseconds" => Float64,
"titles" => Int32,
}
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
@context.library.scan Library.default.scan
ms = (Time.utc - start).total_milliseconds ms = (Time.utc - start).total_milliseconds
send_json env, { send_json env, {
"milliseconds" => ms, "milliseconds" => ms,
"titles" => @context.library.titles.size, "titles" => Library.default.titles.size,
}.to_json }.to_json
end end
Koa.describe "Returns the thumbnail generation progress between 0 and 1" Koa.describe "Returns the thumbnail generation progress between 0 and 1"
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.response 200, ref: "$progressResult" Koa.response 200, schema: {
"progress" => Float64,
}
get "/api/admin/thumbnail_progress" do |env| get "/api/admin/thumbnail_progress" do |env|
send_json env, { send_json env, {
"progress" => Library.default.thumbnail_generation_progress, "progress" => Library.default.thumbnail_generation_progress,
@@ -262,7 +183,7 @@ class APIRouter < Router
end end
Koa.describe "Triggers a thumbnail generation" Koa.describe "Triggers a thumbnail generation"
Koa.tag "admin" Koa.tags ["admin", "library"]
post "/api/admin/generate_thumbnails" do |env| post "/api/admin/generate_thumbnails" do |env|
spawn do spawn do
Library.default.generate_thumbnails Library.default.generate_thumbnails
@@ -270,14 +191,14 @@ class APIRouter < Router
end end
Koa.describe "Deletes a user with `username`" Koa.describe "Deletes a user with `username`"
Koa.tag "admin" Koa.tags ["admin", "users"]
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
delete "/api/admin/user/delete/:username" do |env| delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username Storage.default.delete_user username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -298,11 +219,12 @@ class APIRouter < Router
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false Koa.query "eid", desc: "Entry ID", required: false
Koa.path "page", desc: "The new page number indicating the progress" Koa.path "page", desc: "The new page number indicating the progress"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
Koa.tag "progress"
put "/api/progress/:tid/:page" do |env| put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["eid"]? entry_id = env.params.query["eid"]?
@@ -316,7 +238,7 @@ class APIRouter < Router
title.read_all username title.read_all username
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -329,12 +251,15 @@ class APIRouter < Router
Koa.describe "Updates the reading progress of multiple entries in a title" Koa.describe "Updates the reading progress of multiple entries in a title"
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`" Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.body ref: "$ids", desc: "An array of entry IDs" Koa.body schema: {
Koa.response 200, ref: "$result" "ids" => [String],
}, desc: "An array of entry IDs"
Koa.response 200, schema: "result"
Koa.tag "progress"
put "/api/bulk_progress/:action/:tid" do |env| put "/api/bulk_progress/:action/:tid" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"] action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s ids = env.params.json["ids"].as(Array).map &.as_s
@@ -343,7 +268,7 @@ class APIRouter < Router
end end
title.bulk_progress action, ids, username title.bulk_progress action, ids, username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -356,14 +281,14 @@ class APIRouter < Router
Koa.describe "Sets the display name of a title or an entry", <<-MD Koa.describe "Sets the display name of a title or an entry", <<-MD
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`. When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
MD MD
Koa.tag "admin" Koa.tags ["admin", "library"]
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false Koa.query "eid", desc: "Entry ID", required: false
Koa.path "name", desc: "The new display name" Koa.path "name", desc: "The new display name"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
put "/api/admin/display_name/:tid/:name" do |env| put "/api/admin/display_name/:tid/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["tid"]) title = (Library.default.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["eid"]? entry = env.params.query["eid"]?
@@ -374,7 +299,7 @@ class APIRouter < Router
title.set_display_name eobj.not_nil!.title, name title.set_display_name eobj.not_nil!.title, name
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -387,17 +312,16 @@ class APIRouter < Router
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
On error, returns a JSON that contains the error message in the `error` field. On error, returns a JSON that contains the error message in the `error` field.
MD MD
Koa.tag "admin" Koa.tags ["admin", "mangadex"]
Koa.path "id", desc: "A MangaDex manga ID" Koa.path "id", desc: "A MangaDex manga ID"
Koa.response 200, ref: "$mangadexManga" Koa.response 200, schema: "mdManga"
get "/api/admin/mangadex/manga/:id" do |env| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
id = env.params.url["id"] id = env.params.url["id"]
api = MangaDex::API.default manga = MangaDex::Client.from_config.manga id
manga = api.get_manga id
send_json env, manga.to_info_json send_json env, manga.to_info_json
rescue e rescue e
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
@@ -405,29 +329,34 @@ class APIRouter < Router
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field. On error, returns a JSON that contains the error message in the `error` field.
MD MD
Koa.tag "admin" Koa.tags ["admin", "mangadex", "downloader"]
Koa.body ref: "$chaptersObj" Koa.body schema: {
Koa.response 200, ref: "$successFailCount" "chapters" => ["mdChapter"],
}
Koa.response 200, schema: {
"success" => Int32,
"fail" => Int32,
}
post "/api/admin/mangadex/download" do |env| post "/api/admin/mangadex/download" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter| jobs = chapters.map { |chapter|
Queue::Job.new( Queue::Job.new(
chapter["id"].as_s, chapter["id"].as_i64.to_s,
chapter["manga_id"].as_s, chapter["mangaId"].as_i64.to_s,
chapter["full_title"].as_s, chapter["full_title"].as_s,
chapter["manga_title"].as_s, chapter["mangaTitle"].as_s,
Queue::JobStatus::Pending, Queue::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i Time.unix chapter["timestamp"].as_i64
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
}.to_json }.to_json
rescue e rescue e
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
@@ -437,8 +366,8 @@ class APIRouter < Router
interval = (interval_raw.to_i? if interval_raw) || 5 interval = (interval_raw.to_i? if interval_raw) || 5
loop do loop do
socket.send({ socket.send({
"jobs" => @context.queue.get_all, "jobs" => Queue.default.get_all,
"paused" => @context.queue.paused?, "paused" => Queue.default.paused?,
}.to_json) }.to_json)
sleep interval.seconds sleep interval.seconds
end end
@@ -447,14 +376,24 @@ class APIRouter < Router
Koa.describe "Returns the current download queue", <<-MD Koa.describe "Returns the current download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field. On error, returns a JSON that contains the error message in the `error` field.
MD MD
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.response 200, ref: "$jobs" Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"paused" => Bool?,
"jobs?" => [{
"pages" => Int32,
"success_count" => Int32,
"fail_count" => Int32,
"time" => Int64,
}.merge(s %w(id manga_id title manga_title status_message status))],
}
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = @context.queue.get_all jobs = Queue.default.get_all
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => jobs,
"paused" => @context.queue.paused?, "paused" => Queue.default.paused?,
"success" => true, "success" => true,
}.to_json }.to_json
rescue e rescue e
@@ -474,10 +413,10 @@ class APIRouter < Router
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue. When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
MD MD
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`." Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
Koa.query "id", required: false, desc: "A job ID" Koa.query "id", required: false, desc: "A job ID"
Koa.response 200, ref: "$result" Koa.response 200, schema: "result"
post "/api/admin/mangadex/queue/:action" do |env| post "/api/admin/mangadex/queue/:action" do |env|
begin begin
action = env.params.url["action"] action = env.params.url["action"]
@@ -485,20 +424,20 @@ class APIRouter < Router
case action case action
when "delete" when "delete"
if id.nil? if id.nil?
@context.queue.delete_status Queue::JobStatus::Completed Queue.default.delete_status Queue::JobStatus::Completed
else else
@context.queue.delete id Queue.default.delete id
end end
when "retry" when "retry"
if id.nil? if id.nil?
@context.queue.reset Queue.default.reset
else else
@context.queue.reset id Queue.default.reset id
end end
when "pause" when "pause"
@context.queue.pause Queue.default.pause
when "resume" when "resume"
@context.queue.resume Queue.default.resume
else else
raise "Unknown queue action #{action}" raise "Unknown queue action #{action}"
end end
@@ -526,8 +465,10 @@ class APIRouter < Router
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry. When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
MD MD
Koa.tag "admin" Koa.tag "admin"
Koa.body type: "multipart/form-data", ref: "$binaryUpload" Koa.body media_type: "multipart/form-data", schema: {
Koa.response 200, ref: "$result" "file" => Bytes,
}
Koa.response 200, schema: "result"
post "/api/admin/upload/:target" do |env| post "/api/admin/upload/:target" do |env|
begin begin
target = env.params.url["target"] target = env.params.url["target"]
@@ -544,7 +485,7 @@ class APIRouter < Router
when "cover" when "cover"
title_id = env.params.query["tid"] title_id = env.params.query["tid"]
entry_id = env.params.query["eid"]? entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil! title = Library.default.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \ unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename MIME.from_filename? filename
@@ -583,9 +524,18 @@ class APIRouter < Router
end end
Koa.describe "Lists the chapters in a title from a plugin" Koa.describe "Lists the chapters in a title from a plugin"
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.body ref: "$pluginListBody" Koa.query "plugin", schema: String
Koa.response 200, ref: "$pluginList" Koa.query "query", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"chapters?" => [{
"id" => String,
"title" => String,
}],
"title" => String?,
}
get "/api/admin/plugin/list" do |env| get "/api/admin/plugin/list" do |env|
begin begin
query = env.params.query["query"].as String query = env.params.query["query"].as String
@@ -609,9 +559,19 @@ class APIRouter < Router
end end
Koa.describe "Adds a list of chapters from a plugin to the download queue" Koa.describe "Adds a list of chapters from a plugin to the download queue"
Koa.tag "admin" Koa.tags ["admin", "downloader"]
Koa.body ref: "$pluginDownload" Koa.body schema: {
Koa.response 200, ref: "$successFailCount" "plugin" => String,
"title" => String,
"chapters" => [{
"id" => String,
"title" => String,
}],
}
Koa.response 200, schema: {
"success" => Int32,
"fail" => Int32,
}
post "/api/admin/plugin/download" do |env| post "/api/admin/plugin/download" do |env|
begin begin
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.json["plugin"].as String
@@ -628,7 +588,7 @@ class APIRouter < Router
Time.utc Time.utc
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
@@ -644,13 +604,22 @@ class APIRouter < Router
Koa.describe "Returns the image dimensions of all pages in an entry" Koa.describe "Returns the image dimensions of all pages in an entry"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID" Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$dimensionResult" Koa.tag "reader"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"dimensions?" => [{
"width" => Int32,
"height" => Int32,
}],
"margin" => Int32?,
}
get "/api/dimensions/:tid/:eid" do |env| get "/api/dimensions/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -659,6 +628,7 @@ class APIRouter < Router
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
"margin" => Config.current.page_margin,
}.to_json }.to_json
rescue e rescue e
send_json env, { send_json env, {
@@ -671,20 +641,355 @@ class APIRouter < Router
Koa.describe "Downloads an entry" Koa.describe "Downloads an entry"
Koa.path "tid", desc: "A title ID" Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID" Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$binary" Koa.response 200, schema: Bytes
Koa.response 404, "Entry not found" Koa.response 404, "Entry not found"
Koa.tags ["library", "reader"]
get "/api/download/:tid/:eid" do |env| get "/api/download/:tid/:eid" do |env|
begin begin
title = (@context.library.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil! entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path send_attachment env, entry.zip_path
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
Koa.describe "Gets the tags of a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"tags" => [String?],
}
Koa.tags ["library", "tags"]
get "/api/tags/:tid" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tags = title.tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns all tags"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"tags" => [String?],
}
Koa.tags ["library", "tags"]
get "/api/tags" do |env|
begin
tags = Storage.default.list_tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, schema: "result"
Koa.tags ["admin", "library", "tags"]
put "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.add_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a tag from a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, schema: "result"
Koa.tags ["admin", "library", "tags"]
delete "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.delete_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Lists all missing titles"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"titles?" => [{
"path" => String,
"id" => String,
"signature" => String,
}],
}
Koa.tags ["admin", "library"]
get "/api/admin/titles/missing" do |env|
begin
send_json env, {
"success" => true,
"error" => nil,
"titles" => Storage.default.missing_titles,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Lists all missing entries"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"entries?" => [{
"path" => String,
"id" => String,
"signature" => String,
}],
}
Koa.tags ["admin", "library"]
get "/api/admin/entries/missing" do |env|
begin
send_json env, {
"success" => true,
"error" => nil,
"entries" => Storage.default.missing_entries,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes all missing titles"
Koa.response 200, schema: "result"
Koa.tags ["admin", "library"]
delete "/api/admin/titles/missing" do |env|
begin
Storage.default.delete_missing_title
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes all missing entries"
Koa.response 200, schema: "result"
Koa.tags ["admin", "library"]
delete "/api/admin/entries/missing" do |env|
begin
Storage.default.delete_missing_entry
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
Does nothing if the given `tid` is not found or if the title is not missing.
MD
Koa.response 200, schema: "result"
Koa.tags ["admin", "library"]
delete "/api/admin/titles/missing/:tid" do |env|
begin
tid = env.params.url["tid"]
Storage.default.delete_missing_title tid
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
Does nothing if the given `eid` is not found or if the entry is not missing.
MD
Koa.response 200, schema: "result"
Koa.tags ["admin", "library"]
delete "/api/admin/entries/missing/:eid" do |env|
begin
eid = env.params.url["eid"]
Storage.default.delete_missing_entry eid
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Logs the current user into their MangaDex account", <<-MD
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
MD
Koa.body schema: {
"username" => String,
"password" => String,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
post "/api/admin/mangadex/login" do |env|
begin
username = env.params.json["username"].as String
password = env.params.json["password"].as String
mango_username = get_username env
client = MangaDex::Client.from_config
client.auth username, password
Storage.default.save_md_token mango_username, client.token.not_nil!,
client.token_expires
send_json env, {
"success" => true,
"error" => nil,
"expires" => client.token_expires.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
get "/api/admin/mangadex/expires" do |env|
begin
username = get_username env
_, expires = Storage.default.get_md_token username
send_json env, {
"success" => true,
"error" => nil,
"expires" => expires.try &.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
Returns an empty list if the current user hasn't logged in to MangaDex.
MD
Koa.query "query"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"manga?" => [{
"id" => Int64,
"title" => String,
"description" => String,
"mainCover" => String,
}],
}
Koa.tags ["admin", "mangadex"]
get "/api/admin/mangadex/search" do |env|
begin
username = get_username env
token, expires = Storage.default.get_md_token username
unless expires && token
raise "No token found for user #{username}"
end
client = MangaDex::Client.from_config
client.token = token
client.token_expires = expires
query = env.params.query["query"]
send_json env, {
"success" => true,
"error" => nil,
"manga" => client.partial_search query,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate doc = Koa.generate
@@api_json = doc.to_json if doc @@api_json = doc.to_json if doc
+57 -17
View File
@@ -1,6 +1,4 @@
require "./router" struct MainRouter
class MainRouter < Router
def initialize def initialize
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url base_url = Config.current.base_url
@@ -11,7 +9,7 @@ class MainRouter < Router
begin begin
env.session.delete_string "token" env.session.delete_string "token"
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" Logger.error "Error when attempting to log out: #{e}"
ensure ensure
redirect env, "/login" redirect env, "/login"
end end
@@ -21,7 +19,7 @@ class MainRouter < Router
begin begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil! token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token env.session.string "token", token
@@ -41,22 +39,22 @@ class MainRouter < Router
begin begin
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json @context.library.dir, username sort_opt = SortOptions.from_info_json Library.default.dir, username
get_sort_opt get_sort_opt
titles = @context.library.sorted_titles username, sort_opt titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username percentage = titles.map &.load_percentage username
layout "library" layout "library"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/book/:title" do |env| get "/book/:title" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username sort_opt = SortOptions.from_info_json title.dir, username
@@ -68,7 +66,7 @@ class MainRouter < Router
title_percentage = title.titles.map &.load_percentage username title_percentage = title.titles.map &.load_percentage username
layout "title" layout "title"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -92,7 +90,7 @@ class MainRouter < Router
layout "plugin-download" layout "plugin-download"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -100,21 +98,63 @@ class MainRouter < Router
get "/" do |env| get "/" do |env|
begin begin
username = get_username env username = get_username env
continue_reading = @context continue_reading = Library.default
.library.get_continue_reading_entries username .get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username recently_added = Library.default.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username start_reading = Library.default.get_start_reading_titles username
titles = @context.library.titles titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0 empty_library = titles.size == 0
layout "home" layout "home"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/tags/:tag" do |env|
begin
username = get_username env
tag = env.params.url["tag"]
sort_opt = SortOptions.new
get_sort_opt
title_ids = Storage.default.get_tag_titles tag
raise "Tag #{tag} not found" if title_ids.empty?
titles = title_ids.map { |id| Library.default.get_title id }
.select Title
titles = sort_titles titles, sort_opt, username
percentage = titles.map &.load_percentage username
layout "tag"
rescue e
Logger.error e
env.response.status_code = 404
end
end
get "/tags" do |env|
tags = Storage.default.list_tags.map do |tag|
{
tag: tag,
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
count: Storage.default.get_tag_titles(tag).size,
}
end
# Sort by :count reversly, and then sort by :tag
tags.sort! do |a, b|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
end
layout "tags"
end
get "/api" do |env| get "/api" do |env|
base_url = Config.current.base_url
render "src/views/api.html.ecr" render "src/views/api.html.ecr"
end end
end end
+4 -6
View File
@@ -1,18 +1,16 @@
require "./router" struct OPDSRouter
class OPDSRouter < Router
def initialize def initialize
get "/opds" do |env| get "/opds" do |env|
titles = @context.library.titles titles = Library.default.titles
render_xml "src/views/opds/index.xml.ecr" render_xml "src/views/opds/index.xml.ecr"
end end
get "/opds/book/:title_id" do |env| get "/opds/book/:title_id" do |env|
begin begin
title = @context.library.get_title(env.params.url["title_id"]).not_nil! title = Library.default.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.xml.ecr" render_xml "src/views/opds/title.xml.ecr"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
+23 -16
View File
@@ -1,25 +1,23 @@
require "./router" struct ReaderRouter
class ReaderRouter < Router
def initialize def initialize
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg next layout "reader-error" if entry.err_msg
# load progress # load progress
page = [1, entry.load_progress username].max page_idx = [1, entry.load_progress username].max
# start from page 1 if the user has finished reading the entry # start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username page_idx = 1 if entry.finished? username
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}" redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
@@ -30,22 +28,31 @@ class ReaderRouter < Router
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 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."
end
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil next_entry_url = entry.next_entry(username).try do |e|
next_entry = entry.next_entry username "#{base_url}reader/#{title.id}/#{e.id}"
unless next_entry.nil? end
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
previous_entry_url = entry.previous_entry(username).try do |e|
"#{base_url}reader/#{title.id}/#{e.id}"
end end
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
-3
View File
@@ -1,3 +0,0 @@
class Router
@context : Context = Context.default
end
+4 -29
View File
@@ -5,34 +5,8 @@ require "./handlers/*"
require "./util/*" require "./util/*"
require "./routes/*" require "./routes/*"
class Context
property library : Library
property storage : Storage
property queue : Queue
use_default
def initialize
@storage = Storage.default
@library = Library.default
@queue = Queue.default
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
Logger.{{lvl.id}} msg
end
{% end %}
end
class Server class Server
@context : Context = Context.default
def initialize def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
end
error 404 do |env| error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}" message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message" layout "message"
@@ -53,11 +27,11 @@ class Server
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
add_handler AuthHandler.new @context.storage add_handler AuthHandler.new
add_handler UploadHandler.new Config.current.upload_path add_handler UploadHandler.new Config.current.upload_path
{% if flag?(:release) %} {% if flag?(:release) %}
# when building for relase, embed the static files in binary # when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files." Logger.debug "We are in release mode. Using embedded static files."
serve_static false serve_static false
add_handler StaticHandler.new add_handler StaticHandler.new
{% end %} {% end %}
@@ -71,10 +45,11 @@ class Server
end end
def start def start
@context.debug "Starting Kemal server" Logger.debug "Starting Kemal server"
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
Kemal.config.host_binding = Config.current.host
Kemal.config.port = Config.current.port Kemal.config.port = Config.current.port
Kemal.run Kemal.run
end end
+305 -57
View File
@@ -3,6 +3,8 @@ require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util/*" require "./util/*"
require "mg"
require "../migration/*"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -13,13 +15,16 @@ def verify_password(hash, pw)
end end
class Storage class Storage
@@insert_entry_ids = [] of IDTuple
@@insert_title_ids = [] of IDTuple
@path : String @path : String
@db : DB::Database? @db : DB::Database?
@insert_ids = [] of IDTuple
alias IDTuple = NamedTuple(path: String, alias IDTuple = NamedTuple(
path: String,
id: String, id: String,
is_title: Bool) signature: String?)
use_default use_default
@@ -29,41 +34,20 @@ class Storage
dir = File.dirname @path dir = File.dirname @path
unless Dir.exists? dir unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \ Logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attempting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
db.exec "create table thumbnails " \ MG::Migration.new(db, log: Logger.default.raw_log).migrate
"(id text, data blob, filename text, " \
"mime text, size integer)"
db.exec "create unique index tn_index on thumbnails (id)"
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e rescue e
unless e.message.not_nil!.ends_with? "already exists" Logger.fatal "DB migration failed. #{e}"
Logger.fatal "Error when checking tables in DB: #{e}" raise e
raise e
end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
init_admin if init_user
end end
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
end end
unless @auto_close unless @auto_close
@db = DB.open "sqlite3://#{@path}" @db = DB.open "sqlite3://#{@path}"
@@ -83,13 +67,37 @@ class Storage
private def get_db(&block : DB::Database ->) private def get_db(&block : DB::Database ->)
if @db.nil? if @db.nil?
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "PRAGMA foreign_keys = 1"
yield db yield db
end end
else else
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
yield @db.not_nil! yield @db.not_nil!
end end
end end
def username_exists(username)
exists = false
MainFiber.run do
get_db do |db|
exists = db.query_one("select count(*) from users where " \
"username = (?)", username, as: Int32) > 0
end
end
exists
end
def username_is_admin(username)
is_admin = false
MainFiber.run do
get_db do |db|
is_admin = db.query_one("select admin from users where " \
"username = (?)", username, as: Int32) > 0
end
end
is_admin
end
def verify_user(username, password) def verify_user(username, password)
out_token = nil out_token = nil
MainFiber.run do MainFiber.run do
@@ -216,32 +224,121 @@ class Storage
end end
end end
def get_id(path, is_title) def get_title_id(path, signature)
id = nil id = nil
path = Path.new(path).relative_to(Config.current.library_path).to_s
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path, # First attempt to find the matching title in DB using BOTH path
as: {String} # and signature
id = db.query_one? "select id from titles where path = (?) and " \
"signature = (?) and unavailable = 0",
path, signature.to_s, as: String
should_update = id.nil?
# If it fails, try to match using the path only. This could happen
# for example when a new entry is added to the title
id ||= db.query_one? "select id from titles where path = (?)", path,
as: String
# If it still fails, we will have to rely on the signature values.
# This could happen when the user moved or renamed the title, or
# a title containing the title
unless id
# If there are multiple rows with the same signature (this could
# happen simply by bad luck, or when the user copied a title),
# pick the row that has the most similar path to the give path
rows = [] of Tuple(String, String)
db.query "select id, path from titles where signature = (?)",
signature.to_s do |rs|
rs.each do
rows << {rs.read(String), rs.read(String)}
end
end
row = rows.max_by?(&.[1].components_similarity(path))
id = row[0] if row
end
# At this point, `id` would still be nil if there's no row matching
# either the path or the signature
# If we did identify a matching title, save the path and signature
# values back to the DB
if id && should_update
db.exec "update titles set path = (?), signature = (?), " \
"unavailable = 0 where id = (?)", path, signature.to_s, id
end
end end
end end
id id
end end
def insert_id(tp : IDTuple) # See the comments in `#get_title_id` to see how this method works.
@insert_ids << tp def get_entry_id(path, signature)
id = nil
path = Path.new(path).relative_to(Config.current.library_path).to_s
MainFiber.run do
get_db do |db|
id = db.query_one? "select id from ids where path = (?) and " \
"signature = (?) and unavailable = 0",
path, signature.to_s, as: String
should_update = id.nil?
id ||= db.query_one? "select id from ids where path = (?)", path,
as: String
unless id
rows = [] of Tuple(String, String)
db.query "select id, path from ids where signature = (?)",
signature.to_s do |rs|
rs.each do
rows << {rs.read(String), rs.read(String)}
end
end
row = rows.max_by?(&.[1].components_similarity(path))
id = row[0] if row
end
if id && should_update
db.exec "update ids set path = (?), signature = (?), " \
"unavailable = 0 where id = (?)", path, signature.to_s, id
end
end
end
id
end
def insert_entry_id(tp)
@@insert_entry_ids << tp
end
def insert_title_id(tp)
@@insert_title_ids << tp
end end
def bulk_insert_ids def bulk_insert_ids
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
db.transaction do |tx| db.transaction do |tran|
@insert_ids.each do |tp| conn = tran.connection
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path], @@insert_title_ids.each do |tp|
tp[:id], tp[:is_title] ? 1 : 0 path = Path.new(tp[:path])
.relative_to(Config.current.library_path).to_s
conn.exec "insert into titles (id, path, signature, " \
"unavailable) values (?, ?, ?, 0)",
tp[:id], path, tp[:signature].to_s
end
@@insert_entry_ids.each do |tp|
path = Path.new(tp[:path])
.relative_to(Config.current.library_path).to_s
conn.exec "insert into ids (id, path, signature, " \
"unavailable) values (?, ?, ?, 0)",
tp[:id], path, tp[:signature].to_s
end end
end end
end end
@insert_ids.clear @@insert_entry_ids.clear
@@insert_title_ids.clear
end end
end end
@@ -266,35 +363,186 @@ class Storage
img img
end end
def optimize def get_title_tags(id : String) : Array(String)
tags = [] of String
MainFiber.run do MainFiber.run do
Logger.info "Starting DB optimization"
get_db do |db| get_db do |db|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def get_tag_titles(tag : String) : Array(String)
tids = [] of String
MainFiber.run do
get_db do |db|
db.query "select id from tags where tag = (?)", tag do |rs|
rs.each do
tids << rs.read String
end
end
end
end
tids
end
def list_tags : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select distinct tag from tags natural join titles " \
"where unavailable = 0" do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def add_tag(id : String, tag : String)
err = nil
MainFiber.run do
begin
get_db do |db|
db.exec "insert into tags values (?, ?)", id, tag
end
rescue e
err = e
end
end
raise err.not_nil! if err
end
def delete_tag(id : String, tag : String)
MainFiber.run do
get_db do |db|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
end
end
end
def mark_unavailable
MainFiber.run do
get_db do |db|
# Detect dangling entry IDs
trash_ids = [] of String trash_ids = [] of String
db.query "select path, id from ids" do |rs| db.query "select path, id from ids where unavailable = 0" do |rs|
rs.each do rs.each do
path = rs.read String path = rs.read String
trash_ids << rs.read String unless File.exists? path fullpath = Path.new(path).expand(Config.current.library_path).to_s
trash_ids << rs.read String unless File.exists? fullpath
end end
end end
# Delete dangling IDs unless trash_ids.empty?
db.exec "delete from ids where id in " \ Logger.debug "Marking #{trash_ids.size} entries as unavailable"
end
db.exec "update ids set unavailable = 1 where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})" "(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
if trash_ids.size > 0
# Delete dangling thumbnails # Detect dangling title IDs
trash_thumbnails_count = db.query_one "select count(*) from " \ trash_titles = [] of String
"thumbnails where id not in " \ db.query "select path, id from titles where unavailable = 0" do |rs|
"(select id from ids)", as: Int32 rs.each do
if trash_thumbnails_count > 0 path = rs.read String
db.exec "delete from thumbnails where id not in (select id from ids)" fullpath = Path.new(path).expand(Config.current.library_path).to_s
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted" trash_titles << rs.read String unless Dir.exists? fullpath
end
end
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.map { |i| "'#{i}'" }.join ","})"
end
end
end
private def get_missing(tablename)
ary = [] of IDTuple
MainFiber.run do
get_db do |db|
db.query "select id, path, signature from #{tablename} " \
"where unavailable = 1" do |rs|
rs.each do
ary << {
id: rs.read(String),
path: rs.read(String),
signature: rs.read(String?),
}
end
end end
end end
Logger.info "DB optimization finished"
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 end
def close def close
+51
View File
@@ -0,0 +1,51 @@
require "./util"
class File
abstract struct Info
def inode : UInt64
@stat.st_ino.to_u64
end
end
# Returns the signature of the file at filename.
# When it is not a supported file, returns 0. Otherwise, uses the inode
# number as its signature. On most file systems, the inode number is
# preserved even when the file is renamed, moved or edited.
# Some cases that would cause the inode number to change:
# - Reboot/remount on some file systems
# - Replaced with a copied file
# - Moved to a different device
# Since we are also using the relative paths to match ids, we won't lose
# information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64
if is_supported_file filename
File.info(filename).inode
else
0u64
end
end
end
class Dir
# Returns the signature of the directory at dirname. See the comments for
# `File.signature` for more information.
def self.signature(dirname) : UInt64
signatures = [File.info(dirname).inode]
self.open dirname do |dir|
dir.entries.each do |fn|
next if fn.starts_with? "."
path = File.join dirname, fn
if File.directory? path
signatures << Dir.signature path
else
_sig = File.signature path
# Only add its signature value to `signatures` when it is a
# supported file
signatures << _sig if _sig > 0
end
end
end
Digest::CRC32.checksum(signatures.sort.join).to_u64
end
end
+54 -1
View File
@@ -1,7 +1,8 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8 ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"] STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
@@ -22,15 +23,27 @@ end
def register_mime_types def register_mime_types
{ {
# Comic Archives
".zip" => "application/zip", ".zip" => "application/zip",
".rar" => "application/x-rar-compressed", ".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip", ".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar", ".cbr" => "application/vnd.comicbook-rar",
# Favicon
".ico" => "image/x-icon",
# FontAwesome fonts
".woff" => "font/woff",
".woff2" => "font/woff2",
}.each do |k, v| }.each do |k, v|
MIME.register k, v MIME.register k, v
end end
end end
def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end
struct Int struct Int
def or(other : Int) def or(other : Int)
if self == 0 if self == 0
@@ -67,3 +80,43 @@ def env_is_true?(key : String) : Bool
return false unless val return false unless val
val.downcase.in? "1", "true" val.downcase.in? "1", "true"
end end
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary = titles
case opt.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
class String
# Returns the similarity (in [0, 1]) of two paths.
# For the two paths, separate them into arrays of components, count the
# number of matching components backwards, and divide the count by the
# number of components of the shorter path.
def components_similarity(other : String) : Float64
s, l = [self, other]
.map { |str| Path.new(str).parts }
.sort_by &.size
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
match / s.size
end
end
+40 -15
View File
@@ -1,30 +1,60 @@
# Web related helper functions/macros # Web related helper functions/macros
def is_admin?(env) : Bool
is_admin = false
if !Config.current.auth_proxy_header_name.empty? ||
Config.current.disable_login
is_admin = Storage.default.username_is_admin get_username env
end
# The token (if exists) takes precedence over other authentication methods.
if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token
end
is_admin
end
macro layout(name) macro layout(name)
base_url = Config.current.base_url base_url = Config.current.base_url
is_admin = is_admin? env
begin begin
is_admin = false
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}} page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e rescue e
message = e.to_s message = e.to_s
@context.error message Logger.error message
page = "Error"
render "src/views/message.html.ecr", "src/views/layout.html.ecr" render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end end
end end
macro send_error_page(msg)
message = {{msg}}
base_url = Config.current.base_url
is_admin = is_admin? env
page = "Error"
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
send_file env, html.to_slice, "text/html"
end
macro send_img(env, img) macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime send_file {{env}}, {{img}}.data, {{img}}.mime
end end
macro get_username(env) macro get_username(env)
# if the request gets here, it has gone through the auth handler, and begin
# we can be sure that a valid token exists, so we can use not_nil! here token = env.session.string "token"
token = env.session.string "token" (Storage.default.verify_token token).not_nil!
(@context.storage.verify_token token).not_nil! rescue e
if Config.current.disable_login
Config.current.default_username
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
env.request.headers[header]
else
raise e
end
end
end end
def send_json(env, json) def send_json(env, json)
@@ -46,12 +76,7 @@ def hash_to_query(hash)
end end
def request_path_startswith(env, ary) def request_path_startswith(env, ary)
ary.each do |prefix| ary.any? { |prefix| env.request.path.starts_with? prefix }
if env.request.path.starts_with? prefix
return true
end
end
false
end end
def requesting_static_file(env) def requesting_static_file(env)
+28 -15
View File
@@ -1,26 +1,39 @@
<ul class="uk-list uk-list-large uk-list-divider" id="root" x-data="{progress : 1.0, generating : false, scanTitles: 0, scanMs: -1, scanning : false}"> <ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li @click="location.href = '<%= base_url %>admin/user'">User Managerment</li> <li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li :class="{'nopointer' : scanning}" @click="scan()"> <li>
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span> <a class="uk-link-reset" href="<%= base_url %>admin/missing">Missing Items</a>
<div class="uk-align-right"> <% if missing_count > 0 %>
<div uk-spinner x-show="scanning"></div> <div class="uk-align-right">
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span> <span class="uk-badge"><%= missing_count %></span>
</div> </div>
<% end %>
</li> </li>
<li :class="{'nopointer' : generating}" @click="generateThumbnails()"> <li>
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span> <a class="uk-link-reset" @click="scan()">
<div class="uk-align-right"> <span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span> <div class="uk-align-right">
</div> <div uk-spinner x-show="scanning"></div>
<span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</div>
</a>
</li> </li>
<li class="nopointer"> <li>
<a class="uk-link-reset" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
</a>
</li>
<li>
<span>Theme</span> <span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2"> <select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :value="themeSetting" @change="themeChanged($event)">
<option>Dark</option> <option>Dark</option>
<option>Light</option> <option>Light</option>
<option>System</option> <option>System</option>
</select> </select>
</li> </li>
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
</ul> </ul>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>
<redoc spec-url="/openapi.json"></redoc> <redoc spec-url="<%= base_url %>openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script> <script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -76,7 +76,7 @@
<% end %> <% end %>
<% if item.is_a? Title %> <% if item.is_a? Title %>
<% if grouped_count == 1 %> <% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.size %> entries</p> <p class="uk-text-meta"><%= item.content_label %></p>
<% else %> <% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p> <p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %> <% end %>
+4 -7
View File
@@ -1,18 +1,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge"> <meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title> <title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader"> <meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico"> <link rel="icon" href="<%= base_url %>favicon.ico">
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script> <script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script> <script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script> <script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/common.js"></script> <script src="<%= base_url %>js/common.js"></script>
</head> </head>
+1
View File
@@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
+1
View File
@@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
+2
View 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>
+7 -7
View File
@@ -1,4 +1,4 @@
<div id="root" x-data="{jobs: [], paused: undefined, loading: false, toggling: false}" x-init="load()"> <div x-data="component()" x-init="init()">
<div class="uk-margin"> <div class="uk-margin">
<button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
@@ -25,14 +25,14 @@
<td x-text="job.title"></td> <td x-text="job.title"></td>
</template> </template>
<template x-if="!job.plugin_id"> <template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td> <td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template> </template>
<template x-if="job.plugin_id"> <template x-if="job.plugin_id">
<td x-text="job.manga_title"></td> <td x-text="job.manga_title"></td>
</template> </template>
<template x-if="!job.plugin_id"> <template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td> <td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template> </template>
<td x-text="`${job.success_count}/${job.pages}`"></td> <td x-text="`${job.success_count}/${job.pages}`"></td>
@@ -43,7 +43,7 @@
<template x-if="job.status_message.length > 0"> <template x-if="job.status_message.length > 0">
<div class="uk-inline"> <div class="uk-inline">
<span uk-icon="info"></span> <span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message"></div> <div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div> </div>
</template> </template>
</td> </td>
@@ -51,9 +51,9 @@
<td x-text="`${job.plugin_id || ''}`"></td> <td x-text="`${job.plugin_id || ''}`"></td>
<td> <td>
<a :onclick="`jobAction('delete', '${job.id}')`" uk-icon="trash"></a> <a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0"> <template x-if="job.status_message.length > 0">
<a :onclick="`jobAction('retry', '${job.id}')`" uk-icon="refresh"></a> <a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template> </template>
</td> </td>
</tr> </tr>
@@ -63,7 +63,7 @@
</div> </div>
<% content_for "script" do %> <% 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/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
<% end %> <% end %>
+144 -65
View File
@@ -1,83 +1,162 @@
<h2 class=uk-title>Download from MangaDex</h2> <h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid> <div x-data="downloadComponent()" x-init="init()">
<div class="uk-width-3-4"> <div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL"> <div class="uk-width-expand">
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-auto">
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
</div>
</div> </div>
<div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div> <template x-if="mangaAry">
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button> <div>
</div> <p x-show="mangaAry.length === 0">No matching manga found.</p>
</div>
<div class"uk-grid-small" uk-grid hidden id="manga-details"> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-width-1-4@s"> <template x-for="manga in mangaAry" :key="manga.id">
<img id="cover"> <div class="item" :data-id="manga.id" @click="chooseManga(manga)">
</div> <div class="uk-card uk-card-default">
<div class="uk-width-1-4@s"> <div class="uk-card-media-top uk-inline">
<p id="title"></p> <img uk-img :data-src="manga.mainCover">
<p id="artist"></p> </div>
<p id="author"></p> <div class="uk-card-body">
</div> <h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden> <p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p> </div>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p> </div>
<div class="uk-margin"> </div>
<label class="uk-form-label" for="lang-select">Language</label> </template>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select">
</select>
</div> </div>
</div> </div>
<div class="uk-margin"> </template>
<label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls"> <div x-show="data && data.chapters" x-cloak>
<select class="uk-select filter-field" id="group-select"> <div class"uk-grid-small" uk-grid>
</select> <div class="uk-width-1-4@s">
<img :src="data.mainCover">
</div>
<div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<p x-text="`Artist: ${data.artist}`"></p>
<p x-text="`Author: ${data.author}`"></p>
</div>
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<div class="uk-margin">
<label class="uk-form-label">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<template x-for="lang in languages" :key="lang">
<option x-text="lang"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label> <div class="uk-margin">
<div class="uk-form-controls"> <button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty."> <button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</div> </div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div> </div>
<div class="uk-margin"> <p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<label class="uk-form-label" for="chapter-range">Chapter</label> <table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<div class="uk-form-controls"> <thead>
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty."> <tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
<div id="modal" class="uk-flex-top" uk-modal="container: false">
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
</div>
<div class="uk-modal-body">
<div class="uk-grid">
<div class="uk-width-1-3@s">
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
</div>
<div class="uk-width-2-3@s" uk-overflow-auto>
<p x-text="candidateManga.description"></p>
</div>
</div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div> </div>
</div> </div>
</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 %> <% content_for "script" do %>
<script> <%= render_component "moment" %>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); <%= render_component "jquery-ui" %>
</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/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script> <script src="<%= base_url %>js/download.js"></script>
<% end %> <% end %>
+1 -1
View File
@@ -77,7 +77,7 @@
<%- end -%> <%- end -%>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<% end %> <% end %>
+79 -79
View File
@@ -1,89 +1,89 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
<div class="uk-offcanvas-content"> <div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true"> <div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav> <ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %> <li><a href="<%= base_url %>tags">Tags</a></li>
<li><a href="<%= base_url %>admin">Admin</a></li> <% if is_admin %>
<li class="uk-parent"> <li><a href="<%= base_url %>admin">Admin</a></li>
<a href="#">Download</a> <li class="uk-parent">
<ul class="uk-nav-sub"> <a href="#">Download</a>
<li><a href="<%= base_url %>download">MangaDex</a></li> <ul class="uk-nav-sub">
<li><a href="<%= base_url %>download/plugins">Plugins</a></li> <li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li> <li><a href="<%= base_url %>download/plugins">Plugins</a></li>
</ul> <li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</li> </ul>
<% end %> </li>
<hr uk-divider> <% end %>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <hr uk-divider>
<li><a href="<%= base_url %>logout">Logout</a></li> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
</ul> <li><a href="<%= base_url %>logout">Logout</a></li>
</div> </ul>
</div> </div>
</div>
</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-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"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<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>
</ul>
</div> </div>
</li> </div>
<% end %>
</ul>
</div> </div>
<div class="uk-navbar-right uk-visible@s"> <div class="uk-position-top">
<ul class="uk-navbar-nav"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <div class="uk-navbar-left uk-hidden@s">
<li><a href="<%= base_url %>logout">Logout</a></li> <div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</ul> </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>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<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>
</ul>
</div>
</li>
<% end %>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<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>
</ul>
</div>
</div>
</div> </div>
</div> <div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small" id="main-section">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div> </div>
</div> <div class="uk-section uk-section-small" style="position:relative;">
</div> <div class="uk-container uk-container-small">
<script> <div id="alert"></div>
setTheme(); <%= content %>
const base_url = "<%= base_url %>"; <div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
</script> <a href="#" uk-totop uk-scroll></a>
<script src="<%= base_url %>js/uikit.min.js"></script> </div>
<script src="<%= base_url %>js/uikit-icons.min.js"></script> </div>
</div>
<%= yield_content "script" %> <script>
</body> setTheme();
const base_url = "<%= base_url %>";
</script>
<%= render_component "uikit" %>
<%= yield_content "script" %>
</body>
</html> </html>
+1 -1
View File
@@ -24,7 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "dots-scripts" %> <%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="<%= base_url %>js/sort-items.js"></script>
<% end %> <% end %>
+2 -2
View File
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<% page = "Login" %>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
@@ -29,8 +30,7 @@
<script> <script>
setTheme(); setTheme();
</script> </script>
<script src="<%= base_url %>js/uikit.min.js"></script> <%= render_component "uikit" %>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
</body> </body>
</html> </html>
+39
View File
@@ -0,0 +1,39 @@
<div x-data="component()" x-init="init()">
<h2 class="uk-title">Connect to MangaDex</h2>
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
<div class="uk-width-1-2@s" x-show="!expires">
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
<ul>
<li>Search MangaDex by search terms in addition to manga IDs</li>
<li>Automatically download new chapters when they are available (coming soon)</li>
</ul>
</div>
<div class="uk-width-1-2@s" x-show="expires">
<p>
<span x-show="!expired">You have logged in to MangaDex!</span>
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
<span x-show="!expired">If the integration is not working, you</span>
<span x-show="expired">You</span>
can log in again and the token will be updated.
</p>
</div>
<div class="uk-width-1-2@s">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/mangadex.js"></script>
<% end %>
+40
View 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 -1
View File
@@ -68,7 +68,7 @@
var pid = "<%= plugin.not_nil!.info.id %>"; var pid = "<%= plugin.not_nil!.info.id %>";
</script> </script>
<% end %> <% end %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script> <%= render_component "jquery-ui" %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script> <script src="<%= base_url %>js/plugin-download.js"></script>
+1 -3
View File
@@ -21,9 +21,7 @@
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
UIkit.modal('#modal').show().then(function() { UIkit.modal('#modal').show();
styleModal();
});
UIkit.util.on('#modal', 'hide', function() { UIkit.util.on('#modal', 'hide', function() {
location.href = "<%= base_url %>book/<%= entry.book.id %>"; location.href = "<%= base_url %>book/<%= entry.book.id %>";
}); });
+55 -40
View File
@@ -1,21 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="reader-bg"> <html class="reader-bg">
<% page = "Reader" %>
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;"> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
id="root"
:style="mode === 'continuous' ? '' : 'padding:0'"
x-data="{
loading: true,
mode: 'continuous', // can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null
}">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@@ -29,37 +19,38 @@
</div> </div>
<div <div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}"> :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak> <div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items"> <template x-for="item in items">
<img <img
uk-img uk-img
class="uk-align-center" :class="{'uk-align-center': true, 'spine': item.width < 50}"
:data-src="item.url" :style="item.style"
:width="item.width" :data-src="item.url"
:height="item.height" :width="item.width"
:id="item.id" :height="item.height"
:onclick="`showControl('${item.id}')`" :id="item.id"
/> @click="showControl($event)"
/>
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%> <%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
<%- end -%> <%- end -%>
</div> </div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh"> <div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{ <img uk-img :class="{
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
`" /> `" />
<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;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;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
@@ -77,12 +68,12 @@
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <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>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to Page</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="page-select" class="uk-select"> <select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option> <option value="<%= p %>"><%= p %></option>
<%- end -%> <%- end -%>
@@ -92,35 +83,59 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label> <label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="mode-select" class="uk-select"> <select id="mode-select" class="uk-select" @change="modeChanged($nextTick)">
<option value="continuous">Continuous</option> <option value="continuous">Continuous</option>
<option value="paged">Paged</option> <option value="paged">Paged</option>
</select> </select>
</div> </div>
</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>
<div class="uk-modal-footer uk-text-right"> <div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button> <% if previous_entry_url %>
<a class="uk-button uk-button-default 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-right" href="<%= next_entry_url %>">Next Entry</a>
<% end %>
<a class="uk-button uk-button-danger" href="<%= exit_url %>">Exit Reader</a>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>"; const base_url = "<%= base_url %>";
const page = <%= page %>; const page = <%= page_idx %>;
const tid = "<%= title.id %>"; const tid = "<%= title.id %>";
const eid = "<%= entry.id %>"; const eid = "<%= entry.id %>";
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<%= render_component "uikit" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script> <script src="<%= base_url %>js/reader.js"></script>
</body> </body>
<style> <style>
img[data-src][src*='data:image'] { background: white; } img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; } img:not(.spine) { width: 100%; }
.reader-bg { background: black; }
</style> </style>
</html> </html>
+30
View File
@@ -0,0 +1,30 @@
<h2 class=uk-title>Tag: <%= tag %></h2>
<p class="uk-text-meta"><%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> tagged</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>
+8
View File
@@ -0,0 +1,8 @@
<h2 class=uk-title>Tags</h2>
<p class="uk-text-meta"><%= tags.size %> <%= tags.size > 1 ? "tags" : "tag" %> found</p>
<% tags.each do |tag| %>
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" href="<%= base_url %>tags/<%= tag[:encoded_tag] %>"><%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>)</a>
</span>
<% end %>
+11 -2
View File
@@ -32,7 +32,13 @@
<%- end -%> <%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li> <li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul> </ul>
<p class="uk-text-meta"><%= title.size %> entries found</p> <p class="uk-text-meta"><%= title.content_label %> found</p>
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)" x-show="!loading">
<select class="tag-select" multiple="multiple" style="width:100%">
</select>
</div>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s"> <div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default"> <form class="uk-search uk-search-default">
@@ -117,7 +123,10 @@
</div> </div>
<% content_for "script" do %> <% 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="<%= base_url %>css/tags.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>