Compare commits

...

331 Commits

Author SHA1 Message Date
Alex Ling
7a7d9eb3a1
Update README.md 2025-03-23 16:16:02 +07:00
Alex Ling
1fb48648ad
Merge pull request #322 from getmango/rc/0.27.0
v0.27.0
2022-07-31 22:53:11 +08:00
Alex Ling
7ceb91f051 Merge branch 'rc/0.27.0' into dev 2022-07-31 13:55:02 +00:00
Alex Ling
9ea4ced729
Merge pull request #327 from phlhg/fix/static-manifest
Fix for Error 404 on manifest.json
2022-07-31 21:54:06 +08:00
Alex Ling
4c2f802e2e Fix linter 2022-07-31 10:19:21 +00:00
Philippe Hugo
7258b3cece
Add /manifest.json to static files 2022-07-27 17:59:11 +02:00
Alex Ling
bf885a8b30 Bump version to 0.27.0 2022-07-18 12:38:22 +00:00
Alex Ling
98a0c54499
Merge pull request #311 from hkalexling/fix/hide-subscribe-btn
Hide subscribe btn
2022-07-18 20:03:10 +08:00
Alex Ling
cb3df432d0
Merge branch 'dev' into fix/hide-subscribe-btn 2022-07-18 19:42:23 +08:00
Alex Ling
47af6ee284
Merge pull request #321 from hkalexling/fix/plugin-use-html-parser
Use html parser in plugin helper functions
2022-07-18 19:41:40 +08:00
Alex Ling
9fe269ab13 Disable plugin_spec.cr line limit 2022-07-17 15:24:01 +00:00
Alex Ling
75a30a88e0 Use myhtml in plugin helper and add tests (#320) 2022-07-17 14:54:25 +00:00
Alex Ling
5daeac72cb
Merge pull request #317 from Hiers/feature/image-fit
Fit image options
2022-07-17 11:55:35 +08:00
Hiers
dc3ac42dec Right flip panels are 1/3 of the rightmost area of the entire screen, not of the page. (same for left flip panels) 2022-07-16 12:09:23 +01:00
Hiers
624283643c Fixed right flip panel not being all the way on the right; changed real image size option to not be hard coded. 2022-07-13 14:20:43 +01:00
Hiers
6ddbe8d436 Changed setFit function to not have redundant ifs and a better comment explaining what it does. 2022-07-07 08:55:54 +01:00
Hiers
db5e99b3f0 Fix in reader.html.ecr. 2022-07-05 22:24:31 +01:00
Hiers
405b958deb First draft of image fit. 2022-07-05 22:01:21 +01:00
Alex Ling
e7c4123dec
Merge pull request #315 from Leeingnyo/fix/rescan-when-files-added
Fix Dir.contents_signature to detect valid image files
2022-07-03 15:59:49 +08:00
Alex Ling
2d2486a598
Merge branch 'dev' into fix/rescan-when-files-added 2022-07-03 15:44:02 +08:00
Alex Ling
b6a1ad889e
Merge pull request #314 from crainte/feature/default-env-vars
Allow config defaults to be sourced from ENV
2022-07-03 15:39:47 +08:00
Alex Ling
f2d6d28a72 Define properties with macro 2022-07-03 07:24:33 +00:00
Alex Ling
49425ff714 Merge branch 'feature/default-env-vars' of https://github.com/crainte/Mango into feature/default-env-vars 2022-07-03 06:31:06 +00:00
Chris Alexander
f3eb62a271
Disable line length warnings 2022-06-27 09:30:04 -05:00
Chris Alexander
2e91028ead
Allow config defaults to be sourced from ENV
This allows the default config to source values from ENV variables if
they are set. With this change we don't have to modify the docker CMD or
edit the config.yml and then relaunch.
2022-06-27 09:30:04 -05:00
Alex Ling
19a8f3100b
Merge branch 'dev' into fix/rescan-when-files-added 2022-06-26 11:52:15 +08:00
Alex Ling
3b5e764d36
Merge pull request #312 from tr7zw/jxl-support
Add Jxl support
2022-06-18 19:43:30 +08:00
Alex Ling
32ce26a133
Merge branch 'dev' into jxl-support 2022-06-18 19:26:09 +08:00
Alex Ling
31df058f81 Comment about infinity average ratio 2022-06-18 11:25:20 +00:00
Alex Ling
fe440d82d4 Fix linter issue 2022-06-18 11:10:14 +00:00
Alex Ling
44636e051e
Merge pull request #310 from torta/feature/greedy-continue-reading
Feature/greedy continue reading
2022-06-18 18:38:20 +08:00
Alex Ling
a639392ca0 Update comment 2022-06-18 10:22:25 +00:00
Leeingnyo
17a9c8ecd3 pass lint 2022-06-18 18:51:33 +09:00
Leeingnyo
bbc0c2cbb7 Fix Dir.contents_signature to detect valid image files added 2022-06-18 17:43:57 +09:00
Chris Alexander
be46dd1f86
Allow config defaults to be sourced from ENV
This allows the default config to source values from ENV variables if
they are set. With this change we don't have to modify the docker CMD or
edit the config.yml and then relaunch.
2022-06-15 12:03:40 -05:00
tr7zw
ae583cf2a9 Workaround for "0 width/height" api responses
This needs a more proper fix probably.
2022-06-07 16:09:02 +02:00
tr7zw
ea35faee91 Add jxl support 2022-06-07 00:28:41 +02:00
Alex Ling
5b58d8ac59 Clear page when switching plugins 2022-06-05 12:40:45 +00:00
Alex Ling
30d5ad0c19 Hide subscribe button when not subscribable 2022-06-05 12:33:26 +00:00
torta
d9dce4a881 Fix Continue Reading not show missed reading chapter if the latest chapter mark as read 2022-06-05 19:29:49 +08:00
Alex Ling
2d97faa7c0
Merge pull request #305 from Leeingnyo/feature/unzipped-entry
Support unzipped entry
2022-06-05 16:23:57 +08:00
Leeingnyo
9ce8e918f0 Replace to is_valid? 2022-06-04 00:26:46 +09:00
Leeingnyo
8e4bb995d3 Add zip_path to API document, add path property 2022-06-04 00:18:45 +09:00
Alex Ling
39a331c879 Avoid not_nil in date_added 2022-05-29 05:44:11 +00:00
Alex Ling
df618704ea Fix linter 2022-05-29 05:28:50 +00:00
Alex Ling
2fb620211d Choose correct subclass based on YAML node 2022-05-29 05:24:41 +00:00
Alex Ling
5b23a112b2 Remove unnecessary path method 2022-05-22 05:17:05 +00:00
Alex Ling
e6dbeb623b Use is_valid? 2022-05-22 05:12:43 +00:00
Alex Ling
872e6dc6d6 Better method naming in DirEntry 2022-05-22 04:20:14 +00:00
Alex Ling
82c60ccc1d Replace puts with Logger.debug 2022-05-22 04:04:40 +00:00
Alex Ling
ae503ae099 Remove unnecessary createtime method 2022-05-22 02:54:05 +00:00
Alex Ling
648cdd772c Add back zip_path for backward compatibility 2022-05-22 02:48:06 +00:00
Leeingnyo
238539c27d Split files 2022-05-20 14:21:08 +09:00
Leeingnyo
1f5aed64f7 Rename Entries to ArchiveEntry and DirEntry 2022-05-20 09:51:56 +09:00
Alex Ling
f18f6a5418 Fix linter issues 2022-05-19 12:41:07 +00:00
Leeingnyo
0ed565519b Rollback crystal format 2022-05-15 17:38:21 +09:00
Leeingnyo
3da5d9ba4e Fix contents_signature 2022-05-15 17:36:57 +09:00
Leeingnyo
3a60286c3a Run 'crystal tool format' 2022-05-15 17:02:29 +09:00
Leeingnyo
9f6be70995 Rename Entry.exists? to Entry.examine 2022-05-15 16:28:53 +09:00
Leeingnyo
caf4cfb6cd Fix Entry.new in YAML::Serializable to support DirectyEntry
so hacky
2022-05-15 16:12:43 +09:00
Leeingnyo
137e84dfb6 Fix caching policy
Before rendering it, the Mango reader should check the E-Tag of page
or it renders wrong image when an image file is moved/removed/reordered
2022-05-15 16:12:31 +09:00
Leeingnyo
3b3a0738e8 Scan DirectoryEntry when init and examine 2022-05-15 16:12:31 +09:00
Leeingnyo
55ccd928a2 Implement DirectoryEntry 2022-05-15 16:12:31 +09:00
Leeingnyo
10587f48cb Implement is_supported_image_file 2022-05-15 16:12:31 +09:00
Leeingnyo
ea6cbbd9ce Split Entry and ZippedEntry, Fix to work anyway
make Entry an abstract class
2022-05-15 05:41:25 +09:00
Alex Ling
883e01bbdd
Merge pull request #302 from Leeingnyo/fix/preload-bug
Fix preload bug
2022-05-13 20:11:20 +08:00
Alex Ling
5f59b7ee42
Merge branch 'dev' into fix/preload-bug 2022-05-13 19:51:26 +08:00
Alex Ling
eac274a211
Merge pull request #301 from Leeingnyo/feature/show-control-at-end-in-paged-mode
Show control after reading all (in paged mode)
2022-05-13 19:47:57 +08:00
Leeingnyo
0e4169cb22 Fix preload bug
cause index error
2022-05-13 08:43:25 +09:00
Leeingnyo
28656695c6 Show control after reading at the end in paged mode 2022-05-13 08:35:04 +09:00
Alex Ling
61dc92838a
Merge pull request #294 from hkalexling/rc/0.26.2
v0.26.2
2022-04-18 18:46:11 +08:00
Alex Ling
ce1dcff229 Bump version to 0.26.2 2022-04-18 09:41:36 +00:00
Alex Ling
4f599fb719 Add back accidentally deleted OPDS routes
Resolves https://github.com/hkalexling/Mango/issues/255#issuecomment-1097588181
2022-04-18 08:49:09 +00:00
Alex Ling
c831879c23
Merge pull request #293 from hkalexling/rc/0.26.1
v0.26.1
2022-04-04 22:11:24 +08:00
Alex Ling
171b44643c Bump version to 0.26.1 2022-04-04 13:33:03 +00:00
Alex Ling
a353029fcd Merge branch 'master' into dev 2022-04-04 13:20:36 +00:00
Alex Ling
75e26d8624
Merge pull request #292 from hkalexling/fix/sanitize-html
Sanitize parameters on user edit page (fixes #289)
2022-04-04 21:16:44 +08:00
Alex Ling
ebe2c8efed Sanitize parameters on user edit page (fixes #289) 2022-04-04 03:20:52 +00:00
Alex Ling
b8ce1cc7f1
Merge pull request #286 from hkalexling/rc/0.26.0
v0.26.0
2022-04-03 18:41:14 +08:00
Alex Ling
24c90e7283 Update README config example 2022-03-28 14:17:54 +00:00
Alex Ling
9ffc34e8e6 Bump version to 0.26.0 2022-03-28 14:14:17 +00:00
Alex Ling
d1de8b7a4e Include admin info in /api/signin response 2022-03-23 06:05:12 +00:00
Alex Ling
7ae0577e4e Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2022-03-22 16:08:48 +00:00
Alex Ling
e9b1bccbc9 Fix schema for the parents field 2022-03-22 16:08:16 +00:00
Alex Ling
293fb84e1d Formatting 2022-03-22 16:04:39 +00:00
Alex Ling
9c07944390 Add endpoints for home page
- `/api/library/continue_reading`
- `/api/library/start_reading`
- `/api/library/recently_added`
2022-03-22 16:01:37 +00:00
Alex Ling
173d69eb26 Upgrade Koa 2022-03-22 15:59:56 +00:00
Alex Ling
21d8d0e8a7 Optionally include reading progress in response 2022-03-22 12:58:37 +00:00
Alex Ling
61e85dd49f Include archive error messages in API response 2022-03-22 11:42:25 +00:00
Alex Ling
c778364ca2 Formatting 2022-03-22 11:42:16 +00:00
Alex Ling
7ecdb1c0dd API sorting improvements:
- Add endpoints for getting/updating sorting methods
- Results from library and title endpoints are now sorted
2022-03-22 10:46:38 +00:00
Alex Ling
a5a7396edd Fix CORS allowed methods 2022-03-22 10:46:09 +00:00
Alex Ling
461398d219
Feature/plugin v2 (#284)
* Add "title_title" to slim JSON

* WIP

* WIP

* WIP

* WIP

* Add plugin subscription types

* Revert "Subscription manager"

This reverts commit a612500b0fabf7259a5ee0c841b0157d191e5bdd.

* Use auto overflow tables

cherry-picked from a612500b0fabf7259a5ee0c841b0157d191e5bdd

* Add endpoint for plugin subscription

* WIP

* WIP

* Simplify subscription JSON parsing

* Remove MangaDex files that are no longer needed

* Fix linter

* Refactor date filtering and use native date picker

* Delete unnecessary raise for debugging

* Subscription management API endpoints

* Store manga ID with subscriptions

* Add subscription manager page (WIP)

* Finish subscription manager page

* WIP

* Finish plugin updater

* Base64 encode chapter IDs

* Fix actions on download manager

* Trigger subscription update from manager page

* Fix timestamp precision issue in plugin

* Show target API version

* Update last checked from manager page

* Update last checked even when no chapters found

* Fix null pid

* Clean up

* Document the subscription endpoints

* Fix BigFloat conversion issue

* Confirmation before deleting subscriptions

* Reset table sort options

* Show manga title on subscription manager
2022-03-22 16:30:01 +08:00
Alex Ling
0d52544617 Use sessid and not token and fix get_username 2022-03-21 03:41:24 +00:00
Alex Ling
c3736d222c Fix long line 2022-03-20 10:01:44 +00:00
Alex Ling
2091053221 Allow CORS 2022-03-20 09:57:36 +00:00
Alex Ling
703e6d076b Allow authentication through bearer token 2022-03-20 09:57:10 +00:00
Alex Ling
1817efe608 Fix icon transparency issue 2022-03-17 16:21:06 +00:00
Alex Ling
8814778c22 Add error handling on read_page (fixes #281) 2022-03-12 14:18:08 +00:00
Alex Ling
6ab885499c Use smaller icons on web UI 2022-03-11 14:04:28 +00:00
Alex Ling
91561ecd6b Add simple manifest.json (closes #262) 2022-03-11 13:44:16 +00:00
Alex Ling
3c399fac4e Add error handling on admin page (fixes #274) 2022-02-21 13:25:55 +00:00
Alex Ling
a101526672
Merge pull request #271 from hkalexling/rc/0.25.0
v0.25.0
2022-02-12 12:55:35 +08:00
Alex Ling
eca47e3d32 Update README config example 2022-02-11 14:28:05 +00:00
Alex Ling
ab3386546d Remove db_optimization from README 2022-02-06 06:39:59 +00:00
Alex Ling
857c11be85 Enable metadata cache by default 2022-02-06 06:39:46 +00:00
Alex Ling
b3ea3c6154 Remove unnecessary type restrictions in config 2022-02-06 06:28:39 +00:00
Alex Ling
84168b4f53 Update config example in README 2022-02-06 06:28:10 +00:00
Alex Ling
59528de44d Remove mangadex entry from config 2022-02-06 06:18:09 +00:00
Alex Ling
a29d6754e8 Expand paths in config (closes #277) 2022-02-06 06:17:42 +00:00
Alex Ling
167e207fad Bump version to 0.25.0 2022-01-26 12:12:01 +00:00
Alex Ling
3b52d72ebf Merge branch 'master' into rc/0.25.0 2022-01-26 12:11:06 +00:00
Alex Ling
dc5edc0c1b
Merge pull request #272 from hkalexling/all-contributors/add-nduja
docs: add nduja as a contributor for code
2022-01-26 20:09:32 +08:00
allcontributors[bot]
7fa8ffa0bd
docs: update .all-contributorsrc [skip ci] 2022-01-26 11:52:12 +00:00
allcontributors[bot]
85b57672e6
docs: update README.md [skip ci] 2022-01-26 11:52:11 +00:00
Alex Ling
9b111b0ee8 Ignore thumbnail progress in cache (fixes #270) 2022-01-26 09:10:47 +00:00
Alex Ling
8b1c301950
Merge pull request #269 from hkalexling/all-contributors/add-BradleyDS2
docs: add BradleyDS2 as a contributor for doc
2022-01-25 14:01:02 +08:00
allcontributors[bot]
3df4675dd7
docs: update .all-contributorsrc [skip ci] 2022-01-25 05:14:09 +00:00
allcontributors[bot]
312de0e7b5
docs: update README.md [skip ci] 2022-01-25 05:14:08 +00:00
Alex Ling
d57ccc8f81
Merge pull request #264 from BradleyDS2/patch-1
Update README.md
2022-01-25 13:13:59 +08:00
Alex Ling
fea6c04c4f Fix actions on download manager (fixes #266) 2022-01-24 14:24:35 +00:00
Alex Ling
77df418390 Compare with DB when loading library cache (fixes #256) 2022-01-24 14:18:52 +00:00
Alex Ling
750fbbb8fe Delete cache when dir mismatch (fixes #265) 2022-01-24 13:25:55 +00:00
BradleyDS2
cfe46b435d
Update README.md
Fix typo: 'thrid' to 'third'
2022-01-24 00:08:18 +11:00
Alex Ling
b2329a79b4 Gracefully handle nullish fields 2022-01-18 15:02:16 +00:00
Alex Ling
2007f13ed6
Merge pull request #259 from Leeingnyo/feature/custom-sort-title-and-sorting-titles
Implement custom sort title and sorting titles
2022-01-15 20:56:16 +08:00
Alex Ling
f70be435f9
Merge branch 'dev' into feature/custom-sort-title-and-sorting-titles 2022-01-15 20:30:56 +08:00
Alex Ling
1b32dc3de9 Add sort title to API response 2022-01-15 12:09:03 +00:00
Alex Ling
b83ccf1ccc Fix down SQL 2022-01-15 12:08:23 +00:00
Alex Ling
a68783aa21
Merge pull request #261 from nduja/feature/right-to-left
Feature/right to left
2022-01-06 16:54:47 +08:00
Robbo
86beed0c5f Cast RightToLeft value to boolean when retrieving from local storage. 2022-01-05 19:04:40 +11:00
Robbo
b6c8386caf
Merge branch 'dev' into feature/right-to-left 2022-01-04 21:01:56 +11:00
Robbo
27cc669012 Fix Right to Left for keyboard input 2022-01-04 20:43:55 +11:00
Robbo
4b302af2a1 Add Right to Left option to Paged viewing mode 2022-01-04 00:20:52 +11:00
Alex Ling
ab29a9eb80 Fix down SQL for removing columns 2021-12-31 14:33:49 +00:00
Leeingnyo
e7538bb7f2 Use val(), Remove callbacks after modal hidden 2021-12-26 05:56:45 +09:00
Leeingnyo
ecaec307d6 Fix title sort bug, invalidate titles of the Library
Refactor remove cache
2021-12-26 05:15:21 +09:00
Leeingnyo
b711072492 Fix lint 2021-12-26 04:10:03 +09:00
Leeingnyo
0f94288bab Avoid N+1 queries problem 2021-12-26 03:29:41 +09:00
Leeingnyo
bd2ed1b338 Implement to restore a display name with an empty input
Add a placeholder for the default display name
Remove some console.log() callings
2021-12-26 03:29:23 +09:00
Leeingnyo
1cd777d27d Cache sorted titles 2021-12-26 02:56:57 +09:00
Leeingnyo
1ec8dcbfda Use sort_title, sort_titles in title page 2021-12-26 02:55:52 +09:00
Leeingnyo
8fea35fa51 Use sorted_titles 2021-12-26 00:27:00 +09:00
Leeingnyo
234b29bbdd Fix save 2021-12-25 23:05:12 +09:00
Leeingnyo
edfef80e5c Invalidate sort result cache after change sort_title 2021-12-25 23:05:12 +09:00
Leeingnyo
45ffa3d428 Implement UI to edit sort title 2021-12-25 23:05:12 +09:00
Leeingnyo
162318cf4a Add Api call 2021-12-25 23:05:12 +09:00
Leeingnyo
d4b58e91d1 Implement sort title api 2021-12-25 22:43:35 +09:00
Leeingnyo
546bd0138c Use sort_title instead of title 2021-12-25 22:43:35 +09:00
Leeingnyo
ab799af866 Implement sort_title getter, setter 2021-12-25 22:43:35 +09:00
Leeingnyo
3a932d7b0a Add column 'sort_title' to titles, ids table 2021-12-25 21:49:30 +09:00
Leeingnyo
57683d1cfb Add sort option "Name" for title 2021-12-24 16:44:07 +09:00
Alex Ling
d7afd0969a
Merge pull request #258 from Leeingnyo/fix/fix-bug-on-scan
Fix bug on scanning
2021-12-22 20:33:12 +08:00
Alex Ling
4eda55552b Linter fix 2021-12-22 12:16:44 +00:00
Leeingnyo
f9254c49a1 Fix lint error 2021-12-19 17:14:47 +09:00
Leeingnyo
6d834e9164 Fix formatting 2021-12-19 17:03:39 +09:00
Leeingnyo
70259d8e50 Do same with an entry 2021-12-19 17:03:10 +09:00
Leeingnyo
0fa2bfa744 Fix bug on examine 2021-12-19 16:40:38 +09:00
Leeingnyo
cc33fa6595 Fix bug: remove titles not in root library anymore 2021-12-19 16:31:43 +09:00
Alex Ling
921628ba6d Limit max length in download table (fixes #244) 2021-11-17 13:10:44 +00:00
Alex Ling
1199eb7a03 Use mobile menu at @m (fixes #246) 2021-11-16 13:37:19 +00:00
Alex Ling
f075511847
Merge pull request #245 from hkalexling/feature/login-api
Add endpoint `/api/login`
2021-10-11 13:20:34 +08:00
Alex Ling
80344c3bf0 Add endpoint /api/login 2021-10-08 10:07:40 +00:00
Alex Ling
8a732804ae
Merge pull request #232 from hkalexling/rc/0.24.0
v0.24.0
2021-09-25 13:39:48 +08:00
Alex Ling
9df372f784 Merge branch 'dev' into rc/0.24.0 2021-09-23 07:52:58 +00:00
Alex Ling
cf7431b8b6
Merge pull request #236 from hkalexling/fix/shallow-api-fix
Use `depth` instead of `shallow` in API
2021-09-23 15:52:14 +08:00
Alex Ling
974b6cfe9b Use depth instead of shallow in API 2021-09-22 09:17:14 +00:00
Alex Ling
4fbe5b471c Update README 2021-09-20 01:36:31 +00:00
Alex Ling
33e7e31fbc Bump version to 0.24.0 2021-09-20 01:11:26 +00:00
Alex Ling
72fae7f5ed Fix typo cbz -> gz 2021-09-18 12:41:25 +00:00
Alex Ling
f50a7e3b3e Merge branch 'dev' into rc/0.24.0 2021-09-18 12:40:56 +00:00
Alex Ling
66c4037f2b
Merge pull request #233 from Leeingnyo/feature/avoid-unnecessary-sort
Avoid a unnecessary sorting
2021-09-18 20:40:25 +08:00
Leeingnyo
2c022a07e7 Avoid unnecessary sorts when getting deep percentage
This make page loading fast
2021-09-18 18:49:29 +09:00
Alex Ling
91362dfc7d Merge branch 'master' into rc/0.24.0 2021-09-18 09:20:59 +00:00
Alex Ling
97168b65d8 Make library cache path configurable 2021-09-18 08:40:08 +00:00
Alex Ling
6e04e249e7
Merge pull request #229 from Leeingnyo/feature/preserve-scanned-titles
Reuse scanned titles on boot and scanning
2021-09-18 16:14:31 +08:00
Alex Ling
16397050dd Update comments 2021-09-18 02:24:50 +00:00
Alex Ling
3f73591dd4 Update comments 2021-09-18 02:14:22 +00:00
Alex Ling
ec25109fa5 Merge branch 'feature/preserve-scanned-titles' of https://github.com/Leeingnyo/Mango into feature/preserve-scanned-titles 2021-09-18 02:04:02 +00:00
Alex Ling
96f1ef3dde Improve comments on examine 2021-09-18 02:00:10 +00:00
Leeingnyo
b56e16e1e1 Remove counter, yield everytime 2021-09-18 10:59:43 +09:00
Leeingnyo
9769e760a0 Pass a counter to recursive calls, Ignore negative threshold 2021-09-16 07:49:12 +09:00
Leeingnyo
70ab198a33 Add config 'forcely_yield_count'
the default value 1000 would make a fiber yield on each 4ms on SSD

Apply yield counter in Dir.contents_signauture
Use contents_signature cache in Title.new
2021-09-16 00:16:26 +09:00
Alex Ling
44a6f822cd Simplify Title.new 2021-09-15 09:00:30 +00:00
Alex Ling
2c241a96bb Merge branch 'dev' into feature/preserve-scanned-titles 2021-09-15 08:58:24 +00:00
Alex Ling
219d4446d1
Merge pull request #231 from hkalexling/feature/api-improvements
API Improvements
2021-09-15 16:54:41 +08:00
Alex Ling
d330db131e Simplify mark_unavailable 2021-09-15 08:46:30 +00:00
Leeingnyo
de193906a2 Refactor mark_unavailable 2021-09-15 16:54:55 +09:00
Leeingnyo
d13cfc045f Add a comment 2021-09-15 01:27:05 +09:00
Leeingnyo
a3b2cdd372 Lint 2021-09-15 01:17:44 +09:00
Leeingnyo
f4d7128b59 Mark unavailable only in candidates 2021-09-14 23:30:03 +09:00
Leeingnyo
663c0c0b38 Remove nested title including self 2021-09-14 23:28:28 +09:00
Leeingnyo
57b2f7c625 Get nested ids when title removed 2021-09-14 23:08:07 +09:00
Leeingnyo
9489d6abfd Use reference instead of primitive 2021-09-14 23:07:47 +09:00
Leeingnyo
670cf54957 Apply yield forcely 2021-09-14 22:51:37 +09:00
Leeingnyo
2e09efbd62 Collect deleted ids 2021-09-14 22:51:25 +09:00
Leeingnyo
523195d649 Define ExamineContext, apply it when scanning 2021-09-14 22:37:30 +09:00
Leeingnyo
be47f309b0 Use cache when calculating contents_signature 2021-09-14 18:11:08 +09:00
Alex Ling
03e044a1aa Improve logging 2021-09-14 07:16:14 +00:00
Alex Ling
4eaf271fa4 Simplify #json_build 2021-09-14 02:30:57 +00:00
Alex Ling
4b464ed361 Allow sorting in /api/book endpoint 2021-09-13 10:58:07 +00:00
Alex Ling
a9520d6f26 Add shallow option to library API endpoints 2021-09-13 10:18:07 +00:00
Leeingnyo
a151ec486d Fix file extension of gzip file 2021-09-12 18:04:41 +09:00
Leeingnyo
8f1383a818 Use Gzip instead of Zip 2021-09-12 18:01:16 +09:00
Leeingnyo
f5933a48d9 Register mime_type scan, thumbnails when loading instance 2021-09-12 17:40:40 +09:00
Leeingnyo
7734dae138 Remove unnecessary sort 2021-09-12 14:36:17 +09:00
Leeingnyo
8c90b46114 Remove removed titles from title_hash 2021-09-12 13:39:28 +09:00
Leeingnyo
cd48b45f11 Add 'require "yaml"' 2021-09-12 12:45:24 +09:00
Leeingnyo
bdbdf9c94b Fix to pass 'make check', fix comments 2021-09-12 11:09:48 +09:00
Leeingnyo
7e36c91ea7 Remove debug print 2021-09-12 10:47:15 +09:00
Leeingnyo
9309f51df6 Memoization on dir contents_signature 2021-09-12 02:19:49 +09:00
Leeingnyo
a8f729f5c1 Sort entries and titles when they needed 2021-09-12 02:19:49 +09:00
Leeingnyo
4e8b561f70 Apply contents signature of directories 2021-09-12 02:19:49 +09:00
Leeingnyo
e6214ddc5d Rescan only if instance loaded 2021-09-12 02:19:49 +09:00
Leeingnyo
80e13abc4a Spawn scan job 2021-09-12 02:14:58 +09:00
Leeingnyo
fb43abb950 Enhance the examine method 2021-09-12 02:14:58 +09:00
Leeingnyo
eb3e37b950 Examine titles and recycle them 2021-09-12 02:14:58 +09:00
Leeingnyo
0a90e3b333 Ignore caches 2021-09-12 02:14:58 +09:00
Leeingnyo
4409ed8f45 Implement save_instance, load_instance 2021-09-12 02:14:58 +09:00
Leeingnyo
291a340cdd Add yaml serializer to Library, Title, Entry 2021-09-12 02:14:58 +09:00
Leeingnyo
0667f01471 Measure scan only 2021-09-12 02:14:58 +09:00
Alex Ling
d5847bb105
Merge pull request #224 from hkalexling/fix/sanitize-download-filename
Stricter sanitization rules for download filenames
2021-09-09 08:47:41 +08:00
Alex Ling
3d295e961e Merge branch 'dev' into fix/sanitize-download-filename 2021-09-09 00:31:24 +00:00
Alex Ling
e408398523
Merge pull request #227 from hkalexling/all-contributors/add-lincolnthedev
docs: add lincolnthedev as a contributor for infra
2021-09-09 08:18:22 +08:00
Alex Ling
566cebfcdd Remove all leading dots and spaces 2021-09-09 00:13:58 +00:00
Alex Ling
a190ae3ed6
Merge pull request #226 from lincolnthedev/master
Better .dockerignore
2021-09-08 20:42:12 +08:00
allcontributors[bot]
17d7cefa12
docs: update .all-contributorsrc [skip ci] 2021-09-08 12:42:10 +00:00
allcontributors[bot]
eaef0556fa
docs: update README.md [skip ci] 2021-09-08 12:42:09 +00:00
i use arch btw
53226eab61
Forgot .github 2021-09-08 07:58:58 -04:00
Alex Ling
ccf558eaa7 Improve filename sanitization rules 2021-09-08 10:03:05 +00:00
Alex Ling
0305433e46
Merge pull request #225 from hkalexling/feature/support-all-image-types
Support additional image formats (resolves #192)
2021-09-08 11:03:59 +08:00
i use arch btw
d2cad6c496
Update .dockerignore 2021-09-07 21:12:51 -04:00
Alex Ling
371796cce9 Support additional image formats:
- APNG
- AVIF
- GIF
- SVG
2021-09-07 11:04:05 +00:00
Alex Ling
d9adb49c27 Revert "Support all image types (resolves #192)"
This reverts commit f67e4e6cb9994fd86cafe8b9d8c0541be3b894cb.
2021-09-07 10:45:59 +00:00
Alex Ling
f67e4e6cb9 Support all image types (resolves #192) 2021-09-06 13:32:10 +00:00
Alex Ling
60a126024c Stricter sanitization rules for download filenames
Fixes #212
2021-09-06 13:01:05 +00:00
Alex Ling
da8a485087
Merge pull request #222 from Leeingnyo/feature/enhance-loading-library
Improve loading pages (library, titles)
2021-09-06 16:49:19 +08:00
Alex Ling
d809c21ee1 Document CacheEntry 2021-09-06 08:23:54 +00:00
Alex Ling
ca1e221b10 Rename ids2entries -> ids_to_entries 2021-09-06 08:23:31 +00:00
Alex Ling
44d9c51ff9 Fix logging 2021-09-06 08:10:42 +00:00
Alex Ling
15a54f4f23 Add :sorted_entries suffix to gen_key 2021-09-06 08:10:13 +00:00
Alex Ling
51806f18db Rename config fields and improve logging 2021-09-06 03:35:46 +00:00
Alex Ling
79ef7bcd1c Remove unused variable 2021-09-06 03:01:21 +00:00
Leeingnyo
5cb85ea857 Set cached data when changed 2021-09-06 09:41:46 +09:00
Leeingnyo
9807db6ac0 Fix bug on entry_cover_url_cache 2021-09-06 02:32:13 +09:00
Leeingnyo
565a535d22 Remove caching verbosely, add cached_cover_url 2021-09-06 02:32:13 +09:00
Alex Ling
c5b6a8b5b9 Improve instance_size for Tuple 2021-09-05 13:57:20 +00:00
Leeingnyo
c75c71709f make check 2021-09-05 11:21:53 +09:00
Leeingnyo
11976b15f9 Make LRUCache togglable 2021-09-05 03:02:20 +09:00
Leeingnyo
847f516a65 Cache TitleInfo using LRUCache 2021-09-05 02:35:44 +09:00
Leeingnyo
de410f42b8 Replace InfoCache to LRUCache 2021-09-05 02:08:11 +09:00
Leeingnyo
0fd7caef4b Rename 2021-09-05 00:02:05 +09:00
Leeingnyo
5e919d3e19 Make entry generic 2021-09-04 23:56:17 +09:00
Leeingnyo
9e90aa17b9 Move entry specific method 2021-09-04 14:37:05 +09:00
Leeingnyo
0a8fd993e5 Use bytesize and add comments 2021-09-03 11:11:28 +09:00
Leeingnyo
365f71cd1d Change kbs to mbs 2021-08-30 23:10:08 +09:00
Leeingnyo
601346b209 Set cache if enabled 2021-08-30 23:07:59 +09:00
Leeingnyo
e988a8c121 Add config for sorted entries cache
optional
2021-08-30 22:59:23 +09:00
Leeingnyo
bf81a4e48b Implement sorted entries cache
sorted_entries cached
2021-08-30 22:58:40 +09:00
Leeingnyo
4a09aee177 Implement library caching TitleInfo
* Cache sum of entry progress
* Cache cover_url
* Cache display_name
* Cache sort_opt
2021-08-30 11:31:45 +09:00
Leeingnyo
00c9cc1fcd Prevent saving a sort opt unnecessarily 2021-08-30 11:31:45 +09:00
Leeingnyo
51a47b5ddd Cache display_name 2021-08-30 11:31:45 +09:00
Leeingnyo
244f97a68e Cache entries' cover_url 2021-08-30 08:24:40 +09:00
Alex Ling
8d84a3c502
Merge pull request #221 from hkalexling/rc/0.23.0
v0.23.0
2021-08-28 18:44:50 +08:00
Alex Ling
a26b4b3965 Add Discord badge 2021-08-28 10:27:43 +00:00
Alex Ling
f2dd20cdec Bump version to 0.23.0 2021-08-22 08:20:24 +00:00
Alex Ling
64d6cd293c Remove MangaDex from README 2021-08-22 08:20:01 +00:00
Alex Ling
08dc0601e8 Remove page_margin from README 2021-08-22 08:18:28 +00:00
Alex Ling
9c983df7e9
Merge pull request #216 from Leeingnyo/feature/update-crystal-1.0
Update crystal version to 1.0
2021-08-22 15:59:20 +08:00
Alex Ling
efc547f5b2 Fix "Executing query" spam in log 2021-08-22 07:34:29 +00:00
Alex Ling
995ca3b40f Update ARM Dockerfile 2021-08-20 08:26:11 +00:00
Alex Ling
864435d3f9 Upgrade dependencies for Crystal 1.0.0 2021-08-20 07:15:16 +00:00
Alex Ling
64c145cf80 Merge branch 'dev' of https://github.com/hkalexling/Mango into feature/update-crystal-1.0 2021-08-20 00:36:38 +00:00
Alex Ling
6549253ed1 Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-08-20 00:26:59 +00:00
Alex Ling
d9565718a4 Remove MangaDex integration 2021-08-20 00:25:21 +00:00
Alex Ling
400c3024fd
Merge pull request #219 from Leeingnyo/feature/enhance-paged-mode
Enhance the paged mode reader
2021-08-20 08:13:39 +08:00
Leeingnyo
a703175b3a Parenthesize to avoid a misbehavior 2021-08-19 15:03:01 +09:00
Leeingnyo
83b122ab75 Implement a controller to toggle flip animation 2021-08-18 23:46:45 +09:00
Leeingnyo
1e7d6ba5b1 Keep an image ratio at the paged mode, clamping the image in a screen 2021-08-18 23:46:45 +09:00
Leeingnyo
4d1ad8fb38 Prevent load images 2021-08-18 23:46:45 +09:00
Leeingnyo
d544252e3e Add preload lookahead controller 2021-08-18 23:46:45 +09:00
Leeingnyo
b02b28d3e3 Implement to preload images when viewer is paged mode 2021-08-18 23:07:38 +09:00
Leeingnyo
d7efe1e553 Use yaml-static in Dockerfile 2021-08-18 21:23:28 +09:00
Alex Ling
1973564272 Revert "Subscription manager"
This reverts commit a612500b0fabf7259a5ee0c841b0157d191e5bdd.
2021-08-18 12:09:59 +00:00
Alex Ling
29923f6dc7
Merge pull request #214 from Leeingnyo/feature/http-cache
HTTP cache for thumbnails, pages, dimensions
2021-08-18 07:37:21 +08:00
Leeingnyo
4a261d5ff8 Set Cache-Control header at page, dimensions API 2021-08-18 01:33:22 +09:00
Alex Ling
31d425d462 Document the 304 responses 2021-08-17 07:14:24 +00:00
Leeingnyo
a21681a6d7 Update a container version of bulid workflow 2021-08-16 13:23:58 +09:00
Leeingnyo
208019a0b9 Update Docker image 2021-08-16 01:48:18 +09:00
Leeingnyo
54e2a54ecb Update cyrstal-lang, libraries 2021-08-16 01:48:18 +09:00
Leeingnyo
2426ef05ec Apply cache on dimensions api
Use zip_path and mtime for hashing
It used for weak validation
2021-08-15 21:41:44 +09:00
Leeingnyo
25b90a8724 Apply cache on page, cover api
Get image data and use it for hashing
2021-08-15 21:33:08 +09:00
Alex Ling
cd8944ed2d Slim option in library and title APIs 2021-04-25 12:41:37 +00:00
Alex Ling
7f0c256fe6 Log login errors 2021-04-25 12:41:29 +00:00
Alex Ling
46e6e41bfe Fix reader buttons stacking on mobile 2021-03-29 00:41:33 +00:00
Alex Ling
c9f55e7a8e Use yaml-static 2021-03-28 12:49:50 +00:00
Alex Ling
741c3a4e20 Update config example in README 2021-03-28 11:56:06 +00:00
Alex Ling
f6da20321d Bump version to 0.22.0 2021-03-28 11:49:49 +00:00
Alex Ling
2764e955b2 Show success alert on plugin download page 2021-03-15 17:07:15 +00:00
Alex Ling
00c15014a1 Document subscription APIs 2021-03-15 07:12:30 +00:00
Alex Ling
c6fdbfd9fd Better format ranges on subscription manager page 2021-03-15 07:12:10 +00:00
Alex Ling
e03bf32358 Show success alerts on the download page 2021-03-14 17:36:43 +00:00
Alex Ling
bbf1520c73 Make in_range? private 2021-03-14 17:36:26 +00:00
Alex Ling
8950c3a1ed Fix downloader stuck on external chapters 2021-03-14 16:27:08 +00:00
Alex Ling
17837d8a29 Add tooltips to download manager 2021-03-14 16:03:37 +00:00
Alex Ling
b4a69425c8 Reverse the queue on download manager 2021-03-14 16:01:29 +00:00
Alex Ling
a612500b0f Subscription manager 2021-03-14 16:01:29 +00:00
Alex Ling
9bb7144479 Fix warning 2021-03-12 15:28:39 +00:00
Alex Ling
ee52c52f46 Fix new linter errors 2021-03-12 15:03:12 +00:00
Alex Ling
daec2bdac6 Update ameba 2021-03-12 14:06:20 +00:00
Alex Ling
e9a490676b Update the mangadex shard 2021-03-12 13:59:11 +00:00
Alex Ling
757f7c8214 Upgrade Crystal to 0.36.1 2021-03-12 13:41:24 +00:00
Alex Ling
eed1a9717e Merge branch 'master' into dev 2021-03-10 16:48:51 +00:00
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
0b3e78bcb7 Merge branch 'rc/0.21.0' into dev 2021-03-09 16:45:26 +00:00
Alex Ling
cb4e4437a6 Update MD API URL (closes #174) 2021-03-09 16:43:46 +00:00
Alex Ling
6a275286ea Merge branch 'rc/0.21.0' into dev 2021-03-07 14:14:46 +00:00
Alex Ling
2743868438 Remove outdated MD API link in warning 2021-03-06 17:03:48 +00:00
Alex Ling
d3f26ecbc9 Move the page margin config to frontend 2021-03-06 15:04:44 +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
85 changed files with 4478 additions and 1624 deletions

View File

@ -104,6 +104,33 @@
"contributions": [
"infra"
]
},
{
"login": "lincolnthedev",
"name": "i use arch btw",
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
"profile": "https://lncn.dev",
"contributions": [
"infra"
]
},
{
"login": "BradleyDS2",
"name": "BradleyDS2",
"avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4",
"profile": "https://github.com/BradleyDS2",
"contributions": [
"doc"
]
},
{
"login": "nduja",
"name": "Robbo",
"avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4",
"profile": "https://github.com/nduja",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@ -12,3 +12,4 @@ Layout/LineLength:
MaxLength: 80
Excluded:
- src/routes/api.cr
- spec/plugin_spec.cr

View File

@ -1,2 +1,9 @@
node_modules
lib
Dockerfile
Dockerfile.arm32v7
Dockerfile.arm64v8
README.md
.all-contributorsrc
env.example
.github/

View File

@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest
container:
image: crystallang/crystal:0.35.1-alpine
image: crystallang/crystal:1.0.0-alpine
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
- name: Build
run: make static || make static
- name: Linter

View File

@ -1,9 +1,9 @@
FROM crystallang/crystal:0.35.1-alpine AS builder
FROM crystallang/crystal:1.0.0-alpine AS builder
WORKDIR /Mango
COPY . .
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN make static || make static
FROM library/alpine

View File

@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
COPY mango-arm32v7.o .

View File

@ -2,10 +2,10 @@ FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
COPY mango-arm64v8.o .

View File

@ -2,7 +2,10 @@
# Mango
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q)
> [!CAUTION]
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
Mango is a self-hosted manga server and reader. Its features include
@ -13,8 +16,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Supports nested folders in library
- Automatically stores reading progress
- Thumbnail generation
- Built-in [MangaDex](https://mangadex.org/) downloader
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from third-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless
@ -52,7 +54,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.20.2
Mango - Manga Server and Web Reader. Version 0.27.0
Usage:
@ -81,29 +83,27 @@ base_url: /
session_secret: mango-session-secret
library_path: ~/mango/library
db_path: ~/mango/mango.db
queue_db_path: ~/mango/queue.db
scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
log_level: info
upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
page_margin: 30
library_cache_path: ~/mango/library.yml.gz
cache_enabled: true
cache_size_mbs: 50
cache_log_enabled: true
disable_login: false
default_username: ""
auth_proxy_header_name: ""
mangadex:
base_url: https://mangadex.org
api_url: https://mangadex.org/api/v2
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}'
plugin_update_interval_hours: 24
```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
### Library Structure
@ -175,6 +175,9 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/BradleyDS2"><img src="https://avatars.githubusercontent.com/u/2174921?v=4?s=100" width="100px;" alt=""/><br /><sub><b>BradleyDS2</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=BradleyDS2" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/nduja"><img src="https://avatars.githubusercontent.com/u/69299134?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robbo</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=nduja" title="Code">💻</a></td>
</tr>
</table>

View File

@ -55,7 +55,7 @@ gulp.task('minify-css', () => {
gulp.task('copy-files', () => {
return gulp.src([
'public/*.*',
'public/img/*',
'public/img/**',
'public/webfonts/*',
'public/js/*.min.js'
], {

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

View File

@ -0,0 +1,94 @@
class SortTitle < MG::Base
def up : String
<<-SQL
-- add sort_title column to ids and titles
ALTER TABLE ids ADD COLUMN sort_title TEXT;
ALTER TABLE titles ADD COLUMN sort_title TEXT;
SQL
end
def down : String
<<-SQL
-- remove sort_title column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
signature TEXT,
unavailable INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ids
SELECT path, id, signature, unavailable
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
-- recreate the foreign key constraint on thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
FOREIGN KEY (id) REFERENCES ids (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
-- remove sort_title column from titles
ALTER TABLE titles RENAME TO tmp;
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT,
unavailable INTEGER NOT NULL DEFAULT 0
);
INSERT INTO titles
SELECT id, path, signature, unavailable
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX titles_id_idx on titles (id);
CREATE UNIQUE INDEX titles_path_idx on titles (path);
-- recreate the foreign key constraint on tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag),
FOREIGN KEY (id) REFERENCES titles (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
SQL
end
end

View File

@ -34,9 +34,11 @@
.uk-card-body {
padding: 20px;
.uk-card-title {
max-height: 3em;
font-size: 1rem;
}
.uk-card-title:not(.free-height) {
max-height: 3em;
}
}
}

View File

@ -43,3 +43,22 @@
@internal-list-bullet-image: "../img/list-bullet.svg";
@internal-accordion-open-image: "../img/accordion-open.svg";
@internal-accordion-close-image: "../img/accordion-close.svg";
.hook-card-default() {
.uk-light & {
background: @card-secondary-background;
color: @card-secondary-color;
}
}
.hook-card-default-title() {
.uk-light & {
color: @card-secondary-title-color;
}
}
.hook-card-default-hover() {
.uk-light & {
background-color: @card-secondary-hover-background;
}
}

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -31,6 +31,9 @@ const component = () => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.catch(e => {
alert('danger', `Failed to trigger a scan. Error: ${e}`);
})
.always(() => {
this.scanning = false;
});

View File

@ -117,14 +117,10 @@ const setTheme = (theme) => {
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};

View File

@ -55,7 +55,7 @@ const component = () => {
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-')[1];
const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
url = `${url}?${$.param({
id: id
})}`;

View File

@ -1,233 +0,0 @@
const downloadComponent = () => {
return {
chaptersLimit: 1000,
loading: false,
addingToDownload: false,
searchInput: '',
data: {},
chapters: [],
langChoice: 'All',
groupChoice: 'All',
chapterRange: '',
volumeRange: '',
get languages() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
set.add(chp.language);
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
get groups() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
Object.keys(chp.groups).forEach(g => {
set.add(g);
});
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
init() {
const tableObserver = new MutationObserver(() => {
console.log('table mutated');
$("#selectable").selectable({
filter: 'tr'
});
});
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true
});
},
filtersUpdated() {
if (!this.data.chapters)
this.chapters = [];
const filters = {
chapter: this.parseRange(this.chapterRange),
volume: this.parseRange(this.volumeRange),
lang: this.langChoice,
group: this.groupChoice
};
console.log('filters:', filters);
let _chapters = this.data.chapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
_chapters = _chapters.filter(c => {
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return;
}
if (k === 'lang') {
_chapters = _chapters.filter(c => c.language === v);
return;
}
const lb = parseFloat(v[0]);
const ub = parseFloat(v[1]);
if (isNaN(lb) && isNaN(ub)) return;
_chapters = _chapters.filter(c => {
const val = parseFloat(c[k]);
if (isNaN(val)) return false;
if (isNaN(lb))
return val <= ub;
else if (isNaN(ub))
return val >= lb;
else
return val >= lb && val <= ub;
});
});
console.log('filtered chapters:', _chapters);
this.chapters = _chapters;
},
search() {
if (this.loading || this.searchInput === '') return;
this.loading = true;
this.data = {};
var int_id = -1;
try {
const path = new URL(this.searchInput).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(this.searchInput);
}
if (int_id <= 0 || isNaN(int_id)) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
this.loading = false;
return;
}
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
return;
}
this.data = data;
this.chapters = data.chapters;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
},
parseRange(str) {
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
const matches = str.match(regex);
var num;
if (!matches) {
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
return [null, null];
}
switch (matches[1]) {
case '<':
return [null, num - 1];
case '<=':
return [null, num];
case '>':
return [num + 1, null];
case '>=':
return [num, null];
}
} else if (typeof matches[3] !== 'undefined') {
// a single number
num = parseInt(matches[3]);
if (isNaN(num)) {
return [null, null];
}
return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23
num = parseInt(matches[4]);
const n2 = parseInt(matches[5]);
if (isNaN(num) || isNaN(n2) || num > n2) {
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
},
unescapeHTML(str) {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
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;
});
});
}
};
};

View File

@ -1,141 +1,452 @@
const loadPlugin = id => {
localStorage.setItem('plugin', id);
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
plugin: id
})}`;
window.location.href = newURL;
};
const component = () => {
return {
plugins: [],
subscribable: false,
info: undefined,
pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty
mid: undefined, // id of the selected manga
allChapters: [],
query: "",
mangaTitle: "",
searching: false,
adding: false,
sortOptions: [],
showFilters: false,
appliedFilters: [],
chaptersLimit: 500,
listManga: false,
subscribing: false,
subscriptionName: "",
$(() => {
var storedID = localStorage.getItem('plugin');
if (storedID && storedID !== pid) {
loadPlugin(storedID);
} else {
$('#controls').removeAttr('hidden');
}
init() {
const tableObserver = new MutationObserver(() => {
console.log("table mutated");
$("#selectable").selectable({
filter: "tr",
});
});
tableObserver.observe($("table").get(0), {
childList: true,
subtree: true,
});
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('#plugin-select').val(pid);
$('#plugin-select').change(() => {
const id = $('#plugin-select').val();
loadPlugin(id);
});
});
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid);
let mangaTitle = "";
let searching = false;
const search = () => {
if (searching)
return;
if (this.plugins.length > 0)
this.loadPlugin(this.plugins[0].id);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
loadPlugin(pid) {
fetch(
`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info;
this.subscribable = data.subscribable;
this.pid = pid;
})
.catch((e) => {
alert(
"danger",
`Failed to get plugin metadata. Error: ${e}`
);
});
},
pluginChanged() {
this.manga = undefined;
this.chapters = undefined;
this.mid = undefined;
this.loadPlugin(this.pid);
localStorage.setItem("plugin", this.pid);
},
get chapterKeys() {
if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title"].includes(k)
);
},
searchChapters(query) {
this.searching = true;
this.allChapters = [];
this.sortOptions = [];
this.chapters = undefined;
this.listManga = false;
fetch(
`${base_url}api/admin/plugin/list?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
const query = $.param({
query: $('#search-input').val(),
plugin: pid
});
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Search failed. Error: ${data.error}`);
this.allChapters = data.chapters;
this.chapters = data.chapters;
})
.catch((e) => {
alert("danger", `Failed to list chapters. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
searchManga(query) {
this.searching = true;
this.allChapters = [];
this.chapters = undefined;
this.manga = undefined;
fetch(
`${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.manga = data.manga;
this.listManga = true;
})
.catch((e) => {
alert("danger", `Search failed. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
search() {
const query = this.query.trim();
if (!query) return;
this.manga = undefined;
this.mid = undefined;
if (this.info.version === 1) {
this.searchChapters(query);
} else {
this.searchManga(query);
}
},
selectAll() {
$("tbody > tr").each((i, e) => {
$(e).addClass("ui-selected");
});
},
clearSelection() {
$("tbody > tr").each((i, e) => {
$(e).removeClass("ui-selected");
});
},
download() {
const selected = $("tbody > tr.ui-selected").get();
if (selected.length === 0) return;
UIkit.modal
.confirm(`Download ${selected.length} selected chapters?`)
.then(() => {
const ids = selected.map((e) => e.id);
const chapters = this.chapters.filter((c) =>
ids.includes(c.id)
);
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: "POST",
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert(
"success",
`${successCount} of ${
successCount + failCount
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
);
})
.catch((e) => {
alert(
"danger",
`Failed to add chapters to the download queue. Error: ${e}`
);
})
.finally(() => {
this.adding = false;
});
});
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split("-")[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option);
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log("initial size:", ary.length);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === "array" && filter.value === "all") continue;
if (filter.type.startsWith("number") && isNaN(filter.value))
continue;
if (filter.type === "string") {
ary = ary.filter((ch) =>
ch[filter.key]
.toLowerCase()
.includes(filter.value.toLowerCase())
);
}
if (filter.type === "number-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "number-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "date-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "date-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "array") {
ary = ary.filter((ch) =>
ch[filter.key]
.map((s) =>
typeof s === "string" ? s.toLowerCase() : s
)
.includes(filter.value.toLowerCase())
);
}
console.log("filtered size:", ary.length);
}
return ary;
},
// option:
// - 1: asending
// - -1: desending
// - 0: unsorted
sort(key, option) {
if (option === 0) {
this.chapters = this.filteredChapters;
return;
}
mangaTitle = data.title;
$('#title-text').text(data.title);
buildTable(data.chapters);
})
.fail((jqXHR, status) => {
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {});
};
const buildTable = (chapters) => {
$('#table').attr('hidden', '');
$('table').empty();
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
const thead = `<thead><tr>${keys}</tr></thead>`;
$('table').append(thead);
const rows = chapters.map(ch => {
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
});
const tbody = `<tbody id="selectable">${rows}</tbody>`;
$('table').append(tbody);
$('#selectable').selectable({
filter: 'tr'
});
$('#table table').tablesorter();
$('#table').removeAttr('hidden');
};
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const chapters = selected.map((i, e) => {
return {
id: $(e).attr('data-id'),
title: $(e).attr('data-title')
}
}).get();
console.log(chapters);
$.ajax({
type: 'POST',
url: base_url + 'api/admin/plugin/download',
data: JSON.stringify({
plugin: pid,
chapters: chapters,
title: mangaTitle
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp;
});
});
},
compare(a, b) {
if (a === b) return 0;
// try numbers (also covers dates)
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
const preprocessString = (val) => {
if (typeof val !== "string") return val;
return val.toLowerCase().replace(/\s\s/g, " ").trim();
};
return preprocessString(a) > preprocessString(b) ? 1 : -1;
},
fieldType(values) {
if (values.every((v) => this.numIsDate(v))) return "date";
if (values.every((v) => !isNaN(v))) return "number";
if (values.every((v) => Array.isArray(v))) return "array";
return "string";
},
get filters() {
if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title", "id"].includes(k)
);
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values);
if (type === "array") {
// if the type is an array, return the list of available elements
// example: an array of groups or authors
values = Array.from(
new Set(
values.flat().map((v) => {
if (typeof v === "string")
return v.toLowerCase();
})
)
);
}
return {
key: k,
type: type,
values: values,
};
});
},
get filterSettings() {
return $("#filter-form input:visible, #filter-form select:visible")
.get()
.map((i) => {
const type = i.getAttribute("data-filter-type");
let value = i.value.trim();
if (type.startsWith("date"))
value = value ? Date.parse(value).toString() : "";
return {
key: i.getAttribute("data-filter-key"),
value: value,
type: type,
};
});
},
applyFilters() {
this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
clearFilters() {
$("#filter-form input")
.get()
.forEach((i) => (i.value = ""));
$("#filter-form select").val("all");
this.appliedFilters = [];
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute("data-id");
this.mid = mid;
this.searchChapters(mid);
},
subscribe(modal) {
this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: "POST",
body: JSON.stringify({
filters: this.filterSettings,
plugin: this.pid,
name: this.subscriptionName.trim(),
manga: this.mangaTitle,
manga_id: this.mid,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert("success", "Subscription created");
})
.catch((e) => {
alert("danger", `Failed to subscribe. Error: ${e}`);
})
.finally(() => {
this.subscribing = false;
UIkit.modal(modal).hide();
});
},
numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format(
"MMM D, YYYY"
)}</span>`;
const maxLength = 40;
if (value && value.length > maxLength)
return `<span>${value.substr(
0,
maxLength
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
};
};

View File

@ -6,9 +6,15 @@ const readerComponent = () => {
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
enableFlipAnimation: true,
flipAnimation: null,
longPages: false,
lastSavedPage: page,
selectedIndex: 0, // 0: not selected; 1: the first page
margin: 30,
preloadLookahead: 3,
enableRightToLeft: false,
fitType: 'vert',
/**
* Initialize the component by fetching the page dimensions
@ -24,15 +30,16 @@ const readerComponent = () => {
return {
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;`
width: d.width == 0 ? "100%" : d.width,
height: d.height == 0 ? "100%" : d.height,
};
});
const avgRatio = this.items.reduce((acc, cur) => {
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
// TODO: support more image types in image_size.cr
const avgRatio = dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / this.items.length;
}, 0) / dimensions.length;
console.log(avgRatio);
this.longPages = avgRatio > 2;
@ -46,6 +53,33 @@ const readerComponent = () => {
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
const savedMargin = localStorage.getItem('margin');
if (savedMargin) {
this.margin = savedMargin;
}
// Preload Images
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
const limit = Math.min(page + this.preloadLookahead, this.items.length);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFitType = localStorage.getItem('fitType');
if (savedFitType) {
this.fitType = savedFitType;
$('#fit-select').val(savedFitType);
}
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
if (savedRightToLeft === null) {
this.enableRightToLeft = false;
} else {
this.enableRightToLeft = (savedRightToLeft === 'true');
}
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
@ -54,6 +88,12 @@ const readerComponent = () => {
this.msg = errMsg;
})
},
/**
* Preload an image, which is expected to be cached
*/
preloadImage(url) {
(new Image()).src = url;
},
/**
* Handles the `change` event for the page selector
*/
@ -90,9 +130,9 @@ const readerComponent = () => {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
this.flipPage(false ^ this.enableRightToLeft);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
this.flipPage(true ^ this.enableRightToLeft);
},
/**
* Flips to the next or the previous page
@ -103,14 +143,24 @@ const readerComponent = () => {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0 || newIdx > this.items.length) return;
if (newIdx <= 0) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
}
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
if (this.enableFlipAnimation) {
if (isNext ^ this.enableRightToLeft)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
}
setTimeout(() => {
this.flipAnimation = null;
@ -215,16 +265,21 @@ const readerComponent = () => {
});
},
/**
* Shows the control modal
* Handles clicked image
*
* @param {Event} event - The triggering event
*/
showControl(event) {
clickImage(event) {
const idx = event.currentTarget.id;
const pageCount = this.items.length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
@ -263,19 +318,44 @@ const readerComponent = () => {
});
},
/**
* Exits the reader, and optionally sets the reading progress tp 100%
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
* @param {boolean} [markCompleted] - Whether we should mark the
* reading progress to 100%
*/
exitReader(exitUrl, markCompleted = false) {
if (!markCompleted) {
return this.redirect(exitUrl);
}
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
}
},
/**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
marginChanged() {
localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex);
},
fitChanged(){
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
enableRightToLeftChanged() {
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
},
};
}

View File

@ -0,0 +1,147 @@
const component = () => {
return {
subscriptions: [],
plugins: [],
pid: undefined,
subscription: undefined, // selected subscription
loading: false,
init() {
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
this.pid = pid;
else if (this.plugins.length > 0)
this.pid = this.plugins[0].id;
this.list(pid);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
pluginChanged() {
localStorage.setItem("plugin", this.pid);
this.list(this.pid);
},
list(pid) {
if (!pid) return;
fetch(
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
{
plugin: pid,
}
)}`,
{
method: "GET",
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.subscriptions = data.subscriptions;
})
.catch((e) => {
alert(
"danger",
`Failed to list subscriptions. Error: ${e}`
);
});
},
renderStrCell(str) {
const maxLength = 40;
if (str.length > maxLength)
return `<td><span>${str.substring(
0,
maxLength
)}...</span><div uk-dropdown>${str}</div></td>`;
return `<td>${str}</td>`;
},
renderDateCell(timestamp) {
return `<td>${moment
.duration(moment.unix(timestamp).diff(moment()))
.humanize(true)}</td>`;
},
selected(event, modal) {
const id = event.currentTarget.getAttribute("sid");
this.subscription = this.subscriptions.find((s) => s.id === id);
UIkit.modal(modal).show();
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
actionHandler(event, type) {
const id = $(event.currentTarget).closest("tr").attr("sid");
if (type !== 'delete') return this.action(id, type);
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
labels: {
ok: 'Yes, delete it',
cancel: 'Cancel'
}
}).then(() => {
this.action(id, type);
});
},
action(id, type) {
if (this.loading) return;
this.loading = true;
fetch(
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
{
plugin: this.pid,
subscription: id,
}
)}`,
{
method: type === 'delete' ? "DELETE" : 'POST'
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
if (type === 'update')
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
})
.catch((e) => {
alert(
"danger",
`Failed to ${type} subscription. Error: ${e}`
);
})
.finally(() => {
this.loading = false;
this.list(this.pid);
});
},
};
};

82
public/js/subscription.js Normal file
View File

@ -0,0 +1,82 @@
const component = () => {
return {
available: undefined,
subscriptions: [],
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done(data => {
if (data.error) {
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to check subscription. Error: ${data.error}`);
return;
}
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `${min}`;
if (isNaN(min) && !isNaN(max)) return `${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
}
};
};

View File

@ -60,6 +60,11 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
UIkit.modal($('#modal')).show();
}
UIkit.util.on(document, 'hidden', '#modal', () => {
$('#read-btn').off('click');
$('#unread-btn').off('click');
});
const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
@ -90,8 +95,6 @@ const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
console.log(name);
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
@ -122,15 +125,47 @@ const renameSubmit = (name, eid) => {
});
};
const renameSortNameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
const params = {};
if (eid) params.eid = eid;
if (name) params.name = name;
const query = $.param(params);
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
$.ajax({
type: 'PUT',
url,
contentType: 'application/json',
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update sort title. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
let fileTitle = $('h2.uk-title').attr('data-file-title');
let sortTitle = $('h2.uk-title').attr('data-sort-title');
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
fileTitle = item.find('.uk-card-title').attr('data-file-title');
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
$('#title-progress-control').attr('hidden', '');
} else {
$('#title-progress-control').removeAttr('hidden');
@ -140,14 +175,26 @@ const edit = (eid) => {
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
console.log(displayNameField);
displayNameField.attr('placeholder', fileTitle);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
renameSubmit(displayNameField.val() || fileTitle, eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
renameSubmit(displayNameField.val() || fileTitle, eid);
});
const sortTitleField = $('#sort-title-field');
sortTitleField.val(sortTitle);
sortTitleField.attr('placeholder', fileTitle);
sortTitleField.keyup(event => {
if (event.keyCode === 13) {
renameSortNameSubmit(sortTitleField.val(), eid);
}
});
sortTitleField.siblings('a.uk-form-icon').click(() => {
renameSortNameSubmit(sortTitleField.val(), eid);
});
setupUpload(eid);
@ -155,6 +202,16 @@ const edit = (eid) => {
UIkit.modal($('#edit-modal')).show();
};
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
const displayNameField = $('#display-name-field');
displayNameField.off('keyup');
displayNameField.off('click');
const sortTitleField = $('#sort-title-field');
sortTitleField.off('keyup');
sortTitleField.off('click');
});
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
@ -166,7 +223,6 @@ const setupUpload = (eid) => {
queryObj['eid'] = eid;
const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,
name: 'file',

23
public/manifest.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "Mango",
"description": "Mango: A self-hosted manga server and web reader",
"icons": [
{
"src": "/img/icons/icon_x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/img/icons/icon_x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icons/icon_x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"display": "fullscreen",
"start_url": "/"
}

View File

@ -2,81 +2,81 @@ version: 2.0
shards:
ameba:
git: https://github.com/crystal-ameba/ameba.git
version: 0.12.1
version: 0.14.3
archive:
git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0
version: 0.5.0
baked_file_system:
git: https://github.com/schovi/baked_file_system.git
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
version: 0.10.0
clim:
git: https://github.com/at-grandpa/clim.git
version: 0.12.0
version: 0.17.1
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0
version: 0.10.1
duktape:
git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0
version: 1.0.0
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4
version: 0.1.5
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1
version: 0.8.0
image_size:
git: https://github.com/hkalexling/image_size.cr.git
version: 0.4.0
version: 0.5.0
kemal:
git: https://github.com/kemalcr/kemal.git
version: 0.27.0
version: 1.0.0
kemal-session:
git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1
version: 1.0.0
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.4.0
version: 0.4.1
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
mangadex:
git: https://github.com/hkalexling/mangadex.git
version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb
version: 0.9.0
mg:
git: https://github.com/hkalexling/mg.git
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
myhtml:
git: https://github.com/kostya/myhtml.git
version: 1.5.1
version: 1.5.8
open_api:
git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
git: https://github.com/hkalexling/open_api.cr.git
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
radix:
git: https://github.com/luislavena/radix.git
version: 0.3.9
version: 0.4.1
sanitize:
git: https://github.com/hkalexling/sanitize.git
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0
version: 0.18.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6

View File

@ -1,5 +1,5 @@
name: mango
version: 0.20.2
version: 0.27.0
authors:
- Alex Ling <hkalexling@gmail.com>
@ -8,7 +8,7 @@ targets:
mango:
main: src/mango.cr
crystal: 0.35.1
crystal: 1.0.0
license: MIT
@ -21,7 +21,6 @@ dependencies:
github: crystal-lang/crystal-sqlite3
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive:
github: hkalexling/archive.cr
ameba:
@ -30,7 +29,6 @@ dependencies:
github: at-grandpa/clim
duktape:
github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml:
github: kostya/myhtml
http_proxy:
@ -41,7 +39,8 @@ dependencies:
github: hkalexling/koa
tallboy:
github: epoch/tallboy
branch: master
mg:
github: hkalexling/mg
mangadex:
github: hkalexling/mangadex
sanitize:
github: hkalexling/sanitize

View File

View File

@ -0,0 +1,6 @@
{
"id": "test",
"title": "Test Plugin",
"placeholder": "placeholder",
"wait_seconds": 1
}

View File

@ -1,14 +1,31 @@
require "./spec_helper"
describe Config do
it "creates config if it does not exist" do
with_default_config do |_, path|
it "creates default config if it does not exist" do
with_default_config do |config, path|
File.exists?(path).should be_true
config.port.should eq 9000
end
end
it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000
config.base_url.should eq "/"
end
it "correctly reads config defaults from ENV" do
ENV["LOG_LEVEL"] = "debug"
config = Config.load "spec/asset/test-config.yml"
config.log_level.should eq "debug"
config.base_url.should eq "/"
end
it "correctly handles ENV truthiness" do
ENV["CACHE_ENABLED"] = "false"
config = Config.load "spec/asset/test-config.yml"
config.cache_enabled.should be_false
config.cache_log_enabled.should be_true
config.disable_login.should be_false
end
end

70
spec/plugin_spec.cr Normal file
View File

@ -0,0 +1,70 @@
require "./spec_helper"
describe Plugin do
describe "helper functions" do
it "mango.text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<a href="https://github.com">Click Me<a>');
JS
res.should eq "Click Me"
end
end
it "mango.text returns empty string when no text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<img src="https://github.com" />');
JS
res.should eq ""
end
end
it "mango.css" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
JS
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
end
end
it "mango.css returns empty array when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
JS
res.should eq [] of String
end
end
it "mango.attribute" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
JS
res.should eq "https://github.com"
end
end
it "mango.attribute returns undefined when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div />', 'href') === undefined;
JS
res.should be_true
end
end
# https://github.com/hkalexling/Mango/issues/320
it "mango.attribute handles tags in attribute values" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
JS
res.should eq "test"
end
end
end
end

View File

@ -3,6 +3,7 @@ require "../src/queue"
require "../src/server"
require "../src/config"
require "../src/main_fiber"
require "../src/plugin/plugin"
class State
@@hash = {} of String => String
@ -54,3 +55,10 @@ def with_storage
end
end
end
def with_plugin
with_default_config do
plugin = Plugin.new "test", "spec/asset/plugins"
yield plugin
end
end

View File

@ -8,9 +8,7 @@ describe Storage do
end
it "deletes user" do
with_storage do |storage|
storage.delete_user "admin"
end
with_storage &.delete_user "admin"
end
it "creates new user" do

View File

@ -21,7 +21,7 @@ describe "compare_numerically" do
it "sorts like the stack exchange post" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort { |a, b|
ary.reverse.sort! { |a, b|
compare_numerically a, b
}.should eq ary
end
@ -29,7 +29,7 @@ describe "compare_numerically" do
# https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b|
ary.reverse.sort! { |a, b|
compare_numerically a, b
}.should eq ary
end
@ -56,8 +56,18 @@ describe "chapter_sort" do
it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b|
ary.reverse.sort! do |a, b|
sorter.compare a, b
end.should eq ary
end
end
describe "sanitize_filename" do
it "returns a random string for empty sanitized string" do
sanitize_filename("..").should_not eq sanitize_filename("..")
end
it "sanitizes correctly" do
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
.should eq "マンゴー_()[1_2] 3.14 hello world"
end
end

View File

@ -1,42 +1,51 @@
require "yaml"
class Config
private OPTIONS = {
"host" => "0.0.0.0",
"port" => 9000,
"base_url" => "/",
"session_secret" => "mango-session-secret",
"library_path" => "~/mango/library",
"library_cache_path" => "~/mango/library.yml.gz",
"db_path" => "~/mango.db",
"queue_db_path" => "~/mango/queue.db",
"scan_interval_minutes" => 5,
"thumbnail_generation_interval_hours" => 24,
"log_level" => "info",
"upload_path" => "~/mango/uploads",
"plugin_path" => "~/mango/plugins",
"download_timeout_seconds" => 30,
"cache_enabled" => true,
"cache_size_mbs" => 50,
"cache_log_enabled" => true,
"disable_login" => false,
"default_username" => "",
"auth_proxy_header_name" => "",
"plugin_update_interval_hours" => 24,
}
include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
property host : String = "0.0.0.0"
property port : Int32 = 9000
property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library",
home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true
property scan_interval_minutes : Int32 = 5
property thumbnail_generation_interval_hours : Int32 = 24
property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
property plugin_path : String = File.expand_path "~/mango/plugins",
home: true
property 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
@[YAML::Field(ignore: true)]
@mangadex_defaults = {
"base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api/v2",
"download_wait_seconds" => 5,
"download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true),
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
}
# Go through the options constant above and define them as properties.
# Allow setting the default values through environment variables.
# Overall precedence: config file > environment variable > default value
{% begin %}
{% for k, v in OPTIONS %}
{% if v.is_a? StringLiteral %}
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
{% elsif v.is_a? NumberLiteral %}
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
{% elsif v.is_a? BoolLiteral %}
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
{% else %}
raise "Unknown type in config option: {{ v.class_name.id }}"
{% end %}
{% end %}
{% end %}
@@singlet : Config?
@ -49,12 +58,12 @@ class Config
end
def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil?
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.path = path
config.fill_defaults
config.expand_paths
config.preprocess
return config
end
@ -62,7 +71,7 @@ class Config
"Dumping the default config there."
default = self.allocate
default.path = path
default.fill_defaults
default.expand_paths
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir
@ -72,13 +81,9 @@ class Config
default
end
def fill_defaults
{% for hash_name in ["mangadex"] %}
@{{hash_name.id}}_defaults.map do |k, v|
if @{{hash_name.id}}[k]?.nil?
@{{hash_name.id}}[k] = v
end
end
def expand_paths
{% for p in %w(library library_cache db queue_db upload plugin) %}
@{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true
{% end %}
end
@ -93,16 +98,5 @@ class Config
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
unless mangadex["api_url"] =~ /\/v2/
# `Logger.default` is not available yet
Log.setup :debug
Log.warn { "It looks like you are using the deprecated MangaDex API " \
"v1 in your config file. Please update it to either " \
"https://mangadex.org/api/v2 or " \
"https://api.mangadex.org/v2 to suppress this warning." }
mangadex["api_url"] = "https://mangadex.org/api/v2"
end
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
end
end

View File

@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic"
BEARER = "Bearer"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
@ -18,8 +19,14 @@ class AuthHandler < Kemal::Handler
end
def require_auth(env)
env.session.string "callback", env.request.path
redirect env, "/login"
if request_path_startswith env, ["/api"]
# Do not redirect API requests
env.response.status_code = 401
send_text env, "Unauthorized"
else
env.session.string "callback", env.request.path
redirect env, "/login"
end
end
def validate_token(env)
@ -35,13 +42,18 @@ class AuthHandler < Kemal::Handler
def validate_auth_header(env)
if env.request.headers[AUTH]?
if value = env.request.headers[AUTH]
if value.size > 0 && value.starts_with?(BASIC)
if value.starts_with? BASIC
token = verify_user value
return false if token.nil?
env.session.string "token", token
return true
end
if value.starts_with? BEARER
session_id = value.split(" ")[1]
token = Kemal::Session.get(session_id).try &.string? "token"
return !token.nil? && Storage.default.verify_token token
end
end
end
false
@ -54,15 +66,20 @@ class AuthHandler < Kemal::Handler
end
def call(env)
# Skip all authentication if requesting /login, /logout, or a static file
if request_path_startswith(env, ["/login", "/logout"]) ||
# OPTIONS requests do not require authentication
if env.request.method === "OPTIONS"
return call_next(env)
end
# Skip all authentication if requesting /login, /logout, /api/login,
# or a static file
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
requesting_static_file env
return call_next(env)
end
# Check user is logged in
if validate_token env
# Skip if the request has a valid token
if validate_token(env) || validate_auth_header(env)
# Skip if the request has a valid token (either from cookies or header)
elsif Config.current.disable_login
# Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username

View File

@ -0,0 +1,8 @@
class CORSHandler < Kemal::Handler
def call(env)
if request_path_startswith env, ["/api"]
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
call_next env
end
end

View File

@ -0,0 +1,111 @@
require "yaml"
require "./entry"
class ArchiveEntry < Entry
include YAML::Serializable
getter zip_path : String
def initialize(@zip_path, @book)
storage = Storage.default
@path = @zip_path
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def examine : Bool
File.exists? @zip_path
end
def self.is_valid?(path : String) : Bool
is_supported_file path
end
end

218
src/library/cache.cr Normal file
View File

@ -0,0 +1,218 @@
require "digest"
require "./entry"
require "./title"
require "./types"
# Base class for an entry in the LRU cache.
# There are two ways to use it:
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
# same type. That is, the input value will be stored as it is without
# any transformation.
# 2. You can also subclass it and provide custom implementations for
# `to_save_t` and `to_return_t`. This allows you to transform and store
# the input value to a different type. See `SortedEntriesCacheEntry` as
# an example.
private class CacheEntry(SaveT, ReturnT)
getter key : String, atime : Time
@value : SaveT
def initialize(@key : String, value : ReturnT)
@atime = @ctime = Time.utc
@value = self.class.to_save_t value
end
def value
@atime = Time.utc
self.class.to_return_t @value
end
def self.to_save_t(value : ReturnT)
value
end
def self.to_return_t(value : SaveT)
value
end
def instance_size
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.instance_size
end
end
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
def self.to_save_t(value : Array(Entry))
value.map &.id
end
def self.to_return_t(value : Array(String))
ids_to_entries value
end
private def self.ids_to_entries(ids : Array(String))
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
entries = [] of Entry
begin
ids.each do |id|
entries << e_map[id]
end
return entries if ids.size == entries.size
rescue
end
end
def instance_size
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.size * (instance_sizeof(String) + sizeof(String)) +
@value.sum(&.bytesize) # elements in Array(String)
end
def self.gen_key(book_id : String, username : String,
entries : Array(Entry), opt : SortOptions?)
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_entries"
end
end
class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
def self.to_save_t(value : Array(Title))
value.map &.id
end
def self.to_return_t(value : Array(String))
value.map { |title_id| Library.default.title_hash[title_id].not_nil! }
end
def instance_size
instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.size * (instance_sizeof(String) + sizeof(String)) +
@value.sum(&.bytesize) # elements in Array(String)
end
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_titles"
end
end
class String
def instance_size
instance_sizeof(String) + bytesize
end
end
struct Tuple(*T)
def instance_size
sizeof(T) + # total size of non-reference types
self.sum do |e|
next 0 unless e.is_a? Reference
if e.responds_to? :instance_size
e.instance_size
else
instance_sizeof(typeof(e))
end
end
end
end
alias CacheableType = Array(Entry) | Array(Title) | String |
Tuple(String, Int32)
alias CacheEntryType = SortedEntriesCacheEntry |
SortedTitlesCacheEntry |
CacheEntry(String, String) |
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
def generate_cache_entry(key : String, value : CacheableType)
if value.is_a? Array(Entry)
SortedEntriesCacheEntry.new key, value
elsif value.is_a? Array(Title)
SortedTitlesCacheEntry.new key, value
else
CacheEntry(typeof(value), typeof(value)).new key, value
end
end
# LRU Cache
class LRUCache
@@limit : Int128 = Int128.new 0
@@should_log = true
# key => entry
@@cache = {} of String => CacheEntryType
def self.enabled
Config.current.cache_enabled
end
def self.init
cache_size = Config.current.cache_size_mbs
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
@@should_log = Config.current.cache_log_enabled
end
def self.get(key : String)
return unless enabled
entry = @@cache[key]?
if @@should_log
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
end
return entry.value unless entry.nil?
end
def self.set(cache_entry : CacheEntryType)
return unless enabled
key = cache_entry.key
@@cache[key] = cache_entry
Logger.debug "LRUCache cached #{key}" if @@should_log
remove_least_recent_access
end
def self.invalidate(key : String)
return unless enabled
@@cache.delete key
end
def self.print
return unless @@should_log
sum = @@cache.sum { |_, entry| entry.instance_size }
Logger.debug "---- LRU Cache ----"
Logger.debug "Size: #{sum} Bytes"
Logger.debug "List:"
@@cache.each do |k, v|
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
end
Logger.debug "-------------------"
end
private def self.is_cache_full
sum = @@cache.sum { |_, entry| entry.instance_size }
sum > @@limit
end
private def self.remove_least_recent_access
if @@should_log && is_cache_full
Logger.debug "Removing entries from LRUCache"
end
while is_cache_full && @@cache.size > 0
min_tuple = @@cache.min_by { |_, entry| entry.atime }
min_key = min_tuple[0]
min_entry = min_tuple[1]
Logger.debug " \
Target: #{min_key}, \
Last Access Time: #{min_entry.atime}" if @@should_log
invalidate min_key
end
end
end

132
src/library/dir_entry.cr Normal file
View File

@ -0,0 +1,132 @@
require "yaml"
require "./entry"
class DirEntry < Entry
include YAML::Serializable
getter dir_path : String
@[YAML::Field(ignore: true)]
@sorted_files : Array(String)?
@signature : String
def initialize(@dir_path, @book)
storage = Storage.default
@path = @dir_path
@encoded_path = URI.encode @dir_path
@title = File.basename @dir_path
@encoded_title = URI.encode @title
unless File.readable? @dir_path
@err_msg = "Directory #{@dir_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
unless DirEntry.is_valid? @dir_path
@err_msg = "Directory #{@dir_path} is not valid directory entry."
Logger.warn "#{@err_msg} Please make sure the " \
"directory has valid images."
return
end
size_sum = 0
sorted_files.each do |file_path|
size_sum += File.size file_path
end
@size = size_sum.humanize_bytes
@signature = Dir.directory_entry_signature @dir_path
id = storage.get_entry_id @dir_path, @signature
if id.nil?
id = random_str
storage.insert_entry_id({
path: @dir_path,
id: id,
signature: @signature,
})
end
@id = id
@mtime = sorted_files.map do |file_path|
File.info(file_path).modification_time
end.max
@pages = sorted_files.size
end
def read_page(page_num)
img = nil
begin
files = sorted_files
file_path = files[page_num - 1]
data = File.read(file_path).to_slice
if data
img = Image.new data, MIME.from_filename(file_path),
File.basename(file_path), data.size
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_files.each_with_index do |path, i|
data = File.read(path).to_slice
begin
data.not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
sizes
end
def examine : Bool
existence = File.exists? @dir_path
return false unless existence
files = DirEntry.image_files @dir_path
signature = Dir.directory_entry_signature @dir_path
existence = files.size > 0 && @signature == signature
@sorted_files = nil unless existence
# For more efficient, update a directory entry with new property
# and return true like Title.examine
existence
end
def sorted_files
cached_sorted_files = @sorted_files
return cached_sorted_files if cached_sorted_files
@sorted_files = DirEntry.sorted_image_files @dir_path
@sorted_files.not_nil!
end
def self.image_files(dir_path)
Dir.entries(dir_path)
.reject(&.starts_with? ".")
.map { |fn| File.join dir_path, fn }
.select { |fn| is_supported_image_file fn }
.reject { |fn| File.directory? fn }
.select { |fn| File.readable? fn }
end
def self.sorted_image_files(dir_path)
self.image_files(dir_path)
.sort { |a, b| compare_numerically a, b }
end
def self.is_valid?(path : String) : Bool
image_files(path).size > 0
end
end

View File

@ -1,62 +1,98 @@
require "image_size"
class Entry
getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
node.nodes
.map_with_index { |n, i| {n, i} }
.select(&.[1].even?)
.map(&.[0])
.select(YAML::Nodes::Scalar)
.map(&.as(YAML::Nodes::Scalar).value)
.includes? key
end
def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
abstract class Entry
getter id : String, book : Title, title : String, path : String,
size : String, pages : Int32, mtime : Time,
encoded_path : String, encoded_title : String, err_msg : String?
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
def initialize(
@id, @title, @book, @path,
@size, @pages, @mtime,
@encoded_path, @encoded_title, @err_msg
)
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "title_id", @book.id
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
unless node.is_a? YAML::Nodes::Mapping
raise "Unexpected node type in YAML"
end
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
# instead we are using a more hacky approach (see `node_has_key`).
# TODO: Use a more elegant approach
if node_has_key node, "zip_path"
ArchiveEntry.new ctx, node
elsif node_has_key node, "dir_path"
DirEntry.new ctx, node
else
raise "Unknown entry found in YAML cache. Try deleting the " \
"`library.yml.gz` file"
end
end
def build_json(*, slim = false)
JSON.build do |json|
json.object do
{% for str in %w(path title size id) %}
json.field {{str}}, {{str.id}}
{% end %}
if err_msg
json.field "err_msg", err_msg
end
json.field "zip_path", path # for API backward compatability
json.field "path", path
json.field "title_id", @book.id
json.field "title_title", @book.title
json.field "sort_title", sort_title
json.field "pages" { json.number @pages }
unless slim
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
end
end
end
end
@[YAML::Field(ignore: true)]
@sort_title : String?
def sort_title
sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached
sort_title = @book.entry_sort_title_db id
if sort_title
@sort_title = sort_title
return sort_title
end
@sort_title = @title
@title
end
def set_sort_title(sort_title : String | Nil, username : String)
Storage.default.set_entry_sort_title id, sort_title
if sort_title == "" || sort_title.nil?
@sort_title = nil
else
@sort_title = sort_title
end
@book.entry_sort_title_cache = nil
@book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
username
end
def sort_title_db
@book.entry_sort_title_db @id
end
def display_name
@ -68,10 +104,18 @@ class Entry
end
def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg
unless @book.entry_cover_url_cache
TitleInfo.new @book.dir do |info|
@book.entry_cover_url_cache = info.entry_cover_url
end
end
entry_cover_url = @book.entry_cover_url_cache
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
if entry_cover_url
info_url = entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
@ -79,54 +123,6 @@ class Entry
url
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
@ -134,29 +130,22 @@ class Entry
entries[idx + 1]
end
def previous_entry
idx = @book.entries.index self
def previous_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == 0
@book.entries[idx - 1]
end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
entries[idx - 1]
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, page)
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
@book.parents.each do |parent|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
end
@book.remove_sorted_caches [SortMethod::Progress], username
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
@ -227,7 +216,7 @@ class Entry
end
Storage.default.save_thumbnail @id, img
rescue e
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
end
img
@ -236,4 +225,34 @@ class Entry
def get_thumbnail : Image?
Storage.default.get_thumbnail @id
end
def date_added : Time
date_added = Time::UNIX_EPOCH
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime path
info.save
else
date_added = info_da
end
end
date_added
end
# Hack to have abstract class methods
# https://github.com/crystal-lang/crystal/issues/5956
private module ClassMethods
abstract def is_valid?(path : String) : Bool
end
macro inherited
extend ClassMethods
end
abstract def read_page(page_num)
abstract def page_dimensions
abstract def examine : Bool?
end

View File

@ -1,20 +1,94 @@
class Library
struct ThumbnailContext
property current : Int32, total : Int32
def initialize
@current = 0
@total = 0
end
def progress
if total == 0
0
else
current / total
end
end
def reset
@current = 0
@total = 0
end
def increment
@current += 1
end
end
include YAML::Serializable
getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title)
@[YAML::Field(ignore: true)]
getter thumbnail_ctx = ThumbnailContext.new
use_default
def initialize
register_mime_types
def save_instance
path = Config.current.library_cache_path
Logger.debug "Caching library to #{path}"
writer = Compress::Gzip::Writer.new path,
Compress::Gzip::BEST_COMPRESSION
writer.write self.to_yaml.to_slice
writer.close
end
def self.load_instance
path = Config.current.library_cache_path
return unless File.exists? path
Logger.debug "Loading cached library from #{path}"
begin
Compress::Gzip::Reader.open path do |content|
loaded = Library.from_yaml content
# We will have to do a full restart in these cases. Otherwise having
# two instances of the library will cause some weirdness.
if loaded.dir != Config.current.library_path
Logger.fatal "Cached library dir #{loaded.dir} does not match " \
"current library dir #{Config.current.library_path}. " \
"Deleting cache"
delete_cache_and_exit path
end
if loaded.title_ids.size > 0 &&
Storage.default.count_titles == 0
Logger.fatal "The library cache is inconsistent with the DB. " \
"Deleting cache"
delete_cache_and_exit path
end
@@default = loaded
Logger.debug "Library cache loaded"
end
Library.default.register_jobs
rescue e
Logger.error e
end
end
def initialize
@dir = Config.current.library_path
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@title_ids = [] of String
@title_hash = {} of String => Title
@entries_count = 0
@thumbnails_count = 0
register_jobs
end
protected def register_jobs
register_mime_types
scan_interval = Config.current.scan_interval_minutes
if scan_interval < 1
@ -25,7 +99,7 @@ class Library
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
Logger.debug "Library initialized in #{ms}ms"
sleep scan_interval.minutes
end
end
@ -51,11 +125,6 @@ class Library
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# Helper function from src/util/util.cr
@ -63,14 +132,41 @@ class Library
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
titles + titles.flat_map &.deep_titles
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
def deep_entries
titles.flat_map &.deep_entries
end
def build_json(*, slim = false, depth = -1, sort_context = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
JSON.build do |json|
json.object do
json.field "dir", @dir
json.field "titles" do
json.array do
_titles.each do |title|
json.raw title.build_json(slim: slim, depth: depth,
sort_context: sort_context, percentage: percentage)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |title|
json.number title.load_percentage sort_context[:username]
end
end
end
end
end
end
end
@ -84,6 +180,7 @@ class Library
end
def scan
start = Time.local
unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
@ -92,14 +189,38 @@ class Library
storage = Storage.new auto_close: false
(Dir.entries @dir)
examine_context : ExamineContext = {
cached_contents_signature: {} of String => String,
deleted_title_ids: [] of String,
deleted_entry_ids: [] of String,
}
library_paths = (Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
@title_ids.select! do |title_id|
title = @title_hash[title_id]
next false unless library_paths.includes? title.dir
existence = title.examine examine_context
unless existence
examine_context["deleted_title_ids"].concat [title_id] +
title.deep_titles.map &.id
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
end
existence
end
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
examine_context["deleted_title_ids"].each do |title_id|
@title_hash.delete title_id
end
cache = examine_context["cached_contents_signature"]
library_paths
.select { |path| !(remained_title_dirs.includes? path) }
.select { |path| File.directory? path }
.map { |path| Title.new path, "" }
.map { |path| Title.new path, "", cache }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.sort! { |a, b| a.sort_title <=> b.sort_title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
@ -108,20 +229,27 @@ class Library
storage.bulk_insert_ids
storage.close
Logger.debug "Scan completed"
Storage.default.mark_unavailable
ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
examine_context["deleted_title_ids"]
spawn do
save_instance
end
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
.map { |t| t.get_last_read_entry username }
.map(&.get_last_read_entry username)
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
last_read = e.load_last_read username
pe = e.previous_entry
pe = e.previous_entry username
if last_read.nil? && pe
last_read = pe.load_last_read username
end
@ -150,14 +278,14 @@ class Library
recently_added = [] of RA
last_date_added = nil
titles.map { |t| t.deep_entries_with_date_added }.flatten
.select { |e| e[:date_added] > 1.month.ago }
.sort { |a, b| b[:date_added] <=> a[:date_added] }
titles.flat_map(&.deep_entries_with_date_added)
.select(&.[:date_added].> 1.month.ago)
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
.each do |e|
break if recently_added.size > 12
last = recently_added.last?
if last && e[:entry].book.id == last[:entry].book.id &&
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32)
@ -188,39 +316,34 @@ class Library
# If we use `deep_titles`, the start reading section might include `Vol. 2`
# when the user hasn't started `Vol. 1` yet
titles
.select { |t| t.load_percentage(username) == 0 }
.select(&.load_percentage(username).== 0)
.sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle
end
def thumbnail_generation_progress
return 0 if @entries_count == 0
@thumbnails_count / @entries_count
.shuffle!
end
def generate_thumbnails
if @thumbnails_count > 0
if thumbnail_ctx.current > 0
Logger.debug "Thumbnail generation in progress"
return
end
Logger.info "Starting thumbnail generation"
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
@entries_count = entries.size
@thumbnails_count = 0
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
thumbnail_ctx.total = entries.size
thumbnail_ctx.current = 0
# Report generation progress regularly
spawn do
loop do
unless @thumbnails_count == 0
unless thumbnail_ctx.current == 0
Logger.debug "Thumbnail generation progress: " \
"#{(thumbnail_generation_progress * 100).round 1}%"
"#{(thumbnail_ctx.progress * 100).round 1}%"
end
# Generation is completed. We reset the count to 0 to allow subsequent
# calls to the function, and break from the loop to stop the progress
# report fiber
if thumbnail_generation_progress.to_i == 1
@thumbnails_count = 0
if thumbnail_ctx.progress.to_i == 1
thumbnail_ctx.reset
break
end
sleep 10.seconds
@ -234,7 +357,7 @@ class Library
# and CPU
sleep 1.seconds
end
@thumbnails_count += 1
thumbnail_ctx.increment
end
Logger.info "Thumbnail generation finished"
end

View File

@ -1,13 +1,30 @@
require "digest"
require "../archive"
class Title
include YAML::Serializable
getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time, signature : UInt64
encoded_title : String, mtime : Time, signature : UInt64,
entry_cover_url_cache : Hash(String, String)?
setter entry_cover_url_cache : Hash(String, String)?,
entry_sort_title_cache : Hash(String, String | Nil)?
@[YAML::Field(ignore: true)]
@sort_title : String?
@[YAML::Field(ignore: true)]
@entry_sort_title_cache : Hash(String, String | Nil)?
@[YAML::Field(ignore: true)]
@entry_display_name_cache : Hash(String, String)?
@[YAML::Field(ignore: true)]
@entry_cover_url_cache : Hash(String, String)?
@[YAML::Field(ignore: true)]
@cached_display_name : String?
@[YAML::Field(ignore: true)]
@cached_cover_url : String?
def initialize(@dir : String, @parent_id)
def initialize(@dir : String, @parent_id, cache = {} of String => String)
storage = Storage.default
@signature = Dir.signature dir
id = storage.get_title_id dir, signature
@ -20,6 +37,7 @@ class Title
})
end
@id = id
@contents_signature = Dir.contents_signature dir, cache
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@ -30,54 +48,250 @@ class Title
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id
next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
title = Title.new path, @id, cache
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
end
if DirEntry.is_valid? path
entry = DirEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
next
end
if is_supported_file path
entry = Entry.new path, self
entry = ArchiveEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
mtimes += @entries.map &.mtime
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_numerically Library.default.title_hash[a].title,
Library.default.title_hash[b].title
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
sorter = ChapterSorter.new @entries.map &.title
@entries.sort! do |a, b|
sorter.compare a.title, b.title
sorter.compare a.sort_title, b.sort_title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id"] %}
# Utility method used in library rescanning.
# - When the title does not exist on the file system anymore, return false
# and let it be deleted from the library instance
# - When the title exists, but its contents signature is now different from
# the cache, it means some of its content (nested titles or entries)
# has been added, deleted, or renamed. In this case we update its
# contents signature and instance variables
# - When the title exists and its contents signature is still the same, we
# return true so it can be reused without rescanning
def examine(context : ExamineContext) : Bool
return false unless Dir.exists? @dir
contents_signature = Dir.contents_signature @dir,
context["cached_contents_signature"]
return true if @contents_signature == contents_signature
@contents_signature = contents_signature
@signature = Dir.signature @dir
storage = Storage.default
id = storage.get_title_id dir, signature
if id.nil?
id = random_str
storage.insert_title_id({
path: dir,
id: id,
signature: signature.to_s,
})
end
@id = id
@mtime = File.info(@dir).modification_time
previous_titles_size = @title_ids.size
@title_ids.select! do |title_id|
title = Library.default.get_title title_id
unless title # for if data consistency broken
context["deleted_title_ids"].concat [title_id]
next false
end
existence = title.examine context
unless existence
context["deleted_title_ids"].concat [title_id] +
title.deep_titles.map &.id
context["deleted_entry_ids"].concat title.deep_entries.map &.id
end
existence
end
remained_title_dirs = @title_ids.map do |title_id|
title = Library.default.get_title! title_id
title.dir
end
previous_entries_size = @entries.size
@entries.select! do |entry|
existence = entry.examine
Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence
existence
end
remained_entry_paths = @entries.map &.path
is_titles_added = false
is_entries_added = false
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
unless remained_entry_paths.includes? path
if DirEntry.is_valid? path
entry = DirEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"]
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
end
end
next
end
if is_supported_file path
next if remained_entry_paths.includes? path
entry = ArchiveEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map &.mtime
@mtime = mtimes.max
if is_titles_added || previous_titles_size != @title_ids.size
@title_ids.sort! do |a, b|
compare_numerically Library.default.title_hash[a].title,
Library.default.title_hash[b].title
end
end
if is_entries_added || previous_entries_size != @entries.size
sorter = ChapterSorter.new @entries.map &.sort_title
@entries.sort! do |a, b|
sorter.compare a.sort_title, b.sort_title
end
end
if @title_ids.size > 0 || @entries.size > 0
true
else
context["deleted_title_ids"].concat [@id]
false
end
end
alias SortContext = NamedTuple(username: String, opt: SortOptions)
def build_json(*, slim = false, depth = -1,
sort_context : SortContext? = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
JSON.build do |json|
json.object do
{% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "signature" { json.number @signature }
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
json.field "signature" { json.number @signature }
json.field "sort_title", sort_title
unless slim
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
end
unless depth == 0
json.field "titles" do
json.array do
_titles.each do |title|
json.raw title.build_json(slim: slim,
depth: depth > 0 ? depth - 1 : depth,
sort_context: sort_context, percentage: percentage)
end
end
end
json.field "entries" do
json.array do
_entries.each do |entry|
json.raw entry.build_json(slim: slim)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |t|
json.number t.load_percentage sort_context[:username]
end
end
end
json.field "entry_percentages" do
json.array do
load_percentage_for_all_entries(
sort_context[:username],
sort_context[:opt]
).each do |p|
json.number p.nan? ? 0 : p
end
end
end
end
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
@ -89,15 +303,24 @@ class Title
@title_ids.map { |tid| Library.default.get_title! tid }
end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
end
# Helper function from src/util/util.cr
sort_titles titles, opt.not_nil!, username
end
# Get all entries, including entries in nested titles
def deep_entries
return @entries if title_ids.empty?
@entries + titles.map { |t| t.deep_entries }.flatten
@entries + titles.flat_map &.deep_entries
end
def deep_titles
return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten
titles + titles.flat_map &.deep_titles
end
def parents
@ -125,6 +348,48 @@ class Title
ary.join " and "
end
def sort_title
sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached
sort_title = Storage.default.get_title_sort_title id
if sort_title
@sort_title = sort_title
return sort_title
end
@sort_title = @title
@title
end
def set_sort_title(sort_title : String | Nil, username : String)
Storage.default.set_title_sort_title id, sort_title
if sort_title == "" || sort_title.nil?
@sort_title = nil
else
@sort_title = sort_title
end
if parents.size > 0
target = parents[-1].titles
else
target = Library.default.titles
end
remove_sorted_titles_cache target,
[SortMethod::Auto, SortMethod::Title], username
end
def sort_title_db
Storage.default.get_title_sort_title id
end
def entry_sort_title_db(entry_id)
unless @entry_sort_title_cache
@entry_sort_title_cache =
Storage.default.get_entries_sort_title @entries.map &.id
end
@entry_sort_title_cache.not_nil![entry_id]?
end
def tags
Storage.default.get_title_tags @id
end
@ -138,15 +403,19 @@ class Title
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
@entries.find &.id.== eid
end
def display_name
cached_display_name = @cached_display_name
return cached_display_name unless cached_display_name.nil?
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
@cached_display_name = dn
dn
end
@ -170,6 +439,7 @@ class Title
end
def set_display_name(dn)
@cached_display_name = dn
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
@ -179,12 +449,16 @@ class Title
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
@entry_display_name_cache = info.entry_display_name
info.save
end
end
def cover_url
url = "#{Config.current.base_url}img/icon.png"
cached_cover_url = @cached_cover_url
return cached_cover_url unless cached_cover_url.nil?
url = "#{Config.current.base_url}img/icons/icon_x192.png"
readable_entries = @entries.select &.err_msg.nil?
if readable_entries.size > 0
url = readable_entries[0].cover_url
@ -195,10 +469,12 @@ class Title
url = File.join Config.current.base_url, info_url
end
end
@cached_cover_url = url
url
end
def set_cover_url(url : String)
@cached_cover_url = url
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
@ -208,6 +484,7 @@ class Title
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
@entry_cover_url_cache = info.entry_cover_url
info.save
end
end
@ -217,29 +494,30 @@ class Title
@entries.each do |e|
e.save_progress username, e.pages
end
titles.each do |t|
t.read_all username
end
titles.each &.read_all username
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
e.save_progress username, 0
end
titles.each do |t|
t.unread_all username
end
@entries.each &.save_progress(username, 0)
titles.each &.unread_all username
end
def deep_read_page_count(username) : Int32
load_progress_for_all_entries(username).sum +
titles.map { |t| t.deep_read_page_count username }.flatten.sum
key = "#{@id}:#{username}:progress_sum"
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
cached_sum = LRUCache.get key
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
cached_sum[0] == sig
sum = load_progress_for_all_entries(username, nil, true).sum +
titles.flat_map(&.deep_read_page_count username).sum
LRUCache.set generate_cache_entry key, {sig, sum}
sum
end
def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum +
titles.map { |t| t.deep_total_page_count }.flatten.sum
entries.sum(&.pages) +
titles.flat_map(&.deep_total_page_count).sum
end
def load_percentage(username)
@ -288,44 +566,46 @@ class Title
# use the default (auto, ascending)
# When `opt` is not nil, it saves the options to info.json
def sorted_entries(username, opt : SortOptions? = nil)
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
cached_entries = LRUCache.get cache_key
return cached_entries if cached_entries.is_a? Array(Entry)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
case opt.not_nil!.method
when .title?
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
ary = @entries.sort do |a, b|
compare_numerically a.sort_title, b.sort_title
end
when .time_modified?
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
compare_numerically a.sort_title, b.sort_title }
when .time_added?
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
compare_numerically a.title, b.title }
compare_numerically a.sort_title, b.sort_title }
when .progress?
percentage_ary = load_percentage_for_all_entries username, opt, true
ary = @entries.zip(percentage_ary)
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
compare_numerically a_tp[0].title, b_tp[0].title }
.map { |tp| tp[0] }
compare_numerically a_tp[0].sort_title, b_tp[0].sort_title }
.map &.[0]
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
sorter = ChapterSorter.new @entries.map &.sort_title
ary = @entries.sort do |a, b|
sorter.compare(a.title, b.title).or \
compare_numerically a.title, b.title
sorter.compare(a.sort_title, b.sort_title).or \
compare_numerically a.sort_title, b.sort_title
end
end
ary.reverse! unless opt.not_nil!.ascend
LRUCache.set generate_cache_entry cache_key, ary
ary
end
@ -352,6 +632,16 @@ class Title
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username
if last_read_entry.nil?
# The last entry is finished. Return the first unfinished entry
# (if any)
sorted_entries(username).each do |e|
unless e.finished? username
last_read_entry = e
break
end
end
end
end
last_read_entry
@ -366,7 +656,7 @@ class Title
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.zip_path
da[e.title] = ctime e.path
end
TitleInfo.new @dir do |info|
@ -383,13 +673,39 @@ class Title
{entry: e, date_added: da_ary[i]}
end
return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
zip + titles.flat_map &.deep_entries_with_date_added
end
def remove_sorted_entries_cache(sort_methods : Array(SortMethod),
username : String)
[false, true].each do |ascend|
sort_methods.each do |sort_method|
sorted_entries_cache_key =
SortedEntriesCacheEntry.gen_key @id, username, @entries,
SortOptions.new(sort_method, ascend)
LRUCache.invalidate sorted_entries_cache_key
end
end
end
def remove_sorted_caches(sort_methods : Array(SortMethod), username : String)
remove_sorted_entries_cache sort_methods, username
parents.each do |parent|
remove_sorted_titles_cache parent.titles, sort_methods, username
end
remove_sorted_titles_cache Library.default.titles, sort_methods, username
end
def bulk_progress(action, ids : Array(String), username)
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
parents.each do |parent|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
end
remove_sorted_caches [SortMethod::Progress], username
selected_entries = ids
.map { |id|
@entries.find { |e| e.id == id }
@entries.find &.id.==(id)
}
.select(Entry)

View File

@ -1,5 +1,3 @@
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
enum SortMethod
Auto
Title
@ -47,6 +45,13 @@ class SortOptions
def to_tuple
{@method.to_s.underscore, ascend}
end
def to_json
{
"method" => method.to_s.underscore,
"ascend" => ascend,
}.to_json
end
end
struct Image
@ -88,6 +93,18 @@ class TitleInfo
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
key = "#{dir}:info.json"
info = LRUCache.get key
if info.is_a? String
begin
instance = TitleInfo.from_json info
instance.dir = dir
yield instance
return
rescue
end
end
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
@ -101,6 +118,7 @@ class TitleInfo
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
LRUCache.set generate_cache_entry key, instance.to_json
yield instance
end
end
@ -108,5 +126,12 @@ class TitleInfo
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
key = "#{@dir}:info.json"
LRUCache.set generate_cache_entry key, self.to_json
end
end
alias ExamineContext = NamedTuple(
cached_contents_signature: Hash(String, String),
deleted_title_ids: Array(String),
deleted_entry_ids: Array(String))

View File

@ -34,7 +34,12 @@ class Logger
end
@backend.formatter = Log::Formatter.new &format_proc
Log.setup @@severity, @backend
Log.setup do |c|
c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend
c.bind "duktape", :none, @backend
end
end
def self.get_severity(level = "") : Log::Severity

View File

@ -1,169 +0,0 @@
require "mangadex"
require "compress/zip"
require "../rename"
require "./ext"
module MangaDex
class PageJob
property success = false
property url : String
property filename : String
property writer : Compress::Zip::Writer
property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
end
end
class Downloader < Queue::Downloader
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
use_default
def initialize
@client = Client.from_config
super
end
def pop : Queue::Job?
job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id not like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
end
end
job
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
chapter = @client.chapter job.id
rescue e
Logger.error e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
return
end
@queue.set_pages chapter.pages.size, job
lib_dir = @library_path
rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1
writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size
spawn do
chapter.pages.each_with_index do |url, i|
fn = Path.new(URI.parse(url).path).basename
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job
break unless @queue.exists? job
end
end
spawn do
page_jobs = [] of PageJob
chapter.pages.size.times do
page_job = channel.receive
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job
if page_job.success
@queue.add_success job
else
@queue.add_fail job
msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job
Logger.error msg
end
end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path,
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive filename
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status Queue::JobStatus::Error, job
elsif fail_count > 0
@queue.set_status Queue::JobStatus::MissingPages, job
else
@queue.set_status Queue::JobStatus::Completed, job
end
@downloading = false
end
end
private def download_page(job : PageJob)
Logger.debug "downloading #{job.url}"
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
begin
HTTP::Client.get job.url, headers do |res|
unless res.success?
raise "Failed to download page #{job.url}. " \
"[#{res.status_code}] #{res.status_message}"
end
job.writer.add job.filename, res.body_io
end
job.success = true
rescue e
Logger.error e
job.success = false
end
end
end
end

View File

@ -1,60 +0,0 @@
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

View File

@ -2,13 +2,12 @@ require "./config"
require "./queue"
require "./server"
require "./main_fiber"
require "./mangadex/*"
require "./plugin/*"
require "option_parser"
require "clim"
require "tallboy"
MANGO_VERSION = "0.20.2"
MANGO_VERSION = "0.27.0"
# From http://www.network-science.de/ascii/
BANNER = %{
@ -56,11 +55,13 @@ class CLI < Clim
Config.load(opts.config).set_current
# Initialize main components
LRUCache.init
Storage.default
Queue.default
Library.load_instance
Library.default
MangaDex::Downloader.default
Plugin::Downloader.default
Plugin::Updater.default
spawn do
begin

View File

@ -23,11 +23,6 @@ class Plugin
job
end
private def process_filename(str)
return "_" if str == ".."
str.gsub "/", "_"
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
@ -42,8 +37,8 @@ class Plugin
pages = info["pages"].as_i
manga_title = process_filename job.manga_title
chapter_title = process_filename info["title"].as_s
manga_title = sanitize_filename job.manga_title
chapter_title = sanitize_filename info["title"].as_s
@queue.set_pages pages, job
lib_dir = @library_path
@ -68,7 +63,7 @@ class Plugin
while page = plugin.next_page
break unless @queue.exists? job
fn = process_filename page["filename"].as_s
fn = sanitize_filename page["filename"].as_s
url = page["url"].as_s
headers = HTTP::Headers.new

View File

@ -2,6 +2,8 @@ require "duktape/runtime"
require "myhtml"
require "xml"
require "./subscriptions"
class Plugin
class Error < ::Exception
end
@ -16,12 +18,19 @@ class Plugin
end
struct Info
include JSON::Serializable
{% for name in ["id", "title", "placeholder"] %}
getter {{name.id}} = ""
{% end %}
getter wait_seconds : UInt64 = 0
getter wait_seconds = 0u64
getter version = 0u64
getter settings = {} of String => String?
getter dir : String
@[JSON::Field(ignore: true)]
@json : JSON::Any
def initialize(@dir)
info_path = File.join @dir, "info.json"
@ -37,6 +46,16 @@ class Plugin
@{{name.id}} = @json[{{name}}].as_s
{% end %}
@wait_seconds = @json["wait_seconds"].as_i.to_u64
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
settings_hash.each do |k, v|
unless str_value = v.as_s?
raise "The settings object can only contain strings or null"
end
@settings[k] = str_value
end
end
unless @id.alphanumeric_underscore?
raise "Plugin ID can only contain alphanumeric characters and " \
@ -86,9 +105,10 @@ class Plugin
getter js_path = ""
getter storage_path = ""
def self.build_info_ary
def self.build_info_ary(dir : String? = nil)
@@info_ary.clear
dir = Config.current.plugin_path
dir ||= Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f|
@ -114,10 +134,37 @@ class Plugin
@info.not_nil!
end
def initialize(id : String)
Plugin.build_info_ary
def subscribe(subscription : Subscription)
list = SubscriptionList.new info.dir
list << subscription
list.save
end
@info = @@info_ary.find { |i| i.id == id }
def list_subscriptions
SubscriptionList.new(info.dir).ary
end
def list_subscriptions_raw
SubscriptionList.new(info.dir)
end
def unsubscribe(id : String)
list = SubscriptionList.new info.dir
list.reject! &.id.== id
list.save
end
def check_subscription(id : String)
list = list_subscriptions_raw
sub = list.find &.id.== id
Plugin::Updater.default.check_subscription self, sub.not_nil!
list.save
end
def initialize(id : String, dir : String? = nil)
Plugin.build_info_ary dir
@info = @@info_ary.find &.id.== id
if @info.nil?
raise Error.new "Plugin with ID #{id} not found"
end
@ -138,6 +185,12 @@ class Plugin
sbx.push_string path
sbx.put_prop_string -2, "storage_path"
sbx.push_pointer info.dir.as(Void*)
path = sbx.require_pointer(-1).as String
sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "info_dir"
def_helper_functions sbx
end
@ -152,23 +205,71 @@ class Plugin
{% end %}
end
def assert_manga_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s
rescue e
raise Error.new "Missing required fields in the Manga type"
end
def assert_chapter_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
obj["manga_title"].as_s
rescue e
raise Error.new "Missing required fields in the Chapter type"
end
def assert_page_type(obj : JSON::Any)
obj["url"].as_s && obj["filename"].as_s
rescue e
raise Error.new "Missing required fields in the Page type"
end
def can_subscribe? : Bool
info.version > 1 && eval_exists?("newChapters")
end
def search_manga(query : String)
if info.version == 1
raise Error.new "Manga searching is only available for plugins " \
"targeting API v2 or above"
end
json = eval_json "searchManga('#{query}')"
begin
json.as_a.each do |obj|
assert_manga_type obj
end
rescue e
raise Error.new e.message
end
json
end
def list_chapters(query : String)
json = eval_json "listChapters('#{query}')"
begin
check_fields ["title", "chapters"]
ary = json["chapters"].as_a
ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
if info.version > 1
# Since v2, listChapters returns an array
json.as_a.each do |obj|
assert_chapter_type obj
end
else
check_fields ["title", "chapters"]
title = obj["title"]?
raise "Field `title` missing from `listChapters` outputs" if title.nil?
ary = json["chapters"].as_a
ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
end
title = obj["title"]?
if title.nil?
raise "Field `title` missing from `listChapters` outputs"
end
end
end
rescue e
raise Error.new e.message
@ -179,10 +280,14 @@ class Plugin
def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')"
begin
check_fields ["title", "pages"]
if info.version > 1
assert_chapter_type json
else
check_fields ["title", "pages"]
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
end
end
rescue e
raise Error.new e.message
@ -194,14 +299,28 @@ class Plugin
json = eval_json "nextPage()"
return if json.size == 0
begin
check_fields ["filename", "url"]
assert_page_type json
rescue e
raise Error.new e.message
end
json
end
private def eval(str)
def new_chapters(manga_id : String, after : Int64)
# Converting standard timestamp to milliseconds so plugins can easily do
# `new Date(ms_timestamp)` in JS.
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
begin
json.as_a.each do |obj|
assert_chapter_type obj
end
rescue e
raise Error.new e.message
end
json
end
def eval(str)
@rt.eval str
rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message
@ -213,6 +332,15 @@ class Plugin
JSON.parse eval(str).as String
end
private def eval_exists?(str) : Bool
@rt.eval str
true
rescue e : Duktape::ReferenceError
false
rescue e : Duktape::Error
raise Error.new e.message
end
private def def_helper_functions(sbx)
sbx.push_object
@ -321,9 +449,15 @@ class Plugin
env = Duktape::Sandbox.new ptr
html = env.require_string 0
str = XML.parse(html).inner_text
begin
parser = Myhtml::Parser.new html
str = parser.body!.children.first.inner_text
env.push_string str
rescue
env.push_string ""
end
env.push_string str
env.call_success
end
sbx.put_prop_string -2, "text"
@ -334,8 +468,9 @@ class Plugin
name = env.require_string 1
begin
attr = XML.parse(html).first_element_child.not_nil![name]
env.push_string attr
parser = Myhtml::Parser.new html
attr = parser.body!.children.first.attribute_by name
env.push_string attr.not_nil!
rescue
env.push_undefined
end
@ -379,6 +514,27 @@ class Plugin
end
sbx.put_prop_string -2, "storage"
if info.version > 1
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
key = env.require_string 0
env.get_global_string "info_dir"
info_dir = env.require_string -1
env.pop
info = Info.new info_dir
if value = info.settings[key]?
env.push_string value
else
env.push_undefined
end
env.call_success
end
sbx.put_prop_string -2, "settings"
end
sbx.put_prop_string -2, "mango"
end
end

115
src/plugin/subscriptions.cr Normal file
View File

@ -0,0 +1,115 @@
require "uuid"
require "big"
enum FilterType
String
NumMin
NumMax
DateMin
DateMax
Array
def self.from_string(str)
case str
when "string"
String
when "number-min"
NumMin
when "number-max"
NumMax
when "date-min"
DateMin
when "date-max"
DateMax
when "array"
Array
else
raise "Unknown filter type with string #{str}"
end
end
end
struct Filter
include JSON::Serializable
property key : String
property value : String | Int32 | Int64 | Float32 | Nil
property type : FilterType
def initialize(@key, @value, @type)
end
def self.from_json(str) : Filter
json = JSON.parse str
key = json["key"].as_s
type = FilterType.from_string json["type"].as_s
_value = json["value"]
value = _value.as_s? || _value.as_i? || _value.as_i64? ||
_value.as_f32? || nil
self.new key, value, type
end
def match_chapter(obj : JSON::Any) : Bool
return true if value.nil? || value.to_s.empty?
raw_value = obj[key]
case type
when FilterType::String
raw_value.as_s.downcase == value.to_s.downcase
when FilterType::NumMin, FilterType::DateMin
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
when FilterType::NumMax, FilterType::DateMax
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
when FilterType::Array
return true if value == "all"
raw_value.as_s.downcase.split(",")
.map(&.strip).includes? value.to_s.downcase.strip
else
false
end
end
end
# We use class instead of struct so we can update `last_checked` from
# `SubscriptionList`
class Subscription
include JSON::Serializable
property id : String
property plugin_id : String
property manga_id : String
property manga_title : String
property name : String
property created_at : Int64
property last_checked : Int64
property filters = [] of Filter
def initialize(@plugin_id, @manga_id, @manga_title, @name)
@id = UUID.random.to_s
@created_at = Time.utc.to_unix
@last_checked = Time.utc.to_unix
end
def match_chapter(obj : JSON::Any) : Bool
filters.all? &.match_chapter(obj)
end
end
struct SubscriptionList
@dir : String
@path : String
getter ary = [] of Subscription
forward_missing_to @ary
def initialize(@dir)
@path = Path[@dir, "subscriptions.json"].to_s
if File.exists? @path
@ary = Array(Subscription).from_json File.read @path
end
end
def save
File.write @path, @ary.to_pretty_json
end
end

75
src/plugin/updater.cr Normal file
View File

@ -0,0 +1,75 @@
class Plugin
class Updater
use_default
def initialize
interval = Config.current.plugin_update_interval_hours
return if interval <= 0
spawn do
loop do
Plugin.list.map(&.["id"]).each do |pid|
check_updates pid
end
sleep interval.hours
end
end
end
def check_updates(plugin_id : String)
Logger.debug "Checking plugin #{plugin_id} for updates"
plugin = Plugin.new plugin_id
if plugin.info.version == 1
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
"Skipping update check"
return
end
subscriptions = plugin.list_subscriptions_raw
subscriptions.each do |sub|
check_subscription plugin, sub
end
subscriptions.save
rescue e
Logger.error "Error checking plugin #{plugin_id} for updates: " \
"#{e.message}"
end
def check_subscription(plugin : Plugin, sub : Subscription)
Logger.debug "Checking subscription #{sub.name} for updates"
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
.as_a.select do |chapter|
sub.match_chapter chapter
end
if matches.empty?
Logger.debug "No new chapters found."
sub.last_checked = Time.utc.to_unix
return
end
Logger.debug "Found #{matches.size} new chapters. " \
"Pushing to download queue"
jobs = matches.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
"", # manga_id
ch["title"].as_s,
sub.manga_title,
Queue::JobStatus::Pending,
Time.utc
)
}
inserted_count = Queue.default.push jobs
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
"to the download queue. Plugin ID #{plugin.info.id}, " \
"subscription name #{sub.name}"
if inserted_count != matches.size
Logger.error "Failed to add #{matches.size - inserted_count} " \
"chapters to download queue"
end
sub.last_checked = Time.utc.to_unix
rescue e
Logger.error "Error when checking updates for subscription " \
"#{sub.name}: #{e.message}"
end
end
end

View File

@ -70,7 +70,13 @@ class Queue
ary = @id.split("-")
if ary.size == 2
@plugin_id = ary[0]
@plugin_chapter_id = ary[1]
# This begin-rescue block is for backward compatibility. In earlier
# versions we didn't encode the chapter ID
@plugin_chapter_id = begin
Base64.decode_string ary[1]
rescue
ary[1]
end
end
end
@ -112,7 +118,7 @@ class Queue
use_default
def initialize(db_path : String? = nil)
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
@path = db_path || Config.current.queue_db_path.to_s
dir = File.dirname @path
unless Dir.exists? dir
Logger.info "The queue DB directory #{dir} does not exist. " \
@ -303,12 +309,12 @@ class Queue
end
def pause
@downloaders.each { |d| d.stopped = true }
@downloaders.each &.stopped=(true)
@paused = true
end
def resume
@downloaders.each { |d| d.stopped = false }
@downloaders.each &.stopped=(false)
@paused = false
end

View File

@ -35,15 +35,15 @@ module Rename
class Group < Base(Pattern | String)
def render(hash : VHash)
return "" if @ary.select(&.is_a? Pattern)
return "" if @ary.select(Pattern)
.any? &.as(Pattern).render(hash).empty?
@ary.map do |e|
@ary.join do |e|
if e.is_a? Pattern
e.render hash
else
e
end
end.join
end
end
end
@ -129,13 +129,13 @@ module Rename
end
def render(hash : VHash)
str = @ary.map do |e|
str = @ary.join do |e|
if e.is_a? String
e
else
e.render hash
end
end.join.strip
end.strip
post_process str
end

View File

@ -1,3 +1,5 @@
require "sanitize"
struct AdminRouter
def initialize
get "/admin" do |env|
@ -14,13 +16,13 @@ struct AdminRouter
end
get "/admin/user/edit" do |env|
username = env.params.query["username"]?
sanitizer = Sanitize::Policy::Text.new
username = env.params.query["username"]?.try { |s| sanitizer.process s }
admin = env.params.query["admin"]?
if admin
admin = admin == "true"
end
error = env.params.query["error"]?
current_user = get_username env
error = env.params.query["error"]?.try { |s| sanitizer.process s }
new_user = username.nil? && admin.nil?
layout "user-edit"
end
@ -66,10 +68,13 @@ struct AdminRouter
end
get "/admin/downloads" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager"
end
get "/admin/subscriptions" do |env|
layout "subscription-manager"
end
get "/admin/missing" do |env|
layout "missing-items"
end

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,8 @@ struct MainRouter
else
redirect env, "/"
end
rescue
rescue e
Logger.error e
redirect env, "/login"
end
end
@ -40,7 +41,7 @@ struct MainRouter
username = get_username env
sort_opt = SortOptions.from_info_json Library.default.dir, username
get_sort_opt
get_and_save_sort_opt Library.default.dir
titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
@ -58,12 +59,18 @@ struct MainRouter
username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username
get_sort_opt
get_and_save_sort_opt title.dir
sorted_titles = title.sorted_titles username, sort_opt
entries = title.sorted_entries username, sort_opt
percentage = title.load_percentage_for_all_entries username, sort_opt
title_percentage = title.titles.map &.load_percentage username
title_percentage_map = {} of String => Float64
title_percentage.each_with_index do |tp, i|
t = title.titles[i]
title_percentage_map[t.id] = tp
end
layout "title"
rescue e
Logger.error e
@ -71,23 +78,8 @@ struct MainRouter
end
end
get "/download" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/download/plugins" do |env|
begin
id = env.params.query["plugin"]?
plugins = Plugin.list
plugin = nil
if id
plugin = Plugin.new id
elsif !plugins.empty?
plugin = Plugin.new plugins[0][:id]
end
layout "plugin-download"
rescue e
Logger.error e
@ -103,7 +95,7 @@ struct MainRouter
recently_added = Library.default.get_recently_added_entries username
start_reading = Library.default.get_start_reading_titles username
titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
new_user = !titles.any? &.load_percentage(username).> 0
empty_library = titles.size == 0
layout "home"
rescue e

View File

@ -30,6 +30,11 @@ struct ReaderRouter
title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
sort_opt = SortOptions.from_info_json title.dir, username
get_sort_opt
entries = title.sorted_entries username, sort_opt
page_idx = env.params.url["page"].to_i
if page_idx > entry.pages || page_idx <= 0
raise "Page #{page_idx} not found."
@ -37,15 +42,18 @@ struct ReaderRouter
exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username
unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
next_entry_url = entry.next_entry(username).try do |e|
"#{base_url}reader/#{title.id}/#{e.id}"
end
previous_entry_url = entry.previous_entry(username).try do |e|
"#{base_url}reader/#{title.id}/#{e.id}"
end
render "src/views/reader.html.ecr"
rescue e
Logger.error e
Logger.debug e.backtrace?
env.response.status_code = 404
end
end

View File

@ -25,6 +25,17 @@ class Server
APIRouter.new
OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env|
cors
halt env
end
{% end %}
static_headers do |response|
response.headers.add("Access-Control-Allow-Origin", "*")
end
Kemal.config.logging = false
add_handler LogHandler.new
add_handler AuthHandler.new

View File

@ -34,7 +34,7 @@ class Storage
dir = File.dirname @path
unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it"
"Attempting to create it"
Dir.mkdir_p dir
end
MainFiber.run do
@ -342,6 +342,67 @@ class Storage
end
end
def get_title_sort_title(title_id : String)
sort_title = nil
MainFiber.run do
get_db do |db|
sort_title =
db.query_one? "Select sort_title from titles where id = (?)",
title_id, as: String | Nil
end
end
sort_title
end
def set_title_sort_title(title_id : String, sort_title : String | Nil)
sort_title = nil if sort_title == ""
MainFiber.run do
get_db do |db|
db.exec "update titles set sort_title = (?) where id = (?)",
sort_title, title_id
end
end
end
def get_entry_sort_title(entry_id : String)
sort_title = nil
MainFiber.run do
get_db do |db|
sort_title =
db.query_one? "Select sort_title from ids where id = (?)",
entry_id, as: String | Nil
end
end
sort_title
end
def get_entries_sort_title(ids : Array(String))
results = Hash(String, String | Nil).new
MainFiber.run do
get_db do |db|
db.query "select id, sort_title from ids where id in " \
"(#{ids.join "," { |id| "'#{id}'" }})" do |rs|
rs.each do
id = rs.read String
sort_title = rs.read String | Nil
results[id] = sort_title
end
end
end
end
results
end
def set_entry_sort_title(entry_id : String, sort_title : String | Nil)
sort_title = nil if sort_title == ""
MainFiber.run do
get_db do |db|
db.exec "update ids set sort_title = (?) where id = (?)",
sort_title, entry_id
end
end
end
def save_thumbnail(id : String, img : Image)
MainFiber.run do
get_db do |db|
@ -428,12 +489,21 @@ class Storage
end
end
def mark_unavailable
# Mark titles and entries that no longer exist on the file system as
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
# only checks the existence of the candidate titles/entries to speed up
# the process.
def mark_unavailable(ids_candidates : Array(String)?,
titles_candidates : Array(String)?)
MainFiber.run do
get_db do |db|
# Detect dangling entry IDs
trash_ids = [] of String
db.query "select path, id from ids where unavailable = 0" do |rs|
query = "select path, id from ids where unavailable = 0"
unless ids_candidates.nil?
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
end
db.query query do |rs|
rs.each do
path = rs.read String
fullpath = Path.new(path).expand(Config.current.library_path).to_s
@ -445,11 +515,15 @@ class Storage
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.join "," { |i| "'#{i}'" }})"
# Detect dangling title IDs
trash_titles = [] of String
db.query "select path, id from titles where unavailable = 0" do |rs|
query = "select path, id from titles where unavailable = 0"
unless titles_candidates.nil?
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
end
db.query query do |rs|
rs.each do
path = rs.read String
fullpath = Path.new(path).expand(Config.current.library_path).to_s
@ -461,7 +535,7 @@ class Storage
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 ","})"
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
end
end
end
@ -514,6 +588,51 @@ class Storage
delete_missing "titles", id
end
def save_md_token(username : String, token : String, expire : Time)
MainFiber.run do
get_db do |db|
count = db.query_one "select count(*) from md_account where " \
"username = (?)", username, as: Int64
if count == 0
db.exec "insert into md_account values (?, ?, ?)", username, token,
expire.to_unix
else
db.exec "update md_account set token = (?), expire = (?) " \
"where username = (?)", token, expire.to_unix, username
end
end
end
end
def get_md_token(username) : Tuple(String?, Time?)
token = nil
expires = nil
MainFiber.run do
get_db do |db|
db.query_one? "select token, expire from md_account where " \
"username = (?)", username do |res|
token = res.read String
expires = Time.unix res.read Int64
end
end
end
{token, expires}
end
def count_titles : Int32
count = 0
MainFiber.run do
get_db do |db|
db.query "select count(*) from titles" do |rs|
rs.each do
count = rs.read Int32
end
end
end
end
count
end
def close
MainFiber.run do
unless @db.nil?

View File

@ -73,7 +73,7 @@ class ChapterSorter
.select do |key|
keys[key].count >= str_ary.size / 2
end
.sort do |a_key, b_key|
.sort! do |a_key, b_key|
a = keys[a_key]
b = keys[b_key]
# Sort keys by the number of times they appear

View File

@ -11,7 +11,7 @@ end
def split_by_alphanumeric(str)
arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select { |s| s != "" }
arr += match.captures.select &.!= ""
end
arr
end

View File

@ -19,7 +19,7 @@ class File
# 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
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
File.info(filename).inode
else
0u64
@ -48,4 +48,49 @@ class Dir
end
Digest::CRC32.checksum(signatures.sort.join).to_u64
end
# Returns the contents signature of the directory at dirname for checking
# to rescan.
# Rescan conditions:
# - When a file added, moved, removed, renamed (including which in nested
# directories)
def self.contents_signature(dirname, cache = {} of String => String) : String
return cache[dirname] if cache[dirname]?
Fiber.yield
signatures = [] of String
self.open dirname do |dir|
dir.entries.sort.each do |fn|
next if fn.starts_with? "."
path = File.join dirname, fn
if File.directory? path
signatures << Dir.contents_signature path, cache
else
# Only add its signature value to `signatures` when it is a
# supported file
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
signatures << fn
end
end
Fiber.yield
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname] = hash
hash
end
def self.directory_entry_signature(dirname, cache = {} of String => String)
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
Fiber.yield
signatures = [] of String
image_files = DirEntry.sorted_image_files dirname
if image_files.size > 0
image_files.each do |path|
signatures << File.signature(path).to_s
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname + "?entry"] = hash
hash
end
end

View File

@ -1,8 +1,19 @@
IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt
/manifest.json)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
image/jxl
)
def random_str
UUID.random.to_s.gsub "-", ""
@ -35,6 +46,12 @@ def register_mime_types
# FontAwesome fonts
".woff" => "font/woff",
".woff2" => "font/woff2",
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
# defiend by Crystal in `MIME.DEFAULT_TYPES`
".apng" => "image/apng",
".avif" => "image/avif",
".jxl" => "image/jxl",
}.each do |k, v|
MIME.register k, v
end
@ -44,6 +61,10 @@ def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end
def is_supported_image_file(path)
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
end
struct Int
def or(other : Int)
if self == 0
@ -75,37 +96,56 @@ class String
end
end
def env_is_true?(key : String) : Bool
def env_is_true?(key : String, default : Bool = false) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]?
return false unless val
return default unless val
val.downcase.in? "1", "true"
end
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary = titles
cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt
cached_titles = LRUCache.get cache_key
return cached_titles if cached_titles.is_a? Array(Title)
case opt.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.sort_title, b.sort_title }
when .progress?
ary.sort! do |a, b|
ary = titles.sort do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
compare_numerically a.sort_title, b.sort_title
end
when .title?
ary = titles.sort do |a, b|
compare_numerically a.sort_title, b.sort_title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title }
end
ary.reverse! unless opt.not_nil!.ascend
LRUCache.set generate_cache_entry cache_key, ary
ary
end
def remove_sorted_titles_cache(titles : Array(Title),
sort_methods : Array(SortMethod),
username : String)
[false, true].each do |ascend|
sort_methods.each do |sort_method|
sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
titles, SortOptions.new(sort_method, ascend)
LRUCache.invalidate sorted_titles_cache_key
end
end
end
class String
# Returns the similarity (in [0, 1]) of two paths.
# For the two paths, separate them into arrays of components, count the
@ -114,9 +154,37 @@ class String
def components_similarity(other : String) : Float64
s, l = [self, other]
.map { |str| Path.new(str).parts }
.sort_by &.size
.sort_by! &.size
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
match / s.size
end
end
# Does the followings:
# - turns space-like characters into the normal whitespaces ( )
# - strips and collapses spaces
# - removes ASCII control characters
# - replaces slashes (/) with underscores (_)
# - removes leading dots (.)
# - removes the following special characters: \:*?"<>|
#
# If the sanitized string is empty, returns a random string instead.
def sanitize_filename(str : String) : String
sanitized = str
.gsub(/\s+/, " ")
.strip
.gsub(/\//, "_")
.gsub(/^[\.\s]+/, "")
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
sanitized.size > 0 ? sanitized : random_str
end
def delete_cache_and_exit(path : String)
File.delete path
Logger.fatal "Invalid library cache deleted. Mango needs to " \
"perform a full reset to recover from this. " \
"Pleae restart Mango. This is NOT a bug."
Logger.fatal "Exiting"
exit 1
end

View File

@ -39,13 +39,28 @@ macro send_error_page(msg)
end
macro send_img(env, img)
cors
send_file {{env}}, {{img}}.data, {{img}}.mime
end
def get_token_from_auth_header(env) : String?
value = env.request.headers["Authorization"]
if value && value.starts_with? "Bearer"
session_id = value.split(" ")[1]
return Kemal::Session.get(session_id).try &.string? "token"
end
end
macro get_username(env)
begin
token = env.session.string "token"
(Storage.default.verify_token token).not_nil!
# Check if we can get the session id from the cookie
token = env.session.string? "token"
if token.nil?
# If not, check if we can get the session id from the auth header
token = get_token_from_auth_header env
end
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
(Storage.default.verify_token token.not_nil!).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
@ -57,12 +72,29 @@ macro get_username(env)
end
end
macro cors
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
"DELETE,OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
"Authorization"
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
def send_json(env, json)
cors
env.response.content_type = "application/json"
env.response.print json
end
def send_text(env, text)
cors
env.response.content_type = "text/plain"
env.response.print text
end
def send_attachment(env, path)
cors
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
@ -72,7 +104,7 @@ def redirect(env, path)
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
hash.join "&" { |k, v| "#{k}=#{v}" }
end
def request_path_startswith(env, ary)
@ -107,6 +139,26 @@ macro get_sort_opt
end
end
macro get_and_save_sort_opt(dir)
sort_method = env.params.query["sort"]?
if sort_method
is_ascending = true
ascend = env.params.query["ascend"]?
if ascend && ascend.to_i? == 0
is_ascending = false
end
sort_opt = SortOptions.new sort_method, is_ascending
TitleInfo.new {{dir}} do |info|
info.sort_by[username] = sort_opt.to_tuple
info.save
end
end
end
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)

View File

@ -40,5 +40,6 @@
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/admin.js"></script>
<% end %>

View File

@ -61,7 +61,9 @@
<% if page == "home" && item.is_a? Entry %>
<%= "uk-margin-remove-bottom" %>
<% end %>
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
" data-title="<%= HTML.escape(item.display_name) %>"
data-file-title="<%= HTML.escape(item.title || "") %>"
data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %>
</h3>
<% if page == "home" && item.is_a? Entry %>
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>

View File

@ -1,3 +1,3 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="<%= base_url %>js/dots.js"></script>

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<link rel="manifest" href="<%= base_url %>manifest.json">
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>

View File

@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>

View File

@ -0,0 +1 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>

View File

@ -24,16 +24,10 @@
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
@ -49,11 +43,10 @@
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
</template>
</td>
</tr>
@ -61,9 +54,10 @@
</tbody>
</table>
</div>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script>
<% end %>

View File

@ -1,117 +0,0 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div x-data="downloadComponent()" x-init="init()">
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4">
<input class="uk-input" type="text" placeholder="MangaDex manga ID or URL" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-1-4">
<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 x-show="data && data.chapters" x-cloak>
<div class"uk-grid-small" uk-grid style="margin-top:40px">
<div class="uk-width-1-4@s">
<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 class="uk-margin">
<div class="uk-margin">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></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 x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<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>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %>

View File

@ -77,7 +77,7 @@
<%- end -%>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<%= render_component "dots" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<% end %>

View File

@ -1,89 +1,89 @@
<!DOCTYPE html>
<html>
<%= render_component "head" %>
<%= render_component "head" %>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<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>
<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 class="uk-parent">
<a href="#">Download</a>
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</li>
<% end %>
<hr uk-divider>
<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>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<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>
<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 class="uk-parent">
<a href="#">Download</a>
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
</ul>
</li>
<% end %>
<hr uk-divider>
<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 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" 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 class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@m">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small" style="position:relative;">
<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 class="uk-navbar-left uk-visible@m">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icons/icon.png" style="width:90px;height:90px;"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<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/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
</ul>
</div>
</div>
</li>
<% end %>
</ul>
</div>
<script>
setTheme();
<div class="uk-navbar-right uk-visible@m">
<ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small" style="position:relative;">
<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>
<script>
setTheme();
const base_url = "<%= base_url %>";
</script>
<%= render_component "uikit" %>
<%= yield_content "script" %>
</body>
</script>
<%= render_component "uikit" %>
<%= yield_content "script" %>
</body>
</html>

View File

@ -10,6 +10,7 @@
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"title" => "Name",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
@ -24,7 +25,7 @@
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />

View File

@ -1,75 +1,216 @@
<% if plugins.empty? %>
<div class="uk-container uk-text-center">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<% else %>
<h2 class=uk-title>Download with Plugins</h2>
<div id="controls" class="uk-grid-small" uk-grid hidden>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls">
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
</div>
</div>
<div x-data="component()" x-init="init()" x-cloak>
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
<div class="uk-form-controls">
<select id="plugin-select" class="uk-select">
<% plugins.each do |p| %>
<option value="<%= p[:id] %>"><%= p[:title] %></option>
<% end %>
</select>
<div x-show="plugins.length > 0" style="width:100%">
<h2 class=uk-title>Download with Plugins
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
</h2>
<template x-if="info !== undefined">
<div>
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<div class="uk-form-controls">
<label class="uk-form-label">&nbsp;</label>
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
</div>
</div>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</div>
</div>
</div>
</div>
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
<dl class="uk-description-list" id="toggle" hidden>
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
<dd x-text="entry[1]"></dd>
</dl>
</template>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</template>
<template x-if="manga">
<div class="uk-margin">
<p x-show="manga.length === 0">No matching manga found.</p>
<p x-show="manga.length > 0">
<span x-text="`${manga.length} manga found`"></span>
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
<template x-for="m in manga" :key="m.id">
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="m.cover_url">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div class="uk-margin-large-top" x-show="chapters !== undefined">
<h3 x-text="mangaTitle"></h3>
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
<div class="uk-margin">
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
</div>
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
</div>
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
<template x-for="field in filters">
<div class="uk-margin">
<label class="uk-form-label">
<span x-text="field.key"></span>
<template x-if="field.type === 'number'">
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
</template>
</label>
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
</div>
</div>
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-max">
</div>
</div>
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
<option value="all">All</option>
<template x-for="v in field.values" :key="v">
<option x-text="v" :value="v"></option>
</template>
</select>
</div>
</template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span x-show="subscribable">
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
</form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<template x-for="(k, idx) in chapterKeys" :key="k">
<th :id="`th-${idx}`" @click="thClicked($event)">
<span x-text="k"></span>
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
</th>
</template>
</tr>
</thead>
<tbody id="selectable">
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
<template x-for="ch in chapters" :key="ch">
<tr class="ui-widget-content" :id="ch.id">
<template x-for="k in chapterKeys" :key="k">
<td x-html="renderCell(ch[k])"></td>
</template>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<dl class="uk-description-list" id="toggle" hidden>
<% plugin.not_nil!.info.each do |k, v| %>
<dt><%= k %></dt>
<dd><%= v.to_s %></dd>
<% end %>
</dl>
<div id="table" class="uk-margin-large-top" hidden>
<h3 id="title-text"></h3>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
<div uk-modal="container:false" x-ref="modal">
<div class="uk-modal-dialog">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Confirmation</h2>
</div>
<div class="uk-modal-body">
<p>A subscription with the following filters with be created. All <strong>FUTURE</strong> chapters matching the filters will be automatically downloaded.</p>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in filterSettings" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p>Enter a meaningful name for the subscription to continue:</p>
<input class="uk-input" type="text" x-model="subscriptionName">
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
<button class="uk-button uk-button-primary" type="button" :disabled="subscriptionName.trim().length === 0" @click="subscribe($refs.modal)">Confirm</button>
</div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
</table>
</div>
<% end %>
</div>
<% content_for "script" do %>
<% if plugin %>
<script>
var pid = "<%= plugin.not_nil!.info.id %>";
</script>
<% end %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
<%= render_component "jquery-ui" %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script>
<% end %>

View File

@ -5,7 +5,7 @@
<div>
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div>
<div class="uk-modal-body">

View File

@ -5,7 +5,7 @@
<%= render_component "head" %>
<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" :style="mode === 'continuous' ? '' : 'padding:0'">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'">
<div @keydown.window.debounce="keyHandler($event)"></div>
@ -19,41 +19,44 @@
</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'}" style="width: fit-content;">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items">
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
<img
uk-img
:class="{'uk-align-center': true, 'spine': item.width < 50}"
:style="item.style"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
@click="showControl($event)"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="clickImage($event)"
/>
</template>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<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:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
width:${fitType === 'horz' ? '100vw' : 'auto'};
height:${fitType === 'vert' ? '100vh' : 'auto'};
margin-bottom:0;
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
object-fit: contain;
`" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false ^ enableRightToLeft)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true ^ enableRightToLeft)"></div>
</div>
</div>
@ -64,22 +67,23 @@
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<p id="progress-label"></p>
<p x-text="`Progress: ${selectedIndex}/${items.length} (${(selectedIndex/items.length * 100).toFixed(1)}%)`"></p>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label>
<label class="uk-form-label" for="page-select">Jump to Page</label>
<div class="uk-form-controls">
<select id="page-select" class="uk-select" @change="pageChanged()">
<select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls">
@ -89,9 +93,70 @@
</select>
</div>
</div>
<div class="uk-margin" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="mode-select">Page fit</label>
<div class="uk-form-controls">
<select id="fit-select" class="uk-select" @change="fitChanged()">
<option value="vert">Fit height</option>
<option value="horz">Fit width</option>
<option value="real">Real size</option>
</select>
</div>
</div>
<div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls">
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
<div class="uk-form-controls">
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
<div class="uk-form-controls">
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="enable-right-to-left">Right to Left</label>
<div class="uk-form-controls">
<input id="enable-right-to-left" class="uk-checkbox" type="checkbox" x-model="enableRightToLeft" @change="enableRightToLeftChanged()">
</div>
</div>
<hr class="uk-divider-icon">
<div class="uk-margin">
<label class="uk-form-label" for="entry-select">Jump to Entry</label>
<div class="uk-form-controls">
<select id="entry-select" class="uk-select" @change="entryChanged()">
<% entries.each do |e| %>
<option value="<%= e.id %>"
<% if e.id == entry.id %>
selected
<% end %>>
<%= e.title %>
</option>
<% end %>
</select>
</div>
</div>
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
<% if previous_entry_url %>
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
<% end %>
<% if next_entry_url %>
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
<% end %>
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
</div>
</div>
</div>

View File

@ -0,0 +1,101 @@
<h2 class=uk-title>Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<div x-show="plugins.length > 0" style="width:100%">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
<p x-show="subscriptions.length === 0" class="uk-text-meta">No subscriptions found.</p>
<div class="uk-overflow-auto" x-show="subscriptions.length > 0">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Name</th>
<th>Plugin ID</th>
<th>Manga Title</th>
<th>Created At</th>
<th>Last Checked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr :sid="sub.id" @click="selected($event, $refs.modal)">
<td x-html="renderStrCell(sub.name)"></td>
<td x-html="renderStrCell(sub.plugin_id)"></td>
<td x-html="renderStrCell(sub.manga_title)"></td>
<td x-html="renderDateCell(sub.created_at)"></td>
<td x-html="renderDateCell(sub.last_checked)"></td>
<td>
<a @click.prevent.stop="actionHandler($event, 'delete')" uk-icon="trash" uk-tooltip="Delete" :disabled="loading"></a>
<a @click.prevent.stop="actionHandler($event, 'update')" uk-icon="refresh" uk-tooltip="Check for updates" :disabled="loading"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div uk-modal="container:false" x-ref="modal" class="uk-flex-top">
<div class="uk-modal-dialog uk-margin-auto-vertical uk-overflow-auto">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Details</h2>
</div>
<div class="uk-modal-body">
<dl>
<dt>Name</dt>
<dd x-html="subscription && subscription.name"></dd>
<dt>Subscription ID</dt>
<dd x-html="subscription && subscription.id"></dd>
<dt>Plugin ID</dt>
<dd x-html="subscription && subscription.plugin_id"></dd>
<dt>Manga Title</dt>
<dd x-html="subscription && subscription.manga_title"></dd>
<dt>Manga ID</dt>
<dd x-html="subscription && subscription.manga_id"></dd>
<dt>Filters</dt>
</dl>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in (subscription && subscription.filters || [])" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p class="uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">OK</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription-manager.js"></script>
<% end %>

View File

@ -24,7 +24,7 @@
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<%= render_component "dots" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@ -18,7 +18,8 @@
</div>
</div>
</div>
<h2 class=uk-title><span><%= title.display_name %></span>
<h2 class=uk-title data-file-title="<%= HTML.escape(title.title) %>" data-sort-title="<%= HTML.escape(title.sort_title_db || "") %>">
<span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
@ -59,8 +60,8 @@
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %>
<% sorted_titles.each do |item| %>
<% progress = title_percentage_map[item.id] %>
<%= render_component "card" %>
<% end %>
</div>
@ -89,6 +90,13 @@
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="sort-title">Sort Title</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="sort-title" id="sort-title-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
@ -123,7 +131,7 @@
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<%= render_component "dots" %>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
<link href="<%= 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>