Compare commits

...

441 Commits

Author SHA1 Message Date
Alex Ling
586ee4f0ba Bump version to v0.10.0 2020-08-02 12:33:31 +00:00
Alex Ling
53f3387e1a Rephrase the plugin part in README 2020-08-02 12:32:14 +00:00
Alex Ling
be5d1918aa Add offset to the sticky bar 2020-08-02 12:29:49 +00:00
Alex Ling
df2cc0ffa9 Display nested titles and entries separately 2020-08-02 10:43:46 +00:00
Alex Ling
b8cfc3a201 Remove unnecessary ids from HTML 2020-08-02 10:43:24 +00:00
Alex Ling
8dc60ac2ea Add select all button to the selection bar 2020-08-02 09:28:31 +00:00
Alex Ling
1719335d02 Add "Start Reading" section to home page (#92) 2020-08-01 15:17:18 +00:00
Alex Ling
0cd46abc66 Finish batch marking (#75) 2020-07-30 11:39:23 +00:00
Alex Ling
e4fd7c58ee Add multi-select for cards in web interface 2020-07-30 08:32:00 +00:00
Alex Ling
d4abee52db Fix .uk-card-media-top width 2020-07-30 08:29:41 +00:00
Alex Ling
d29c94e898 Use Alpine.js 2020-07-30 08:28:54 +00:00
Alex Ling
1c19a91ee2 Merge branch 'master' of https://github.com/hkalexling/Mango 2020-07-29 12:19:24 +00:00
Alex Ling
7eb5c253e9 Bump version to v0.9.0 2020-07-29 10:07:36 +00:00
Alex Ling
22a660aabf Fix 500 for empty plugins 2020-07-29 10:07:10 +00:00
Alex Ling
6e9466c9d2 Rename plugin function search to listChapters 2020-07-29 07:15:55 +00:00
Alex Ling
ab34fb260c Fix memory leak through archive.cr 2020-07-28 07:51:55 +00:00
Alex Ling
0e9a659828 Instantiate Plugin objects with IDs 2020-07-26 15:34:54 +00:00
Alex Ling
361d37d742 Decode plugin title before using it 2020-07-26 12:56:46 +00:00
Alex Ling
c6adb4ee18 Fix plugin hot load 2020-07-26 12:56:29 +00:00
Alex Ling
8349fb68a4 Save last used plugin in local storage 2020-07-26 12:42:28 +00:00
Alex Ling
0e1e4de528 Add image placeholder to the reader page 2020-07-26 12:15:22 +00:00
Alex Ling
b47788a85a Add download sub-nav to the mobile nav 2020-07-26 06:59:09 +00:00
Alex Ling
f7004549b8 Remove MangaDex module tests 2020-07-26 06:48:49 +00:00
Alex Ling
8d99400c5f Return strings as header values 2020-07-25 16:19:39 +00:00
Alex Ling
ce59acae7a Fix variable shadowing 2020-07-25 07:25:38 +00:00
Alex Ling
37c5911a23 Make plugin download table sortable 2020-07-25 07:20:22 +00:00
Alex Ling
8694b4beaf Show plugin info on the plugin download page 2020-07-24 15:02:05 +00:00
Alex Ling
3b315ad880 Pass status code and headers to plugin scripts 2020-07-24 13:56:54 +00:00
Alex Ling
33107670ce Use index.js instead of main.js 2020-07-24 09:30:10 +00:00
Alex Ling
f116e2f1d0 Rename the state helper function to storage 2020-07-24 09:27:54 +00:00
Alex Ling
ebf6221876 Rename Job#plugin_name to plugin_id 2020-07-24 07:50:50 +00:00
Alex Ling
2a910335af Easier to use mango.css helper method 2020-07-24 05:11:13 +00:00
Alex Ling
9ea26474b4 Fix formatting 2020-07-23 17:15:40 +00:00
Alex Ling
df8a6ee6da Finish plugin functionalities 2020-07-23 17:15:40 +00:00
Alex Ling
70ea1711ce Handle selectable table dark mode more elegantly 2020-07-22 17:31:38 +00:00
Alex Ling
2773c1e67f Plugin download page WIP 2020-07-22 13:52:28 +00:00
Alex Ling
dcfd1c8765 Expose @filename from the Plugin class 2020-07-22 13:51:45 +00:00
Alex Ling
10b6047df8 Process filenames before downloading 2020-07-22 13:51:03 +00:00
Alex Ling
8de735a2ca Add download dropdown in nav
and remove download manager from admin page
2020-07-22 12:12:29 +00:00
Alex Ling
6c2350c9c7 Fix modal and dropdown colors in dark mode
and get rid of the hacky `styleModal` function
2020-07-22 12:06:29 +00:00
Alex Ling
a994c43857 Plugin downloader WIP 2020-07-22 09:09:02 +00:00
Alex Ling
7e4532fb14 Instantiate Plugins by plugin names 2020-07-22 09:09:02 +00:00
Alex Ling
d184d6fba5 Expand path by home 2020-07-21 17:20:40 +00:00
Alex Ling
92f5a90629 Move pop to the Downloader classes 2020-07-21 17:20:03 +00:00
Alex Ling
2a36804e8d Validate returned JSON 2020-07-21 16:11:56 +00:00
Alex Ling
87b6e79952 Use macro to DRY the self.default method 2020-07-21 12:33:50 +00:00
Alex Ling
b75a838e14 Move common code to Queue::Downloader 2020-07-21 12:32:48 +00:00
Alex Ling
ae7c72ab85 Decouple Queue and related classes from MangaDex 2020-07-21 11:47:14 +00:00
Alex Ling
5cee68d76c Cleanup 2020-07-21 10:44:12 +00:00
Alex Ling
f444496915 Check plugins dir exists before listing plugins 2020-07-21 10:08:30 +00:00
Alex Ling
a812e3ed46 Add duktape.cr and the Plugin class 2020-07-21 09:30:45 +00:00
Alex Ling
1be089b53e Add open collective 2020-07-19 23:37:03 +08:00
Alex Ling
a7f4e161de Add make setup 2020-07-19 13:53:50 +00:00
Alex Ling
ba31eb0071 Use UIKit JS files from node_modules/ 2020-07-19 13:50:46 +00:00
Alex Ling
192474c950 Fix 404 icons 2020-07-19 13:29:05 +00:00
Alex Ling
87b72fbd30 Support 'System' theme setting (#91) 2020-07-19 10:58:23 +00:00
Alex Ling
6acfa02314 Remove unneeded property title_id from Entry 2020-07-18 13:34:55 +00:00
Alex Ling
bdba7bdd13 Show unreadable archives in web interface (#49) 2020-07-18 13:29:03 +00:00
Alex Ling
1b244c68b8 Bump version to v0.8.0 2020-07-17 08:18:24 +00:00
Alex Ling
281f626e8c More tie-breaking 2020-07-16 13:17:58 +00:00
Alex Ling
5be4f51d7e Name partially downloaded cbz files .part (#90) 2020-07-16 13:16:36 +00:00
Alex Ling
cd7782ba1e Respect custom sorting method in continue reading
(#86)
2020-07-15 17:06:54 +00:00
Alex Ling
6d97bc083c Break library.cr into multiple files 2020-07-15 16:12:36 +00:00
Alex Ling
ff4b1be9ae Template cleanup 2020-07-15 16:04:03 +00:00
Alex Ling
ba16c3db2f Add SortOptions.from_info_json 2020-07-15 15:33:26 +00:00
Alex Ling
69b06a8352 Use auto sort to break ties when sorting 2020-07-15 15:13:38 +00:00
Alex Ling
687788767f Use auto when an unknown sorting method is passed 2020-07-15 10:47:27 +00:00
Alex Ling
94a1e63963 Handle library/title sorting on backend (#86) 2020-07-15 10:34:53 +00:00
Alex Ling
360913ee78 Add chapter sort 2020-07-12 08:59:40 +00:00
Alex Ling
ea366f263a Move require "big" to relevant util file 2020-07-12 08:53:46 +00:00
Alex Ling
0d11cb59e9 Break util.cr into multiple files 2020-07-12 08:53:04 +00:00
Alex Ling
2208f90d8e Properly close archive files after validating them 2020-07-11 15:51:57 +00:00
Alex Ling
07100121ef Bump version to v0.7.3 2020-07-05 14:36:12 +00:00
Alex Ling
a0e550569e Use archive.cr v0.3.0 for 32bit support 2020-07-05 14:34:19 +00:00
Alex Ling
bbbe2e0588 Move uikit.less 2020-07-04 11:17:27 +00:00
Alex Ling
9d31b24e8c Fix nested a tags 2020-07-04 11:10:32 +00:00
Alex Ling
38ba324fa9 Save the sorting option in local storage (#76) 2020-07-04 09:47:51 +00:00
Alex Ling
c00016fa19 Remove link to title page from the entry modal 2020-07-04 08:56:58 +00:00
Alex Ling
4d5a305d1b Reduce card font size and link to the title pages
(#84)
2020-07-04 08:56:58 +00:00
Alex Ling
f9ca52ee2f Keep progress label color in dark mode (#85) 2020-07-03 06:53:39 +00:00
Alex Ling
f6c393545c Only show started entries in "continue reading"
(#83)
2020-07-03 06:52:50 +00:00
Alex Ling
466aee62fe Bump version to v0.7.2 2020-07-01 14:15:29 +00:00
Alex Ling
eab0800376 Improve scan performance (#79) 2020-07-01 14:01:26 +00:00
Alex Ling
1725f42698 Use HTML.escape to escape XML 2020-07-01 13:27:30 +00:00
Alex Ling
f5cdf8b7b6 Explicitly register supported mime types (#82) 2020-07-01 13:21:14 +00:00
Alex Ling
fe082e7537 Escape illegal characters in XML (#82) 2020-06-30 16:46:47 +00:00
Alex Ling
c87b96dd0b Improve performance for library and title pages 2020-06-24 16:29:34 +00:00
Alex Ling
9d76ca8c24 Improve home page loading time (#81) 2020-06-24 15:52:26 +00:00
Alex Ling
5f21653e07 Bump version to v0.7.1 2020-06-20 16:25:14 +00:00
Alex Ling
0035cd9177 Revert "Upgrade Crystal to 0.35.1"
Kemal is having some issues in 0.35.0: https://github.com/kemalcr/kemal/issues/575
2020-06-20 16:24:01 +00:00
Alex Ling
899b221842 Merge branch 'dev' 2020-06-20 13:50:37 +00:00
Alex Ling
a317086f81 Bump version to v0.7.0 2020-06-20 13:39:14 +00:00
Alex Ling
b83313b231 Set recently added group range to 1 day 2020-06-20 13:12:13 +00:00
Alex Ling
62af879bfa Upgrade Crystal to 0.35.1 2020-06-20 13:12:13 +00:00
Alex Ling
96f98f6c78 Rename and format ECR files 2020-06-19 11:34:03 +00:00
Alex Ling
841d5051cb Copy robots.txt to dist/ in gulpfile 2020-06-18 15:09:18 +00:00
Alex Ling
0768e2177b Bring back original behavior for recently added
(#37 https://github.com/hkalexling/Mango/issues/37#issuecomment-644748066)
2020-06-17 16:17:29 +00:00
Alex Ling
0e4d67cf29 Hide the progress badge with incorrect value 2020-06-17 16:16:35 +00:00
Alex Ling
00fcc881ee Start from page 1 if the entry has been completed
(#71)
2020-06-16 06:17:52 +00:00
Alex Ling
ca8d9efcfd Show entry display name and path in reader modal
(#71)
2020-06-16 06:06:32 +00:00
Alex Ling
0e7be6392d Fix incorrect modal colors on the reader page 2020-06-16 06:06:32 +00:00
Alex Ling
4af5258602 Show Mango version on the admin page (#71) 2020-06-16 05:26:34 +00:00
Alex Ling
23c6256552 Merge branch 'feature/color-label' into dev 2020-06-16 05:16:27 +00:00
Alex Ling
ef0e3fd346 Add color to labels in dark mode (#70) 2020-06-16 05:15:39 +00:00
Alex Ling
b70fad13a7 Restrict "recently added" from 3 months to 1 2020-06-15 14:54:28 +00:00
Alex Ling
d2f9735250 Add space between entry title and button in modal 2020-06-15 14:53:16 +00:00
Alex Ling
06d6311080 Display book percentage in "recently added" 2020-06-15 14:32:37 +00:00
Alex Ling
674da55bde Add entry download button (#45) 2020-06-15 12:54:42 +00:00
Alex Ling
dc084aff7c Support webp (#69, nice) 2020-06-15 12:35:44 +00:00
Alex Ling
4c2cf64f53 Limit load_progress to @pages 2020-06-15 12:34:51 +00:00
Alex Ling
f4c4bb536c Include nested entries in continue reading 2020-06-15 12:10:31 +00:00
Alex Ling
47edb3008b Refactor get_recently_added_entries 2020-06-15 12:09:17 +00:00
Alex Ling
e28dadc94e Add started? and deep_titles helper methods 2020-06-15 12:05:59 +00:00
Alex Ling
3dc9bd2264 Add finished? helper method 2020-06-15 09:48:41 +00:00
Alex Ling
9302601307 Move relevant methods from Title to Entry 2020-06-13 15:54:55 +00:00
Alex Ling
650ba98039 Merge pull request #67 from flying-sausages/patch-1
Added Use-case to Feature Request template
2020-06-11 22:45:02 +08:00
flying-sausages
bb2173788b Added Use-case to Feature Request template 2020-06-11 14:14:41 +01:00
Alex Ling
c8be2849b9 Show progress for titles and nested titles 2020-06-11 12:36:25 +00:00
Alex Ling
aa269f26ee Fix incorrect breadcrumb menu order 2020-06-11 12:34:13 +00:00
Alex Ling
5c26b0d6dc Handle nested titles in the recently added section 2020-06-11 10:03:34 +00:00
Alex Ling
c9d3c35bdd Add robots.txt 2020-06-09 15:48:08 +00:00
Alex Ling
9255de710f Link to Wiki in README 2020-06-09 15:12:23 +00:00
Alex Ling
39b251774f Bump version to v0.6.1 [skip ci] 2020-06-09 15:08:15 +00:00
Alex Ling
156e511d4a Fix failed build (omitted parentheses) 2020-06-09 14:54:23 +00:00
Alex Ling
5cd6f3eacb Fix incorrect login redirect (#64) 2020-06-09 14:46:45 +00:00
Alex Ling
a0e5a03052 DRY html modal and head 2020-06-09 10:34:24 +00:00
Alex Ling
e53641add1 Handle the rare case when renamed string is ".." 2020-06-09 09:42:28 +00:00
Alex Ling
45cdfd5306 Merge branch 'fix/mangadex-slash' into dev 2020-06-09 09:31:17 +00:00
Alex Ling
3d352ed062 Add test for slash escaping 2020-06-09 09:28:37 +00:00
Alex Ling
bac7be5163 Escape slash in filename when downloading (#62) 2020-06-09 09:25:20 +00:00
Alex Ling
717d44e029 Refactor get_recently_added_entries method 2020-06-09 05:37:10 +00:00
Alex Ling
8da4475a74 Remove duplicate title ID (#56) 2020-06-08 15:55:40 +00:00
Alex Ling
680504779f Use component template on home page 2020-06-08 15:51:42 +00:00
Alex Ling
926d0e66a5 Formatting 2020-06-08 15:29:05 +00:00
Alex Ling
0f3dd51d6b Respect base URL 2020-06-08 15:24:35 +00:00
Alex Ling
53c3798691 Merge branch 'feature/home' into dev 2020-06-08 15:11:09 +00:00
Jared Turner
6d4e8ea544 Show config path for empty libraries and link to Admin for manual re-scan 2020-06-08 15:24:51 +01:00
Jared Turner
0bd94a2290 Add config path to Config 2020-06-08 15:24:17 +01:00
Jared Turner
cff599f688 refactor get_recently_added_entries, new_user and empty_library 2020-06-08 15:23:36 +01:00
Jared Turner
fa85d9834f Onboarding for new libraries and new users 2020-06-07 18:40:31 +01:00
Jared Turner
aaf0a3c6af Group Recently Added by neighbouring Title 2020-06-07 18:39:05 +01:00
Jared Turner
5ed2a8affa Add Library link to mobile nav 2020-06-07 18:36:51 +01:00
Alex Ling
de690fbf29 Store token and callback URI in memory session 2020-06-07 16:18:34 +00:00
Alex Ling
12c3c3f356 Bump version to v0.6.0 2020-06-06 15:45:44 +00:00
Alex Ling
1ddcabcc12 Use component templates 2020-06-06 12:00:02 +00:00
Alex Ling
8b04f2c96b Remove comment in the OPDS xml file [skip ci] 2020-06-05 16:41:55 +00:00
Alex Ling
66e2fc138a Mention OPDS support in README [skip ci] 2020-06-05 16:15:55 +00:00
Alex Ling
6817113523 Clean up 2020-06-05 15:25:41 +00:00
Alex Ling
6ad4385b18 Respect base URL in OPDS feed 2020-06-05 15:18:46 +00:00
Alex Ling
012fd71ab4 Use a helper function to set token cookie 2020-06-05 14:31:12 +00:00
Alex Ling
373ff6520a Merge branch 'feature/opds' into dev 2020-06-05 14:28:36 +00:00
Alex Ling
8a0e9250c8 Finish OPDS 2020-06-05 14:21:47 +00:00
Alex Ling
871a5fe755 Add render_xml helper function 2020-06-05 14:21:47 +00:00
Alex Ling
1493c3de90 Set token cookie after successful basic auth 2020-06-05 14:21:47 +00:00
Jared Turner
808074e478 Add Recently Added to home 2020-06-05 15:13:19 +01:00
Jared Turner
49193b9b00 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-04 19:44:07 +01:00
jaredlt
1cb470fb2d Merge pull request #57 from hkalexling/feature/home-ctime
Add `ctime` helper function
2020-06-04 19:43:46 +01:00
Alex Ling
e443176a79 Add ctime helper function 2020-06-04 16:31:49 +00:00
Alex Ling
bec257c99f Update HTML description meta tag 2020-06-04 15:07:32 +00:00
Alex Ling
f2df493d79 Add Ko-Fi link [skip ci] 2020-06-04 14:54:46 +00:00
Alex Ling
b74f61c025 Bump version to v0.5.2 [skip ci] 2020-06-04 14:52:38 +00:00
Alex Ling
c76c287e66 Fix URL of uploaded images when using base URL 2020-06-04 12:38:38 +00:00
Alex Ling
8e7eaa680a Fix favicon for base URL (#55) [skip-ci] 2020-06-04 05:43:37 +00:00
Alex Ling
30cdb3ec8f Remove duplicate title ID (#56) 2020-06-04 05:37:20 +00:00
Alex Ling
9c367e7d35 Format HTML files with html-beautify 2020-06-04 05:36:39 +00:00
Jared Turner
4f5e05c008 refactor continue reading into Library class 2020-06-03 13:48:49 +01:00
Alex Ling
d2f95e5970 Bump version to v0.5.1 2020-06-03 08:22:05 +00:00
Alex Ling
82bcd03f15 Always create initial user if the DB is empty when started 2020-06-03 08:20:40 +00:00
Alex Ling
fe799f30c8 Make the user listing command handles empty DB 2020-06-03 08:19:40 +00:00
Alex Ling
54123917af Empty ARGV before starting Kemal (#53) 2020-06-03 07:55:18 +00:00
Alex Ling
3b737c0bee Add library URL in README [skip ci] 2020-06-02 15:57:14 +00:00
Alex Ling
14bf4da06c Merge branch 'dev' 2020-06-02 15:45:11 +00:00
Alex Ling
a72dfcecd3 Bump version to v0.5.0 2020-06-02 15:29:32 +00:00
Alex Ling
160a249dc6 Update CLI help message in README 2020-06-02 15:26:38 +00:00
Alex Ling
f9a2534f80 Mention CBR support in README 2020-06-02 15:21:08 +00:00
Alex Ling
06fe2ccf16 Handle escaped characters when filtering (#51) [skip ci] 2020-06-02 15:08:43 +00:00
Jared Turner
13c0878357 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-01 15:29:36 +01:00
Jared Turner
3ef6a7bfc4 continue reading sorted by last read 2020-06-01 15:29:18 +01:00
Alex Ling
e214e00dfb Include port number in token 2020-06-01 13:50:51 +00:00
Alex Ling
9b5aea223d Promote archive error log level to warning 2020-06-01 13:38:15 +00:00
Alex Ling
60100c51fe Add send_attachment function for direct download 2020-06-01 13:21:10 +00:00
Alex Ling
27c111d273 Handle basic auth for OPDS 2020-06-01 13:20:05 +00:00
Alex Ling
1b9d83f367 Report if archive is not readable #49 2020-06-01 04:54:28 +00:00
Alex Ling
96b8186add Merge branch 'feature/admin-cli' into dev 2020-06-01 04:33:27 +00:00
Alex Ling
27dab3c989 Disable initial user creation in spec 2020-05-31 15:26:11 +00:00
Alex Ling
bcb95d1462 Make validate_archive more thorough 2020-05-31 15:14:17 +00:00
Alex Ling
4371c7877d Use base URL in cookies path 2020-05-31 14:34:42 +00:00
Alex Ling
d72d635c68 Add admin/user sub-command 2020-05-31 14:30:45 +00:00
Alex Ling
b724b4d508 Move username/password validation to Storage class 2020-05-31 14:26:20 +00:00
Alex Ling
8bbbe650f1 Allow skipping initial user creation 2020-05-31 14:25:15 +00:00
Alex Ling
651bd17612 Rewrite option parsing using clim and add the admin subcommand 2020-05-30 15:14:39 +00:00
Alex Ling
dd01e632a2 Promote ameba from development dependency to regular dependency
So I can use it in CI while keeping the `--production` flag in Makefile
2020-05-29 16:33:15 +00:00
Alex Ling
43ee8f3b85 Pass in production flag when installing shards 2020-05-29 16:23:48 +00:00
Alex Ling
4841f90cc1 Remove edit buttons from home 2020-05-29 15:51:01 +00:00
Alex Ling
bedcac4e35 Add missing libarchive-dev library 2020-05-29 14:28:18 +00:00
Alex Ling
5260a82e88 Add libarchive libraries to Docker and build files 2020-05-29 14:04:17 +00:00
Alex Ling
1efb300988 Use archive.cr v0.1.0 2020-05-29 14:00:59 +00:00
Alex Ling
6b43ee7fe5 Add RAR/CBR support 2020-05-29 13:45:25 +00:00
Jared Turner
e99d7b8b29 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-05-29 13:31:00 +01:00
Jared Turner
d2ad7fef77 WIP last_read property for Entries 2020-05-29 13:26:47 +01:00
Jared Turner
ddb6a860ae add 'jump to title' to home modal 2020-05-24 10:35:35 +01:00
Alex Ling
3039031924 Merge branch 'master' of https://github.com/hkalexling/Mango 2020-05-24 06:47:19 +00:00
Alex Ling
8665616c2e Bump version to v0.4.0 2020-05-24 06:36:40 +00:00
Alex Ling
4453b0ee9f Link to development guideline in README [skip ci] 2020-05-24 14:34:19 +08:00
Alex Ling
487154e68c Add base url and rename rules to README [skip ci] 2020-05-24 14:33:05 +08:00
Alex Ling
60609263ab Explicitly set icon size (#40) [skip ci] 2020-05-23 14:47:35 +00:00
Alex Ling
4a245d2504 Check supplied base url has leading slash and append tailing slash if needed 2020-05-23 14:30:41 +00:00
Alex Ling
48c3a82078 Use base url when generating cover URLs 2020-05-23 14:16:56 +00:00
Alex Ling
4a59459773 Use base url in JS files 2020-05-23 14:18:14 +00:00
Alex Ling
eefa8c3982 Use base url in some hardcoded URLs 2020-05-23 14:17:11 +00:00
Alex Ling
8fe2f3b4cc Use base url in views 2020-05-23 14:16:56 +00:00
Alex Ling
6a9105605d Fix library link in the breadcrumb menu 2020-05-23 12:16:08 +00:00
Alex Ling
60d4cee0a9 Respect base url setting when redirecting 2020-05-23 10:42:59 +00:00
Alex Ling
8658cb8306 Add base url to config 2020-05-23 10:42:39 +00:00
Alex Ling
c74a01f546 Remove unnecessary JS files from home.ecr 2020-05-21 09:10:46 +00:00
Alex Ling
2aeb38a271 Remove edit button from home screen 2020-05-21 09:06:50 +00:00
Jared Turner
a2c7638141 refactor on deck to continue reading and show percentages on home 2020-05-20 10:38:23 +01:00
Alex Ling
c35e840694 Refactor the / route 2020-05-19 12:16:32 +00:00
Alex Ling
ff6e64f12a Refactor get_on_deck_entry 2020-05-19 12:05:15 +00:00
Jared Turner
16fa27e4f6 update comments 2020-05-18 21:09:11 +01:00
Jared Turner
16734c2c59 rename root to library and add home with on deck WIP 2020-05-18 21:06:14 +01:00
Jared Turner
392b3d8339 fix load_percetage method name typo 2020-05-18 20:32:09 +01:00
Alex Ling
d4e523c337 [skip ci] allow skip CI 2020-05-17 14:08:46 +00:00
Alex Ling
d49c0092c2 Generate artifact 2020-05-17 13:57:28 +00:00
Alex Ling
d75009f088 Rename scripts/ to dev/ 2020-05-17 13:44:10 +00:00
Alex Ling
d416dc6618 Use rename when downloading 2020-05-17 06:29:13 +00:00
Alex Ling
7233e6e5c3 Type annotate the self.default methods 2020-05-17 06:28:33 +00:00
Alex Ling
bd8ae9497f Initialize the downloader when started 2020-05-07 15:42:31 +00:00
Alex Ling
34b11dc2c7 Only hijack HTTP 500 when in release mode 2020-05-07 15:41:02 +00:00
Alex Ling
30dea57346 Use singleton in tests 2020-05-07 10:12:58 +00:00
Alex Ling
7448592216 Optionally pass in db path for testing 2020-05-07 10:12:58 +00:00
Alex Ling
049bd3ab2c Fix long lines 2020-05-07 10:12:58 +00:00
Alex Ling
c3608c101b Enforce 80 characters limit in make check 2020-05-07 10:12:58 +00:00
Alex Ling
1bec9f0108 Use singleton in various classes 2020-05-07 10:12:58 +00:00
Alex Ling
09b297cd8e Add rename method to Manga and Chapter 2020-05-07 10:12:06 +00:00
Alex Ling
b7cd55e692 Add rename rules to config 2020-05-07 10:11:45 +00:00
Alex Ling
986939ecb6 Add tests for the Rename module 2020-05-07 10:01:32 +00:00
Alex Ling
a5e97af3a3 Use abstract class in the Rename module 2020-05-03 16:31:00 +00:00
Alex Ling
4cee5faecd Allow | character outside of patterns 2020-05-03 16:30:35 +00:00
Alex Ling
711add74ef Allow spaces in patterns 2020-05-03 16:29:54 +00:00
Alex Ling
f6f09c54bc Add Rename module 2020-05-03 12:02:12 +00:00
Alex Ling
0f58ebb87b Ignore markdown files 2020-05-03 12:01:57 +00:00
Alex Ling
46347a8fe4 Update README.md 2020-04-23 14:49:06 +08:00
Alex Ling
a354d811d9 Merge branch 'dev' 2020-04-22 14:36:36 +00:00
Alex Ling
22d757362a Update README.md 2020-04-22 22:34:02 +08:00
Alex Ling
8afcea7e87 Update README.md 2020-04-22 22:31:40 +08:00
Alex Ling
fb05e913a0 Limit cover image types to png/jpeg in the web UI 2020-04-20 07:36:55 +00:00
Alex Ling
490888ad71 Bump version to 0.3.0 2020-04-19 16:23:00 +00:00
Alex Ling
20d71bfa81 Finish #30 2020-04-19 16:11:23 +00:00
Alex Ling
ec6a7bd3d9 Read/unread a directory with API 2020-04-19 15:47:36 +00:00
Alex Ling
b449d906ec Merge branch 'cover' into dev 2020-04-19 14:39:19 +00:00
Alex Ling
f66bec5545 Update frontend for cover upload 2020-04-19 14:33:24 +00:00
Alex Ling
ce5f444012 Remove debug code in upload handler 2020-04-19 14:32:58 +00:00
Alex Ling
8506044232 Handle errors in the "/" endpoint 2020-04-14 06:08:10 +00:00
Alex Ling
079dd8e280 Fix layout macro message displaying bug 2020-04-14 06:08:10 +00:00
Alex Ling
8262a163db Finish the API endpoint for cover upload 2020-04-14 06:09:23 +00:00
Alex Ling
d6b22ef736 Don't return from DB blocks 2020-04-10 15:24:49 +00:00
Alex Ling
39f4897fc5 Set status as "Error" if downloaded zip is invalid
(#29)
2020-04-08 10:31:30 +00:00
Alex Ling
fc6a33e5fd Update Makefile 2020-04-08 07:18:25 +00:00
Alex Ling
7d97d21d40 Run Ameba and Crystal formatting tool on push 2020-04-08 07:09:54 +00:00
Alex Ling
fcf9d39047 Project-wise refactoring to follow Ameba 2020-04-08 06:45:45 +00:00
Alex Ling
d33cae7618 Use Ameba 2020-04-08 06:45:45 +00:00
Alex Ling
8b184ed48d Project-wise code formatting 2020-04-08 05:25:12 +00:00
Alex Ling
d3309a810b Update bug_report.md 2020-04-07 22:15:07 +08:00
Alex Ling
3866c81588 Use the updated Logger class in spec 2020-04-07 13:26:09 +00:00
Alex Ling
2c31f594a4 Use the new Log module in Crystal 0.34.0 2020-04-07 12:58:42 +00:00
Alex Ling
c572c56a39 Upgrade Crystal version to 0.34.0 2020-04-07 12:57:50 +00:00
Alex Ling
e670a083a3 Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling
9b23e1759d Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling
14e3470b12 Hide rename buttons when the login user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling
8ce51a6163 Hide the "Admin" and "Download" buttons when user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling
1d4237d687 Pass in admin information when rendering all pages 2020-04-07 12:57:50 +00:00
Alex Ling
b7c0515af7 Fix dark mode on login page 2020-04-07 12:57:50 +00:00
Alex Ling
75edfcdb5b Set and load display names in frontend 2020-04-07 12:57:50 +00:00
Alex Ling
51d19328be Set up API endpoint for setting display names 2020-04-07 12:57:50 +00:00
Alex Ling
d405498af4 Update shards.lock 2020-04-07 04:01:04 +00:00
Alex Ling
696f79aea1 Merge pull request #28 from noirscape/env-file
Use a .env file for docker-compose configuration.
2020-04-07 11:43:32 +08:00
noirscape
d2da8d0b9a docker: Use a .env file 2020-04-06 21:49:14 +02:00
Alex Ling
4e961192d4 Update README.md 2020-04-06 22:44:45 +08:00
Alex Ling
8b90524a2c Create dockerhub.yml 2020-04-06 21:45:08 +08:00
Alex Ling
c9b8770b9f Bump version to v0.2.5 2020-04-02 09:12:35 +00:00
Alex Ling
e568ec8878 Fix the unexpected sorting behavior on Chrome 2020-04-02 09:06:16 +00:00
Alex Ling
ac3df03d88 Show page counts on chapter cards 2020-04-02 05:44:29 +00:00
Alex Ling
7c9728683c On the title page, hide progress label of nested titles 2020-04-02 00:16:19 +00:00
Alex Ling
d921d04abf Bump version to v0.2.4 2020-04-01 23:32:16 +00:00
Alex Ling
5400c8c8ef Fix a UI bug that shows "resume download" button on download manager even when the downloading process is not paused 2020-04-01 23:21:32 +00:00
Alex Ling
58e96cd4fe Watch the title element size for change 2020-04-01 06:13:03 +00:00
Alex Ling
aa09f3a86f Only show tooltips for truncated titles 2020-04-01 05:59:46 +00:00
Alex Ling
a5daded453 Fix the width and height of cover images (#23) 2020-04-01 04:51:57 +00:00
Alex Ling
4968cb8e18 Add tooltips to show un-truncated titles 2020-04-01 04:49:53 +00:00
Alex Ling
27c6e02da8 Run the truncate function after DOM is ready 2020-04-01 04:48:53 +00:00
Alex Ling
68d1b55aea Limit title text height in CSS 2020-04-01 04:47:55 +00:00
Alex Ling
32dc3e84b9 Lazy load images in library/title page to improve page load time 2020-03-31 08:44:07 +00:00
Alex Ling
460fcdf2f5 Limit the number of lines to display in card titles 2020-03-30 20:36:27 +00:00
Alex Ling
c6369f9f26 Prevent flash of white in cards 2020-03-30 20:35:30 +00:00
Alex Ling
aa147602fc Bump version number 0.2.2 -> 0.2.3 2020-03-27 05:00:14 +00:00
Alex Ling
d58c83fbd8 Use BigInt when sorting filenames (#22) 2020-03-27 04:45:03 +00:00
Alex Ling
1a0c3d81ce Add Patreon 2020-03-21 05:18:53 +00:00
Alex Ling
33c61fd8c1 Add build badge 2020-03-19 16:04:06 -04:00
Alex Ling
6eba3fe351 Create build.yml 2020-03-19 19:58:59 +00:00
Alex Ling
da2708abe5 Put mango binary in / instead of /root/Mango/ 2020-03-19 18:17:26 +00:00
Alex Ling
febf344d33 Remove unnecessary libraries 2020-03-19 18:16:48 +00:00
Alex Ling
ae15398b6c Name the builder stage 2020-03-19 18:14:02 +00:00
Alex Ling
b28f6046dd Merge pull request #17 from WROIATE/master
Update Dockerfile to reduce the image size
2020-03-19 12:29:19 -04:00
Jarao
91b823450c Update Dockerfile 2020-03-19 13:00:11 +08:00
Alex Ling
085fba611c Update README.md 2020-03-17 11:59:32 -04:00
Alex Ling
f8d633c751 Add example library structure to README 2020-03-17 11:45:46 -04:00
Alex Ling
f5e6f42fc2 Update README.md 2020-03-15 13:16:19 -04:00
Alex Ling
3ca6d3d338 Bump version (0.2.0 -> 0.2.1) 2020-03-15 17:09:27 +00:00
Alex Ling
750a28eccb Break words in modal title and path to handle long text 2020-03-15 02:58:27 +00:00
Alex Ling
88b16445e2 Show entry title instead of book title in modal 2020-03-15 02:55:35 +00:00
Alex Ling
7774efa471 When a title has no entry as immediate child, always return 0 as the reading progress 2020-03-15 02:30:18 +00:00
Alex Ling
4aeda53806 Sort title_ids and entries alphanumerically 2020-03-15 02:29:45 +00:00
Alex Ling
5d62a87720 Fix inaccurate sorting when sorting by progress 2020-03-15 02:28:21 +00:00
Alex Ling
e902e1dff0 Merge branch 'nested' into v0.2.1 2020-03-15 02:15:55 +00:00
Alex Ling
9fe32b5011 When a title contains no entry as immediate child, display mango logo and remove progress badge 2020-03-15 02:10:22 +00:00
Alex Ling
e65d701e0a Show sum of entries and titles count when displaying the number of entries 2020-03-15 02:08:20 +00:00
Alex Ling
5a500364fc Show a list of parent directories on the title page 2020-03-15 01:45:10 +00:00
Alex Ling
3e42266955 List the parent title objects in Title.to_json 2020-03-15 01:31:14 +00:00
Alex Ling
6407cea7bf Refactor src/library.cr to reduce memory usage
- Store the `Title` objects in `Library@title_hash`
- The `Title` objects only stores IDs to other titles
2020-03-15 01:05:37 +00:00
Alex Ling
7e22cc5f57 Fix bug in API /api/book/:tid that causes 500 2020-03-15 01:03:49 +00:00
Alex Ling
e68678f2fb Remove unnecessary JSON::Field calls 2020-03-14 23:59:46 +00:00
Alex Ling
82fb45b242 Use json builder in src/library.cr instead of json mapping 2020-03-14 23:58:49 +00:00
Alex Ling
46dfc2f712 Set login cookie expiration date 2020-03-14 22:53:52 +00:00
Alex Ling
79aa816ca8 Merge branch 'v0.2.0' of https://github.com/hkalexling/Mango into v0.2.0 2020-03-13 18:16:14 +00:00
Alex Ling
e35cf2ce0c Update README.md 2020-03-13 14:00:22 -04:00
Alex Ling
47ba0e39af Add dark mode screenshot 2020-03-13 13:59:25 -04:00
Alex Ling
aedb13ac92 Update README.md 2020-03-13 13:53:54 -04:00
Alex Ling
d1c0e52f90 Fix crash after generating default config 2020-03-13 17:46:28 +00:00
Alex Ling
173ff2d2e6 Ignore key mangadex_default in config YAML and remove unnecessary
calls to `YAML::Field`
2020-03-13 17:45:29 +00:00
Alex Ling
ae281e2e21 Bump version number (0.1.2 -> 0.2.0) 2020-03-13 17:03:20 +00:00
Alex Ling
2c10623731 Formatting 2020-03-13 17:03:06 +00:00
Alex Ling
31da5acdc5 Preserve line-breaks in download error messages 2020-03-13 17:00:52 +00:00
Alex Ling
77237a274a Color the close button in alert black so it won't disappear in dark
mode
2020-03-13 01:38:16 +00:00
Alex Ling
318501bc9b Show reading progress in reader 2020-03-13 01:05:16 +00:00
Alex Ling
dc5284968d Use helper function compare_alphanumerically(String, String) to make
the function call shorter
2020-03-12 23:53:51 +00:00
Alex Ling
01216d806c Add a helper function to combine the two steps of filenames sorting
(splitting and comparing)
2020-03-12 23:52:49 +00:00
Alex Ling
c4ffb5cd59 Handle the case when two split arrays have different size in
`compare_alphanumerically`
2020-03-12 23:51:09 +00:00
Alex Ling
50ce0e2b54 Fix typo 2020-03-12 23:49:40 +00:00
Alex Ling
8b8967de26 Simplify the split_by_alphanumeric function 2020-03-12 23:49:02 +00:00
Alex Ling
335fb45de6 Add spec for util.cr 2020-03-12 23:47:14 +00:00
Alex Ling
8c7ced87f1 Add nested library support (WIP) 2020-03-12 20:37:03 +00:00
Alex Ling
00d2540b95 Merge pull request #14 from Leeingnyo/change-page-sort
Sort page names alphanumerically
2020-03-12 13:37:59 -04:00
Leeingnyo
d120433525 Sort page alphanumerically
See https://ux.stackexchange.com/questions/95431/how-should-sorting-work-when-numeric-is-mixed-with-alpha-numeric
2020-03-13 02:27:59 +09:00
Alex Ling
9536ce62e6 Add the "auto" sorting option (#9) 2020-03-12 03:10:56 +00:00
Alex Ling
4ba81b9ffe Sort download jobs listed on the download manager by time 2020-03-11 19:28:04 +00:00
Alex Ling
c355c67415 Download older chapters (as shown on MangaDex) first 2020-03-11 19:26:23 +00:00
Alex Ling
4def23a5cf Fix the problem that URLs are not being parsed on the download page 2020-03-11 18:15:43 +00:00
Alex Ling
943076ccf7 Simplify the download queue tasks APIs 2020-03-11 18:11:32 +00:00
Alex Ling
36034042f2 Fix styling issues with dark theme on the download page 2020-03-11 03:07:09 +00:00
Alex Ling
36e2b2bfaf Add dark mode support 2020-03-07 02:51:08 +00:00
Alex Ling
c6c908953b Use transparent icon 2020-03-07 02:50:44 +00:00
Alex Ling
3ae0ad6348 Add fontawesome and add the "adjust" icon to navigation bar to prepare for dark
mode.
2020-03-06 02:25:10 +00:00
Alex Ling
7ca40215b6 Remove temp DB files before resetting the state 2020-03-04 01:56:48 +00:00
Alex Ling
54206bc6ac Finish the test cases for MangaDex::Queue 2020-03-04 01:52:17 +00:00
Alex Ling
1abdac2fdd Better state management in spec and add some tests for MangaDex::Queue 2020-03-03 18:34:39 +00:00
Alex Ling
9ffe896705 Only log the "baking dist/" message when building for release 2020-03-03 02:51:45 +00:00
Alex Ling
7a7c855ce4 Remove the unused gzip import from static_handler.cr 2020-03-03 02:51:09 +00:00
Alex Ling
e2d01f7eb9 Remove error handling in the parse_query_result method of
MangaDex::Job, and pass the possible exceptions to the frontend and
handle them there.
2020-03-03 02:33:32 +00:00
Alex Ling
7575785c1c Remove the unused get method from MangaDex::Queue 2020-03-03 02:32:08 +00:00
Alex Ling
dfd53bc51d Fix incorrect variable in MangaDex::Downloader (@stop -> @stopped) 2020-03-03 02:06:27 +00:00
Alex Ling
f140ffa4b2 Add and use MLogger in MangaDex::Queue and MangaDex::Downloader 2020-03-03 02:05:55 +00:00
Alex Ling
589483cd75 Center the message on message.ecr 2020-03-02 16:55:08 +00:00
Alex Ling
306edc3c77 Handle HTTP 404 and 500 errors 2020-03-02 16:54:29 +00:00
Alex Ling
30af64e9ca Add pause/resume download button to the download manager 2020-03-02 16:30:05 +00:00
Alex Ling
fecb96c91b Redirect to the download manager after adding jobs to the queue 2020-03-02 02:10:01 +00:00
Alex Ling
4f01aba3e1 Add link to the download manager page on the admin page 2020-03-02 01:57:09 +00:00
Alex Ling
f13f7989d5 Finish the download manager page 2020-03-02 01:50:04 +00:00
Alex Ling
1ce553f541 Add the /admin/downloads page for monitoring download queue 2020-03-01 03:05:40 +00:00
Alex Ling
c4253db572 Add status message to MangaDex::Job and improve formatting 2020-03-01 03:04:55 +00:00
Alex Ling
db6d33eae1 Add sourcerer.io contributors 2020-02-29 21:47:59 -05:00
Alex Ling
8fbc5528a8 Move the JS alert function definition to /js/alert.js and move the alert div to views/layout.ecr 2020-03-01 01:23:16 +00:00
Alex Ling
d50804830d Merge branch 'master' into v0.2.0 2020-02-29 17:50:38 +00:00
Alex Ling
5d7bbc7c9b Remove @log from MangaDex::Job and save the @success_count and
@fail_count instead
2020-02-29 04:11:48 +00:00
Alex Ling
0b463539c9 Better log in MangaDex::API when status is not OK, and handles external
chapters
2020-02-29 04:10:09 +00:00
Alex Ling
7f0088f45a Fix typo (embeded -> embedded) 2020-02-29 00:08:27 +00:00
Alex Ling
5645f272df When an invalid zip file is found, instead of ignoring it silently, log
a warning message
2020-02-28 18:08:56 +00:00
Alex Ling
dc3bbd10d6 Close zip file after listing entries to prevent leaking 2020-02-28 17:55:45 +00:00
Alex Ling
c89c74c71b Bump version to 0.1.2 2020-02-28 17:53:35 +00:00
Alex Ling
cb76a96126 Removing the logging of the library json on scan 2020-02-28 17:52:50 +00:00
Alex Ling
73b38492ba Remove console in minified JS files 2020-02-28 17:52:14 +00:00
Alex Ling
bf37c4aa10 Sorting in library and title page 2020-02-28 17:51:26 +00:00
Alex Ling
f837be0718 Ignore invalid zip files in library 2020-02-28 17:45:30 +00:00
Alex Ling
8c47d50291 Update download API to use Queue in Context and use @full_title 2020-02-28 17:38:45 +00:00
Alex Ling
4ca8daca29 Add API to fetch download queue 2020-02-28 17:38:08 +00:00
Alex Ling
d3d8dff6d2 Add MangaDex::Queue to Context 2020-02-28 17:37:08 +00:00
Alex Ling
f11a5cd608 Finish downloader 2020-02-28 17:36:21 +00:00
Alex Ling
6bccba16da Add MangaDex::PageJob and MangaDex::Downloader 2020-02-28 17:31:09 +00:00
Alex Ling
28ac5c7a00 Formatting in mangadex/api.cr 2020-02-28 17:17:57 +00:00
Alex Ling
f8e0c6d795 Remove download code from mangadex/api.cr, as the download functionality
is now handled by mangadex/downloader.cr
2020-02-28 17:15:55 +00:00
Alex Ling
e3d505d62b Add @full_title to MangaDex::Chapter 2020-02-28 17:14:38 +00:00
Alex Ling
77864afa67 Removing the logging of the library json on scan 2020-02-28 16:55:39 +00:00
Alex Ling
5abdca24c2 Ignore invalid zip files in library 2020-02-28 16:54:37 +00:00
Alex Ling
e8c365b7a1 Rename module Mangadex to MangaDex 2020-02-28 16:48:48 +00:00
Alex Ling
6659041631 Allow missing keys in the "mangadex" dictionary in the config file 2020-02-28 16:24:46 +00:00
Alex Ling
fa50f4cb88 Delete the single job push method 2020-02-26 23:55:38 +00:00
Alex Ling
c39a1ddbaf Merge branch 'master' into v0.2.0 2020-02-26 18:26:49 +00:00
Alex Ling
7de01991a0 Fix the problem that all chapters (regardless of selections) will be
posted to the API
2020-02-26 18:20:14 +00:00
Alex Ling
319967438b Merge branch 'master' into v0.2.0 2020-02-26 18:19:30 +00:00
Alex Ling
1bbb08eede Show spinner and hide download button when posting to API 2020-02-26 17:46:28 +00:00
Alex Ling
d9d1dbc26f Post selected chapter JSON object to API, instead of just posting the
IDs
2020-02-26 17:45:37 +00:00
Alex Ling
c33884ea29 Add mangadex download enpoint to API 2020-02-26 17:42:40 +00:00
Alex Ling
2dd980b92c Add mangadex/downloader.cr (implements MangaDex::Job and
MangaDex::Queue)
2020-02-26 17:41:50 +00:00
Alex Ling
89e747d3ee Get Chapter object directly from chapter ID 2020-02-26 17:31:53 +00:00
Alex Ling
468f109776 Add Manga object as a property of MangaDex::Chapter 2020-02-26 17:31:04 +00:00
Alex Ling
905d02e911 Add queue db path to config.cr 2020-02-26 17:29:34 +00:00
Alex Ling
bb00c2e77f Close zip file after listing entries to prevent leaking 2020-02-26 17:24:16 +00:00
Alex Ling
bc75f4d336 Add unit test 2020-02-25 19:00:10 +00:00
Alex Ling
98baf63b0c Add gitter to README 2020-02-24 22:19:31 -05:00
Alex Ling
46b36860d1 Add static build target to Makefile 2020-02-25 02:32:32 +00:00
Alex Ling
9f6261e02d Make the selection and download buttons on the download page functional 2020-02-25 02:29:00 +00:00
Alex Ling
d782995bac - Filter MangaDex chapters with volume and chapter range
- Select from table rows using jQuery selectable
2020-02-25 01:52:04 +00:00
Alex Ling
b264f7dd76 Remove console in minified JS files 2020-02-24 15:45:05 +00:00
Alex Ling
59622930c7 Fix the problem that minified assets in dist/ are not used. 2020-02-24 15:41:18 +00:00
Alex Ling
e90b97ca43 Display timestamp as time from now using moments.js 2020-02-24 03:14:05 +00:00
Alex Ling
b58d2e3620 Show chapter count 2020-02-24 03:13:47 +00:00
Alex Ling
a507e3be7a Filter in MangaDex search results 2020-02-24 02:56:50 +00:00
Alex Ling
bf0f5270f0 Merge branch 'v0.1.1' 2020-02-23 21:16:23 +00:00
Alex Ling
ac620e1f2a Update docker-compose example 2020-02-23 21:09:50 +00:00
Alex Ling
a7519a791e Fix the problem that minified assets in dist/ are not used. 2020-02-23 19:18:30 +00:00
Alex Ling
7a21f4dc9b Sort titles in library by name by default 2020-02-23 18:36:10 +00:00
Alex Ling
650ebc7f9d Fix #6 2020-02-23 18:35:27 +00:00
Alex Ling
5b34c05243 Use Babel, so I can write modern JS and save my sanity 2020-02-23 18:26:57 +00:00
Alex Ling
803fc8c44b Split routes in server.cr into small files 2020-02-23 18:24:32 +00:00
Alex Ling
67d3d2bd55 Sorting in library and title page 2020-02-22 18:16:04 +00:00
Alex Ling
5ec35f3af6 Sort titles in library by name by default 2020-02-22 18:15:40 +00:00
Alex Ling
dd49f75079 Update README.md 2020-02-21 21:22:55 -05:00
Alex Ling
6be9c3eac6 Update README.md 2020-02-21 21:21:34 -05:00
Alex Ling
0fa95959a7 Basic search functionality 2020-02-22 02:14:01 +00:00
Alex Ling
83597e7f84 Fix typo 2020-02-22 02:13:35 +00:00
Alex Ling
c893135ec6 Use Babel, so I can write mordern JS and save my sanity 2020-02-22 02:12:01 +00:00
Alex Ling
5a2f80b5e1 Add issue templates 2020-02-21 16:42:12 -05:00
Alex Ling
5b4d79220c Provide pre-built binary (amd64) in README 2020-02-21 14:08:55 -05:00
Alex Ling
a3356344fa Add API for fetching manga info from ID 2020-02-21 02:00:01 +00:00
Alex Ling
aecac748dc Add /download page 2020-02-21 01:59:37 +00:00
Alex Ling
c449a1e9b1 Add /download page 2020-02-21 01:59:09 +00:00
Alex Ling
f9a4698fca Add mangadex to config 2020-02-21 01:58:45 +00:00
Alex Ling
676f2ae032 Include chapter information in MangaDex::Manga#to_info_json output 2020-02-21 01:56:57 +00:00
Alex Ling
fd342fe1ee Add mangadex lang code csv to src/assets 2020-02-20 23:41:40 +00:00
Alex Ling
1649f286aa Split routes in server.cr into small files 2020-02-20 23:39:54 +00:00
Alex Ling
60a1032f71 add a basic Mangadex API wrapper 2020-02-20 01:32:18 +00:00
112 changed files with 6658 additions and 1346 deletions

9
.ameba.yml Normal file
View File

@@ -0,0 +1,9 @@
Lint/UselessAssign:
Excluded:
- src/routes/*
- src/server.cr
Lint/UnusedArgument:
Excluded:
- src/routes/*
Metrics/CyclomaticComplexity:
Enabled: false

5
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
# These are supported funding model platforms
open_collective: mango
patreon: hkalexling
ko_fi: hkalexling

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bug report
about: I found a bug in Mango!
title: "[Bug Report]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 18.04]
- Browser [e.g. chrome, safari, if applicable]
- Mango Version [e.g. v0.1.0]
**Docker (if you are running Mango in a Docker container)**
- The `docker-compose.yml` file you are using, or your `.env` file.
**Additional context**
Add any other context about the problem here. Add screenshots if applicable.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest a feature for Mango
title: "[Feature Request]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. E.g. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe a small use-case for this feature request**
How would you imagine this to be used? What would be the advantage of this for the users of the application?
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,10 @@
---
name: General Question
about: I have a question about Mango
title: "[Question]"
labels: general question
assignees: ''
---

BIN
.github/screenshots/dark.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

31
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Build
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest
container:
image: crystallang/crystal:0.34.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
- name: Build
run: make static
- name: Linter
run: make check
- name: Run tests
run: make test
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: mango
path: mango

19
.github/workflows/dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Publish Dockerhub
on:
release:
types: [published]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Get release version
id: get_version
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
- name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master
with:
name: hkalexling/mango
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
tags: "latest,${{ env.RELEASE_VERSION }}"

6
.gitignore vendored
View File

@@ -7,4 +7,8 @@ node_modules
yarn.lock
dist
mango
docker-compose.yml
.env
*.md
public/css/uikit.css
public/img/*.svg
public/js/*.min.js

View File

@@ -1,18 +1,16 @@
FROM crystallang/crystal:0.32.0
RUN apt-get update && apt-get install -y curl
RUN curl -sL https://deb.nodesource.com/setup_10.x | bash -
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install -y nodejs yarn libsqlite3-dev
FROM crystallang/crystal:0.34.0-alpine AS builder
WORKDIR /Mango
COPY . .
COPY package*.json .
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
&& make static
RUN make && make install
FROM library/alpine
CMD ["mango"]
WORKDIR /
COPY --from=builder /Mango/mango .
CMD ["./mango"]

View File

@@ -1,4 +1,4 @@
PREFIX=/usr/local
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
all: uglify | build
@@ -7,15 +7,30 @@ uglify:
yarn
yarn uglify
setup: libs
yarn
yarn gulp dev
build: libs
crystal build src/mango.cr --release --progress
static: uglify | libs
crystal build src/mango.cr --release --progress --static
libs:
shards install
shards install --production
run:
crystal run src/mango.cr --error-trace
test:
crystal spec
check:
crystal tool format --check
./bin/ameba
./dev/linewidth.sh
install:
cp mango $(INSTALL_DIR)/mango

View File

@@ -1,30 +1,46 @@
![banner](./public/img/banner-paddings.png)
# Mango
![banner](./public/img/banner-paddings.png)
[![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)
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
- Supports both `.zip` and `.cbz` formats
- OPDS support
- Dark/light mode switch
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library
- Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-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
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
## Installation
### Pre-built Binary
Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose`
2. Clone the repository
3. Copy `docker-compose.example.yml` to `docker-compose.yml`
4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine
3. Copy the `env.example` file to `.env`
4. Fill out the values in the `.env` file. Note that the main and config directories will be created if they don't already exist. The files in these folders will be owned by the root user
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
6. Head over to `localhost:9000` to log in
6. Head over to `localhost:9000` (or a different port if you changed it) to log in
### Docker (via Dockerhub)
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
### Build from source
1. Make sure you have Crystal, Node and Yarn installed
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
2. Clone the repository
3. `make && sudo make install`
4. Start Mango by running the command `mango`
@@ -35,11 +51,21 @@ Mango is a self-hosted manga server and reader. Its features include
### CLI
```
Mango e-manga server/reader. Version 0.1.0
Mango - Manga Server and Web Reader. Version 0.10.0
-v, --version Show version
-h, --help Show help
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
Usage:
mango [sub_command] [options]
Options:
-c PATH, --config=PATH Path to the config file [type:String]
-h, --help Show this help.
-v, --version Show version.
Sub Commands:
admin Run admin tools
```
### Config
@@ -49,29 +75,42 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml
---
port: 9000
base_url: /
library_path: ~/mango/library
db_path: ~/mango/mango.db
scan_interval_minutes: 5
log_level: info
upload_path: ~/mango/uploads
mangadex:
base_url: https://mangadex.org
api_url: https://mangadex.org/api
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}'
```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
### Required Library Structure
### Library Structure
Please make sure that your library directory has the following structure:
You can organize your archive files in nested folders in the library directory. Here's an example:
```
.
├── Manga 1
│   └── Manga 1.cbz
│   ├── Volume 1.cbz
│   ├── Volume 2.cbz
│   ├── Volume 3.cbz
│   └── Volume 4.zip
└── Manga 2
├── Vol 0001.zip
├── Vol 0002.zip
├── Vol 0003.zip
├── Vol 0004.zip
└── Vol 0005.zip
   └── Vol. 1
   └── Ch.1 - Ch.3
   ├── 1.zip
   ├── 2.zip
   └── 3.zip
```
### Initial Login
@@ -88,6 +127,10 @@ Title:
![title screenshot](./.github/screenshots/title.png)
Dark mode:
![dark mode screeshot](./.github/screenshots/dark.png)
Reader:
![reader screenshot](./.github/screenshots/reader.png)
@@ -95,3 +138,9 @@ Reader:
Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png)
## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)

5
dev/linewidth.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \
|| exit 0

View File

@@ -7,9 +7,9 @@ services:
context: .
dockerfile: ./Dockerfile
expose:
- 9000
- ${PORT}
ports:
- 9000:9000
- "${PORT}:9000"
volumes:
- ./mango:/root/mango
- ./config:/root/.config/mango
- ${MAIN_DIRECTORY_PATH}:/root/mango
- ${CONFIG_DIRECTORY_PATH}:/root/.config/mango

10
env.example Normal file
View File

@@ -0,0 +1,10 @@
# Port that exposes the HTTP frontend
PORT=9000
# Path to the mango main directory
# This directory holds the database and the library files
MAIN_DIRECTORY_PATH=
# Path to the mango config directory
# This directory holds the mango configuration path
CONFIG_DIRECTORY_PATH=

View File

@@ -1,27 +1,56 @@
const gulp = require('gulp');
const uglify = require('gulp-uglify');
const minify = require("gulp-babel-minify");
const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
gulp.task('copy-uikit-js', () => {
return gulp.src('node_modules/uikit/dist/js/*.min.js')
.pipe(gulp.dest('public/js'));
});
gulp.task('minify-js', () => {
return gulp.src('public/js/*.js')
.pipe(uglify())
.pipe(minify({
removeConsole: true,
builtIns: false
}))
.pipe(gulp.dest('dist/js'));
});
gulp.task('less', () => {
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
gulp.task('minify-css', () => {
return gulp.src('public/css/*.css')
.pipe(minifyCss())
.pipe(gulp.dest('dist/css'));
});
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('img', () => {
return gulp.src('public/img/*')
.pipe(gulp.dest('dist/img'));
});
gulp.task('favicon', () => {
return gulp.src('public/favicon.ico')
gulp.task('copy-files', () => {
return gulp.src('public/*.*')
.pipe(gulp.dest('dist'));
});
gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon'));
gulp.task('default', gulp.parallel(
gulp.series('copy-uikit-js', 'minify-js'),
gulp.series('less', 'minify-css'),
gulp.series('copy-uikit-icons', 'img'),
'copy-files'
));
gulp.task('dev', gulp.parallel(
'copy-uikit-js', 'less', 'copy-uikit-icons'
));

View File

@@ -7,10 +7,15 @@
"license": "MIT",
"devDependencies": {
"gulp": "^4.0.2",
"gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4",
"gulp-uglify": "^3.0.2"
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"uikit": "^3.5.4"
}
}

View File

@@ -1,28 +1,137 @@
.uk-alert-close {
color: black !important;
}
.uk-card-body {
padding: 20px;
padding: 20px;
}
.uk-card-media-top {
max-height: 350px;
overflow: hidden;
width: 100%;
height: 250px;
}
.acard:hover {
text-decoration: none;
@media (min-width: 600px) {
.uk-card-media-top {
height: 300px;
}
}
.uk-list li {
cursor: pointer;
}
.reader-bg {
background-color: black;
}
#scan-status {
cursor: auto;
.uk-card-media-top>img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title {
word-wrap: break-word;
max-height: 3em;
}
.uk-logo > img {
max-height: 90px;
.acard:hover {
cursor: pointer;
}
.uk-list li:not(.nopointer) {
cursor: pointer;
}
#scan-status {
cursor: auto;
}
.reader-bg {
background-color: black;
}
.break-word {
word-wrap: break-word;
}
.uk-logo>img {
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;
width: 100%;
}
#selectable .ui-selecting {
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
}
.uk-light #selectable .ui-selecting {
background: #5E5731;
}
.uk-light #selectable .ui-selected {
background: #9D9252;
}
td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}

45
public/css/uikit.less Normal file
View File

@@ -0,0 +1,45 @@
@import "node_modules/uikit/src/less/uikit.theme.less";
.label {
display: inline-block;
padding: @label-padding-vertical @label-padding-horizontal;
background: @label-background;
line-height: @label-line-height;
font-size: @label-font-size;
color: @label-color;
vertical-align: middle;
white-space: nowrap;
.hook-label;
}
.label-success {
background-color: @label-success-background;
color: @label-success-color;
}
.label-warning {
background-color: @label-warning-background;
color: @label-warning-color;
}
.label-danger {
background-color: @label-danger-background;
color: @label-danger-color;
}
.label-pending {
background-color: @global-secondary-background;
color: @global-inverse-color;
}
@internal-divider-icon-image: "../img/divider-icon.svg";
@internal-form-select-image: "../img/form-select.svg";
@internal-form-datalist-image: "../img/form-datalist.svg";
@internal-form-radio-image: "../img/form-radio.svg";
@internal-form-checkbox-image: "../img/form-checkbox.svg";
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
@internal-list-bullet-image: "../img/list-bullet.svg";
@internal-accordion-open-image: "../img/accordion-open.svg";
@internal-accordion-close-image: "../img/accordion-close.svg";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/img/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -1,13 +1,14 @@
var scanning = false;
function scan() {
let scanning = false;
const scan = () => {
scanning = true;
$('#scan-status > div').removeAttr('hidden');
$('#scan-status > span').attr('hidden', '');
var color = $('#scan').css('color');
const color = $('#scan').css('color');
$('#scan').css('color', 'gray');
$.post('/api/admin/scan', function (data) {
var ms = data.milliseconds;
var titles = data.titles;
$.post(base_url + 'api/admin/scan', (data) => {
const ms = data.milliseconds;
const titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
$('#scan-status > span').removeAttr('hidden');
$('#scan').css('color', color);
@@ -15,11 +16,25 @@ function scan() {
scanning = false;
});
}
$(function() {
$('li').click(function() {
url = $(this).attr('data-url');
String.prototype.capitalize = function() {
return this.charAt(0).toUpperCase() + this.slice(1);
}
$(() => {
$('li').click((e) => {
const url = $(e.currentTarget).attr('data-url');
if (url) {
$(location).attr('href', url);
}
});
const setting = loadThemeSetting();
$('#theme-select').val(setting.capitalize());
$('#theme-select').change((e) => {
const newSetting = $(e.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting);
setTheme();
});
});

6
public/js/alert.js Normal file
View File

@@ -0,0 +1,6 @@
const alert = (level, text) => {
$('#alert').empty();
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
$('#alert').append(html);
$("html, body").animate({ scrollTop: 0 });
};

17
public/js/dots.js Normal file
View File

@@ -0,0 +1,17 @@
const truncate = () => {
$('.uk-card-title').each((i, e) => {
$(e).dotdotdot({
truncate: 'letter',
watch: true,
callback: (truncated) => {
if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
}
}
});
});
};
truncate();

View File

@@ -0,0 +1,145 @@
$(() => {
$('input.uk-checkbox').each((i, e) => {
$(e).change(() => {
loadConfig();
});
});
loadConfig();
load();
const intervalMS = 5000;
setTimeout(() => {
setInterval(() => {
if (globalConfig.autoRefresh !== true) return;
load();
}, intervalMS);
}, intervalMS);
});
var globalConfig = {};
var loading = false;
const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
};
const remove = (id) => {
var url = base_url + 'api/admin/mangadex/queue/delete';
if (id !== undefined)
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const refresh = (id) => {
var url = base_url + 'api/admin/mangadex/queue/retry';
if (id !== undefined)
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to restart download job. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const toggle = () => {
$('#pause-resume-btn').attr('disabled', '');
const paused = $('#pause-resume-btn').text() === 'Resume download';
const action = paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
load();
$('#pause-resume-btn').removeAttr('disabled');
});
};
const load = () => {
if (loading) return;
loading = true;
console.log('fetching');
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
console.log(data);
const btnText = data.paused ? "Resume download" : "Pause download";
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending';
if (obj.status === 'Completed')
cls += 'label-success';
if (obj.status === 'Error')
cls += 'label-danger';
if (obj.status === 'MissingPages')
cls += 'label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}">
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
<td>${obj.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td>
<td>${obj.plugin_id || ""}</td>
<td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn}
</td>
</tr>`;
});
const tbody = `<tbody>${rows.join('')}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
loading = false;
});
};

305
public/js/download.js Normal file
View File

@@ -0,0 +1,305 @@
$(() => {
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('.filter-field').each((i, ele) => {
$(ele).change(() => {
buildTable();
});
});
});
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const ids = selected.map((i, e) => {
return $(e).find('td').first().text();
}).get();
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
$.ajax({
type: 'POST',
url: base_url + 'api/admin/mangadex/download',
data: JSON.stringify({
chapters: chapters
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
});
};
const toggleSpinner = () => {
var attr = $('#spinner').attr('hidden');
if (attr) {
$('#spinner').removeAttr('hidden');
$('#search-btn').attr('hidden', '');
} else {
$('#search-btn').removeAttr('hidden');
$('#spinner').attr('hidden', '');
}
searching = !searching;
};
var searching = false;
var globalChapters;
const search = () => {
if (searching) {
return;
}
$('#manga-details').attr('hidden', '');
$('#filter-form').attr('hidden', '');
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
toggleSpinner();
const input = $('input').val();
if (input === "") {
toggleSpinner();
return;
}
var int_id = -1;
try {
const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(input);
}
if (int_id <= 0 || isNaN(int_id)) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
toggleSpinner();
return;
}
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
return;
}
const cover = baseURL + data.cover_url;
$('#cover').attr("src", cover);
$('#title').text("Title: " + data.title);
$('#artist').text("Artist: " + data.artist);
$('#author').text("Author: " + data.author);
$('#manga-details').removeAttr('hidden');
console.log(data.chapters);
globalChapters = data.chapters;
let langs = new Set();
let group_names = new Set();
data.chapters.forEach(chp => {
Object.entries(chp.groups).forEach(([k, v]) => {
group_names.add(k);
});
langs.add(chp.language);
});
const comp = (a, b) => {
var ai;
var bi;
try {
ai = parseFloat(a);
} catch (e) {}
try {
bi = parseFloat(b);
} catch (e) {}
if (typeof ai === 'undefined') return -1;
if (typeof bi === 'undefined') return 1;
if (ai < bi) return 1;
if (ai > bi) return -1;
return 0;
};
langs = [...langs].sort();
group_names = [...group_names].sort();
langs.unshift('All');
group_names.unshift('All');
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
$('#filter-form').removeAttr('hidden');
buildTable();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
toggleSpinner();
});
};
const parseRange = str => {
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
const matches = str.match(regex);
var num;
if (!matches) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
}
switch (matches[1]) {
case '<':
return [null, num - 1];
case '<=':
return [null, num];
case '>':
return [num + 1, null];
case '>=':
return [num, null];
}
} else if (typeof matches[3] !== 'undefined') {
// a single number
num = parseInt(matches[3]);
if (isNaN(num)) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
}
return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23
num = parseInt(matches[4]);
const n2 = parseInt(matches[5]);
if (isNaN(num) || isNaN(n2) || num > n2) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
};
const getFilters = () => {
const filters = {};
$('.uk-select').each((i, ele) => {
const id = $(ele).attr('id');
const by = id.split('-')[0];
const choice = $(ele).val();
filters[by] = choice;
});
filters.volume = parseRange($('#volume-range').val());
filters.chapter = parseRange($('#chapter-range').val());
return filters;
};
const buildTable = () => {
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
console.log('rebuilding table');
const filters = getFilters();
console.log('filters:', filters);
var chapters = globalChapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
chapters = chapters.filter(c => {
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return;
}
if (k === 'lang') {
chapters = chapters.filter(c => c.language === v);
return;
}
const lb = parseFloat(v[0]);
const ub = parseFloat(v[1]);
if (isNaN(lb) && isNaN(ub)) return;
chapters = chapters.filter(c => {
const val = parseFloat(c[k]);
if (isNaN(val)) return false;
if (isNaN(lb))
return val <= ub;
else if (isNaN(ub))
return val >= lb;
else
return val >= lb && val <= ub;
});
});
console.log('filtered chapters:', chapters);
$('#count-text').text(`${chapters.length} chapters found`);
const chaptersLimit = 1000;
if (chapters.length > chaptersLimit) {
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
$('#filter-notification').removeAttr('hidden');
return;
}
const inner = chapters.map(chp => {
const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | ');
return `<tr class="ui-widget-content">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td>
<td>${chp.language}</td>
<td>${group_str}</td>
<td>${chp.volume}</td>
<td>${chp.chapter}</td>
<td>${moment.unix(chp.time).fromNow()}</td>
</tr>`;
}).join('');
const tbody = `<tbody id="selectable">${inner}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
$('table').removeAttr('hidden');
$("#selectable").selectable({
filter: 'tr'
});
$('#selection-controls').removeAttr('hidden');
};
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};

5
public/js/fontawesome.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
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;
};
$(() => {
var storedID = localStorage.getItem('plugin');
if (storedID && storedID !== pid) {
loadPlugin(storedID);
} else {
$('#controls').removeAttr('hidden');
}
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('#plugin-select').val(pid);
$('#plugin-select').change(() => {
const id = $('#plugin-select').val();
loadPlugin(id);
});
});
let mangaTitle = "";
let searching = false;
const search = () => {
if (searching)
return;
const query = $('#search-input').val();
$.ajax({
type: 'POST',
url: base_url + 'api/admin/plugin/list',
data: JSON.stringify({
query: query,
plugin: pid
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Search failed. Error: ${data.error}`);
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');
});
});
};

View File

@@ -3,18 +3,18 @@ $(function() {
var controller = new ScrollMagic.Controller();
// replace history on scroll
$('img').each(function(idx){
$('img').each(function(idx) {
var scene = new ScrollMagic.Scene({
triggerElement: $(this).get(),
triggerHook: 'onEnter',
reverse: true
})
triggerElement: $(this).get(),
triggerHook: 'onEnter',
reverse: true
})
.addTo(controller)
.on('enter', function(event){
.on('enter', function(event) {
current = $(event.target.triggerElement()).attr('id');
replaceHistory(current);
})
.on('leave', function(event){
.on('leave', function(event) {
var prev = $(event.target.triggerElement()).prev();
current = $(prev).attr('id');
replaceHistory(current);
@@ -23,12 +23,12 @@ $(function() {
// poor man's infinite scroll
var scene = new ScrollMagic.Scene({
triggerElement: $('.next-url').get(),
triggerHook: 'onEnter',
offset: -500
})
triggerElement: $('.next-url').get(),
triggerHook: 'onEnter',
offset: -500
})
.addTo(controller)
.on('enter', function(){
.on('enter', function() {
var nextURL = $('.next-url').attr('href');
$('.next-url').remove();
if (!nextURL) {
@@ -39,7 +39,7 @@ $(function() {
$('#next-btn').removeAttr('hidden');
return;
}
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
if (status === 'error') console.log(xhr.statusText);
if (status === 'success') {
console.log(nextURL + ' loaded');
@@ -54,13 +54,18 @@ $(function() {
bind();
});
$('#page-select').change(function(){
$('#page-select').change(function() {
jumpTo(parseInt($('#page-select').val()));
});
function showControl(idx) {
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
}
function jumpTo(page) {
var ary = window.location.pathname.split('/');
ary[ary.length - 1] = page;
@@ -68,10 +73,12 @@ function jumpTo(page) {
ary.unshift(window.location.origin);
window.location.replace(ary.join('/'));
}
function replaceHistory(url) {
history.replaceState(null, "", url);
console.log('reading ' + url);
}
function redirect(url) {
window.location.replace(url);
}

5
public/js/solid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

15
public/js/sort-items.js Normal file
View File

@@ -0,0 +1,15 @@
$(() => {
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
sort: by,
ascend: dir === 'up' ? 1 : 0
})}`;
window.location.href = newURL;
});
});

72
public/js/theme.js Normal file
View File

@@ -0,0 +1,72 @@
// https://flaviocopes.com/javascript-detect-dark-mode/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
// dark / light / system
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light';
return str;
};
// dark / light
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light';
localStorage.setItem('theme', setting);
};
// when toggled, Auto will be changed to light or dark
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});

View File

@@ -1,49 +1,244 @@
function showModal(title, zipPath, pages, percentage, title, entry) {
$('#modal button, #modal a').each(function(){
$(() => {
setupAcard();
});
const setupAcard = () => {
$('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard');
showModal(
$(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'),
$(card).attr('data-book-id'),
$(card).attr('data-id')
);
});
};
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function() {
$(this).removeAttr('hidden');
});
if (percentage === 0) {
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
}
else {
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
if (percentage === 100) {
$('#read-btn').attr('hidden', '');
}
$('#modal-title').text(title);
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', '/reader/' + title + '/' + entry + '/1');
$('#continue-btn').attr('href', '/reader/' + title + '/' + entry);
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function(){
updateProgress(title, entry, pages);
$('#read-btn').click(function() {
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function(){
updateProgress(title, entry, 0);
$('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0);
});
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show();
}
function updateProgress(title, entry, page) {
$.post('/api/progress/' + title + '/' + entry + '/' + page, function(data) {
const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
entry: eid
});
if (eid)
url += `?${query}`;
$.post(url, (data) => {
if (data.success) {
location.reload();
}
else {
} else {
error = data.error;
alert('danger', error);
}
});
}
function alert(level, text) {
hideAlert();
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
$('#alert').append(html);
}
function hideAlert() {
$('#alert').empty();
}
};
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;
}
const query = $.param({
entry: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. 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();
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
} else {
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
});
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
};
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {
title: titleId
};
if (eid)
queryObj['entry'] = 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',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};
const deselectAll = () => {
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
data['selected'] = false;
});
$('#select-bar')[0].__x.$data['count'] = 0;
};
const selectAll = () => {
let count = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled']) {
data['selected'] = true;
count++;
}
});
$('#select-bar')[0].__x.$data['count'] = count;
};
const selectedIDs = () => {
const ary = [];
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item');
ary.push($(item).attr('id'));
}
});
return ary;
};
const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk-progress/${action}/${tid}`;
$.ajax({
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
ids: ids
})
})
.done(data => {
if (data.error) {
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
deselectAll();
});
};

View File

@@ -1,16 +1,6 @@
$(function(){
var target = '/admin/user/edit';
$(() => {
var target = base_url + 'admin/user/edit';
if (username) target += username;
$('form').attr('action', target);
function alert(level, text) {
hideAlert();
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
$('#alert').append(html);
}
function hideAlert() {
$('#alert').empty();
}
if (error) alert('danger', error);
});

View File

@@ -1,13 +1,5 @@
function alert(level, text) {
hideAlert();
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
$('#alert').append(html);
}
function hideAlert() {
$('#alert').empty();
}
function remove(username) {
$.post('/api/admin/user/delete/' + username, function(data) {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) {
if (data.success) {
location.reload();
}

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -1,30 +1,54 @@
version: 1.0
shards:
ameba:
github: crystal-ameba/ameba
version: 0.12.1
archive:
github: hkalexling/archive.cr
version: 0.4.0
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8
clim:
github: at-grandpa/clim
version: 0.12.0
db:
github: crystal-lang/crystal-db
version: 0.8.0
version: 0.9.0
duktape:
github: jessedoyle/duktape.cr
version: 0.20.0
exception_page:
github: crystal-loot/exception_page
version: 0.1.2
version: 0.1.4
kemal:
github: kemalcr/kemal
version: 0.26.1
kemal-basic-auth:
github: kemalcr/kemal-basic-auth
version: 0.2.0
kemal-session:
github: kemalcr/kemal-session
version: 0.12.1
kilt:
github: jeromegn/kilt
version: 0.4.0
myhtml:
github: kostya/myhtml
version: 1.5.1
radix:
github: luislavena/radix
version: 0.3.9
sqlite3:
github: crystal-lang/crystal-sqlite3
version: 0.15.0
version: 0.16.0

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.1.0
version: 0.10.0
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -8,14 +8,27 @@ targets:
mango:
main: src/mango.cr
crystal: 0.32.1
crystal: 0.34.0
license: MIT
dependencies:
kemal:
github: kemalcr/kemal
kemal-session:
github: kemalcr/kemal-session
sqlite3:
github: crystal-lang/crystal-sqlite3
baked_file_system:
github: schovi/baked_file_system
archive:
github: hkalexling/archive.cr
ameba:
github: crystal-ameba/ameba
clim:
github: at-grandpa/clim
duktape:
github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml:
github: kostya/myhtml

View File

@@ -0,0 +1,2 @@
---
port: 3000

14
spec/config_spec.cr Normal file
View File

@@ -0,0 +1,14 @@
require "./spec_helper"
describe Config do
it "creates config if it does not exist" do
with_default_config do |_, path|
File.exists?(path).should be_true
end
end
it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000
end
end

76
spec/rename_spec.cr Normal file
View File

@@ -0,0 +1,76 @@
require "./spec_helper"
require "../src/rename"
include Rename
describe Rule do
it "raises on nested brackets" do
expect_raises Exception do
Rule.new "[[]]"
end
expect_raises Exception do
Rule.new "{{}}"
end
end
it "raises on unclosed brackets" do
expect_raises Exception do
Rule.new "["
end
expect_raises Exception do
Rule.new "{"
end
expect_raises Exception do
Rule.new "[{]}"
end
end
it "raises when closing unopened brackets" do
expect_raises Exception do
Rule.new "]"
end
expect_raises Exception do
Rule.new "[}"
end
end
it "handles `|` in patterns" do
rule = Rule.new "{a|b|c}"
rule.render({"b" => "b"}).should eq "b"
rule.render({"a" => "a", "b" => "b"}).should eq "a"
end
it "allows `|` outside of patterns" do
rule = Rule.new "hello|world"
rule.render({} of String => String).should eq "hello|world"
end
it "raises on escaped characters" do
expect_raises Exception do
Rule.new "hello/world"
end
end
it "handles spaces in patterns" do
rule = Rule.new "{ a }"
rule.render({"a" => "a"}).should eq "a"
end
it "strips leading and tailing spaces" do
rule = Rule.new " hello "
rule.render({"a" => "a"}).should eq "hello"
end
it "renders a few examples correctly" do
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
rule.render({"id" => "ID"}).should eq "ID testing"
rule.render({"chapter" => "CH", "id" => "ID"})
.should eq "Ch. CH ID testing"
rule.render({} of String => String).should eq "testing"
end
it "escapes slash" do
rule = Rule.new "{id}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
end
end

55
spec/spec_helper.cr Normal file
View File

@@ -0,0 +1,55 @@
require "spec"
require "../src/queue"
require "../src/server"
require "../src/config"
class State
@@hash = {} of String => String
def self.get(key)
@@hash[key]?
end
def self.get!(key)
@@hash[key]
end
def self.set(key, value)
return if value.nil?
@@hash[key] = value
end
def self.reset
@@hash.clear
end
end
def get_tempfile(name)
path = State.get name
if path.nil? || !File.exists? path
file = File.tempfile name
State.set name, file.path
file
else
File.new path
end
end
def with_default_config
temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path
config.set_current
yield config, temp_config.path
temp_config.delete
end
def with_storage
with_default_config do
temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, false
clear = yield storage, temp_db.path
if clear == true
temp_db.delete
end
end
end

91
spec/storage_spec.cr Normal file
View File

@@ -0,0 +1,91 @@
require "./spec_helper"
describe Storage do
it "creates DB at given path" do
with_storage do |_, path|
File.exists?(path).should be_true
end
end
it "deletes user" do
with_storage do |storage|
storage.delete_user "admin"
end
end
it "creates new user" do
with_storage do |storage|
storage.new_user "user", "123456", false
storage.new_user "admin", "123456", true
end
end
it "verifies username/password combination" do
with_storage do |storage|
user_token = storage.verify_user "user", "123456"
admin_token = storage.verify_user "admin", "123456"
user_token.should_not be_nil
admin_token.should_not be_nil
State.set "user_token", user_token
State.set "admin_token", admin_token
end
end
it "rejects duplicate username" do
with_storage do |storage|
expect_raises SQLite3::Exception,
"UNIQUE constraint failed: users.username" do
storage.new_user "admin", "123456", true
end
end
end
it "verifies token" do
with_storage do |storage|
user_token = State.get! "user_token"
user = storage.verify_token user_token
user.should eq "user"
end
end
it "verfies admin token" do
with_storage do |storage|
admin_token = State.get! "admin_token"
storage.verify_admin(admin_token).should be_true
end
end
it "rejects non-admin token" do
with_storage do |storage|
user_token = State.get! "user_token"
storage.verify_admin(user_token).should be_false
end
end
it "updates user" do
with_storage do |storage|
storage.update_user "admin", "admin", "654321", true
token = storage.verify_user "admin", "654321"
admin_token = State.get! "admin_token"
token.should eq admin_token
end
end
it "logs user out" do
with_storage do |storage|
user_token = State.get! "user_token"
admin_token = State.get! "admin_token"
storage.logout user_token
storage.logout admin_token
storage.verify_token(user_token).should be_nil
storage.verify_token(admin_token).should be_nil
end
end
it "cleans up" do
with_storage do
true
end
State.reset
end
end

46
spec/util_spec.cr Normal file
View File

@@ -0,0 +1,46 @@
require "./spec_helper"
describe "compare_numerically" do
it "sorts filenames with leading zeros correctly" do
ary = ["010.jpg", "001.jpg", "002.png"]
ary.sort! { |a, b|
compare_numerically a, b
}
ary.should eq ["001.jpg", "002.png", "010.jpg"]
end
it "sorts filenames without leading zeros correctly" do
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
ary.sort! { |a, b|
compare_numerically a, b
}
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
end
# https://ux.stackexchange.com/a/95441
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|
compare_numerically a, b
}.should eq ary
end
# 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|
compare_numerically a, b
}.should eq ary
end
end
describe "chapter_sort" do
it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
sorter = ChapterSorter.new ary
ary.reverse.sort do |a, b|
sorter.compare a, b
end.should eq ary
end
end

59
src/archive.cr Normal file
View File

@@ -0,0 +1,59 @@
require "zip"
require "archive"
# A unified class to handle all supported archive formats. It uses the ::Zip
# module in crystal standard library if the target file is a zip archive.
# Otherwise it uses `archive.cr`.
class ArchiveFile
def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Zip::File.new filename
else
@archive_file = Archive::File.new filename
end
end
def self.open(filename : String, &)
s = self.new filename
yield s
s.close
end
def close
if @archive_file.is_a? Zip::File
@archive_file.as(Zip::File).close
end
end
# Lists all file entries
def entries
ary = [] of Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e|
if (e.is_a? Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?)
ary.push e
end
end
ary
end
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Zip::File::Entry
data = nil
e.open do |io|
slice = Bytes.new e.uncompressed_size
bytes_read = io.read_fully? slice
data = slice if bytes_read
end
data
else
e.read
end
end
def check
if @archive_file.is_a? Archive::File
@archive_file.as(Archive::File).check
end
end
end

41
src/assets/lang_codes.csv Normal file
View File

@@ -0,0 +1,41 @@
Arabic,sa
Bengali,bd
Bulgarian,bg
Burmese,mm
Catalan,ct
Chinese (Simp),cn
Chinese (Trad),hk
Czech,cz
Danish,dk
Dutch,nl
English,gb
Filipino,ph
Finnish,fi
French,fr
German,de
Greek,gr
Hebrew,il
Hindi,in
Hungarian,hu
Indonesian,id
Italian,it
Japanese,jp
Korean,kr
Lithuanian,lt
Malay,my
Mongolian,mn
Other,
Persian,ir
Polish,pl
Portuguese (Br),br
Portuguese (Pt),pt
Romanian,ro
Russian,ru
Serbo-Croatian,rs
Spanish (Es),es
Spanish (LATAM),mx
Swedish,se
Thai,th
Turkish,tr
Ukrainian,ua
Vietnames,vn
1 Arabic sa
2 Bengali bd
3 Bulgarian bg
4 Burmese mm
5 Catalan ct
6 Chinese (Simp) cn
7 Chinese (Trad) hk
8 Czech cz
9 Danish dk
10 Dutch nl
11 English gb
12 Filipino ph
13 Finnish fi
14 French fr
15 German de
16 Greek gr
17 Hebrew il
18 Hindi in
19 Hungarian hu
20 Indonesian id
21 Italian it
22 Japanese jp
23 Korean kr
24 Lithuanian lt
25 Malay my
26 Mongolian mn
27 Other
28 Persian ir
29 Polish pl
30 Portuguese (Br) br
31 Portuguese (Pt) pt
32 Romanian ro
33 Russian ru
34 Serbo-Croatian rs
35 Spanish (Es) es
36 Spanish (LATAM) mx
37 Swedish se
38 Thai th
39 Turkish tr
40 Ukrainian ua
41 Vietnames vn

View File

@@ -1,26 +0,0 @@
require "kemal"
require "./storage"
require "./util"
class AuthHandler < Kemal::Handler
def initialize(@storage : Storage)
end
def call(env)
return call_next(env) \
if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find { |c| c.name == "token" }
if cookie.nil? || ! @storage.verify_token cookie.value
return env.redirect "/login"
end
if request_path_startswith env, ["/admin", "/api/admin"]
unless @storage.verify_admin cookie.value
env.response.status_code = 403
end
end
call_next env
end
end

View File

@@ -1,44 +1,91 @@
require "yaml"
class Config
include YAML::Serializable
include YAML::Serializable
@[YAML::Field(key: "port")]
property port : Int32 = 9000
@[YAML::Field(ignore: true)]
property path : String = ""
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
@[YAML::Field(key: "scan_interval_minutes")]
property scan_interval : Int32 = 5
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 mangadex = Hash(String, String | Int32).new
@[YAML::Field(key: "library_path")]
property library_path : String = \
File.expand_path "~/mango/library", home: true
@[YAML::Field(ignore: true)]
@mangadex_defaults = {
"base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api",
"download_wait_seconds" => 5,
"download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true),
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
}
@[YAML::Field(key: "db_path")]
property db_path : String = \
File.expand_path "~/mango/mango.db", home: true
@@singlet : Config?
@[YAML::Field(key: "scan_interval_minutes")]
property scan_interval : Int32 = 5
def self.current
@@singlet.not_nil!
end
@[YAML::Field(key: "log_level")]
property log_level : String = "info"
def set_current
@@singlet = self
end
def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
return self.from_yaml File.read cfg_path
end
puts "The config file #{cfg_path} does not exist." \
" Do you want mango to dump the default config there? [Y/n]"
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir
end
File.write cfg_path, default.to_yaml
puts "The config file has been created at #{cfg_path}."
default
end
def self.load(path : String?)
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.preprocess
config.path = path
config.fill_defaults
return config
end
puts "The config file #{cfg_path} does not exist." \
" Do you want mango to dump the default config there? [Y/n]"
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate
default.path = path
default.fill_defaults
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir
end
File.write cfg_path, default.to_yaml
puts "The config file has been created at #{cfg_path}."
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
{% end %}
end
def preprocess
unless base_url.starts_with? "/"
raise "base url (#{base_url}) should start with `/`"
end
unless base_url.ends_with? "/"
@base_url += "/"
end
end
end

View File

@@ -1,20 +0,0 @@
require "./config"
require "./library"
require "./storage"
require "./logger"
class Context
property config : Config
property library : Library
property storage : Storage
property logger : MLogger
def initialize(@config, @logger, @library, @storage)
end
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
@logger.{{lvl.id}} msg
end
{% end %}
end

View File

@@ -0,0 +1,92 @@
require "kemal"
require "../storage"
require "../util/*"
class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def require_basic_auth(env)
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
call_next env
end
def validate_token(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_token token
end
def validate_token_admin(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_admin token
end
def validate_auth_header(env)
if env.request.headers[AUTH]?
if value = env.request.headers[AUTH]
if value.size > 0 && value.starts_with?(BASIC)
token = verify_user value
return false if token.nil?
env.session.string "token", token
return true
end
end
end
false
end
def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":")
@storage.verify_user username, password
end
def handle_opds_auth(env)
if validate_token(env) || validate_auth_header(env)
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
if request_path_startswith(env, ["/login", "/logout"]) ||
requesting_static_file env
return call_next(env)
end
unless validate_token env
env.session.string "callback", env.request.path
return redirect env, "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless validate_token_admin env
env.response.status_code = 403
end
end
call_next env
end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end

View File

@@ -0,0 +1,23 @@
require "kemal"
require "../logger"
class LogHandler < Kemal::BaseLogHandler
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
Logger.debug msg
env
end
def write(msg)
Logger.debug msg
end
private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1
"#{(millis * 1000).round(2)}µs"
end
end

View File

@@ -0,0 +1,30 @@
require "baked_file_system"
require "kemal"
require "../util/*"
class FS
extend BakedFileSystem
{% if flag?(:release) %}
{% if read_file? "#{__DIR__}/../../dist/favicon.ico" %}
{% puts "baking ../../dist" %}
bake_folder "../../dist"
{% else %}
{% puts "baking ../../public" %}
bake_folder "../../public"
{% end %}
{% end %}
end
class StaticHandler < Kemal::Handler
def call(env)
if requesting_static_file env
file = FS.get? env.request.path
return call_next env if file.nil?
slice = Bytes.new file.size
file.read slice
return send_file env, slice, file.mime_type
end
call_next env
end
end

View File

@@ -0,0 +1,26 @@
require "kemal"
require "../util/*"
class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String)
end
def call(env)
unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) &&
env.request.method == "GET"
return call_next env
end
ary = env.request.path.split(File::SEPARATOR).select do |part|
!part.empty?
end
ary[0] = @upload_dir
path = File.join ary
if File.exists? path
send_file env, path
else
env.response.status_code = 404
end
end
end

View File

@@ -1,173 +0,0 @@
require "zip"
require "mime"
require "json"
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class Entry
JSON.mapping zip_path: String, book_title: String, title: String, \
size: String, pages: Int32, cover_url: String
def initialize(path, @book_title)
@zip_path = path
@title = File.basename path, File.extname path
@size = (File.size path).humanize_bytes
@pages = Zip::File.new(path).entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.size
@cover_url = "/api/page/#{@book_title}/#{title}/1"
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.sort { |a, b| a.filename <=> b.filename }
.[page_num - 1]
page.open do |io|
slice = Bytes.new page.uncompressed_size
bytes_read = io.read_fully? slice
unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),\
page.filename, bytes_read
end
end
end
end
class Title
JSON.mapping dir: String, entries: Array(Entry), title: String
def initialize(dir : String)
@dir = dir
@title = File.basename dir
@entries = (Dir.entries dir)
.select { |path| [".zip", ".cbz"].includes? File.extname path }
.map { |path| Entry.new File.join(dir, path), @title }
.select { |e| e.pages > 0 }
.sort { |a, b| a.title <=> b.title }
end
def get_entry(name)
@entries.find { |e| e.title == name }
end
def save_progress(username, entry, page)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
info.save @dir
return
end
info.progress[username][entry] = page
info.save @dir
end
def load_progress(username, entry : String)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
return 0
end
if info.progress[username][entry]?.nil?
return 0
end
info.progress[username][entry]
end
def load_percetage(username, entry : String)
info = TitleInfo.new @dir
page = load_progress username, entry
entry_obj = get_entry entry
return 0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
read_pages = total_pages = 0
@entries.each do |e|
read_pages += load_progress username, e.title
total_pages += e.pages
end
read_pages / total_pages
end
def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1
@entries[idx + 1]
end
end
class TitleInfo
# { user1: { entry1: 10, entry2: 0 } }
include JSON::Serializable
@[JSON::Field(key: "comment")]
property comment = "Generated by Mango. DO NOT EDIT!"
@[JSON::Field(key: "progress")]
property progress : Hash(String, Hash(String, Int32))
def initialize(title_dir)
info = nil
json_path = File.join title_dir, "info.json"
if File.exists? json_path
info = TitleInfo.from_json File.read json_path
else
info = TitleInfo.from_json "{\"progress\": {}}"
end
@progress = info.progress.clone
end
def save(title_dir)
json_path = File.join title_dir, "info.json"
File.write json_path, self.to_pretty_json
end
end
class Library
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32, logger: MLogger
def initialize(@dir, @scan_interval, @logger)
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@titles = [] of Title
return scan if @scan_interval < 1
spawn do
loop do
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@titles.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def get_title(name)
@titles.find { |t| t.title == name }
end
def scan
unless Dir.exists? @dir
@logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@titles = (Dir.entries @dir)
.select { |path| File.directory? File.join @dir, path }
.map { |path| Title.new File.join @dir, path }
.select { |title| !title.entries.empty? }
@logger.debug "Scan completed"
@logger.debug "Scanned library: \n#{self.to_pretty_json}"
end
end

182
src/library/entry.cr Normal file
View File

@@ -0,0 +1,182 @@
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book, storage)
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_id @zip_path, false
if id.nil?
id = random_str
storage.insert_id({
path: @zip_path,
id: id,
is_title: false,
})
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
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id",
"encoded_path", "encoded_title"] %}
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 }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_numerically a.filename, b.filename
}
.[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 next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
return nil if idx.nil? || idx == entries.size - 1
entries[idx + 1]
end
def previous_entry
idx = @book.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?
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, page)
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
else
info.progress[username][@title] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {@title => Time.utc}
else
info.last_read[username][@title] = Time.utc
end
info.save
end
end
def load_progress(username)
progress = 0
TitleInfo.new @book.dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][@title]?.nil?
progress = info.progress[username][@title]
end
end
[progress, @pages].min
end
def load_percentage(username)
page = load_progress username
page / @pages
end
def load_last_read(username)
last_read = nil
TitleInfo.new @book.dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][@title]?.nil?
last_read = info.last_read[username][@title]
end
end
last_read
end
def finished?(username)
load_progress(username) == @pages
end
def started?(username)
load_progress(username) > 0
end
end

197
src/library/library.cr Normal file
View File

@@ -0,0 +1,197 @@
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
title_hash : Hash(String, Title)
use_default
def initialize
register_mime_types
@dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# 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
return scan if @scan_interval < 1
spawn do
loop do
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
end
def titles
@title_ids.map { |tid| self.get_title!(tid) }
end
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
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan
unless Dir.exists? @dir
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
storage = Storage.new auto_close: false
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
storage.bulk_insert_ids
storage.close
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
.map { |t| t.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
if last_read.nil? && pe
last_read = pe.load_last_read username
end
{
entry: e,
percentage: e.load_percentage(username),
last_read: last_read,
}
}
# Sort by by last_read, most recent first (nils at the end)
cr_entries.sort { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}
end
alias RA = NamedTuple(
entry: Entry,
percentage: Float64,
grouped_count: Int32)
def get_recently_added_entries(username)
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] }
.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
# 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)
last_hash[:grouped_count] = count + 1
# Setting the percentage to a negative value will hide the
# percentage badge on the card
last_hash[:percentage] = -1.0
recently_added[recently_added.size - 1] = RA.from last_hash
else
last_date_added = e[:date_added]
recently_added << {
entry: e[:entry],
percentage: e[:entry].load_percentage(username),
grouped_count: 1,
}
end
end
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
end
def get_start_reading_titles(username)
# Here we are not using `deep_titles` as it may cause unexpected behaviors
# For example, consider the following nested titles:
# - One Puch Man
# - Vol. 1
# - Vol. 2
# 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 }
.sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle
end
end

378
src/library/title.cr Normal file
View File

@@ -0,0 +1,378 @@
require "../archive"
class Title
property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@library : Library)
id = storage.get_id @dir, true
if id.nil?
id = random_str
storage.insert_id({
path: @dir,
id: id,
is_title: true,
})
end
@id = id
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@entries = [] of Entry
@mtime = File.info(dir).modification_time
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
entry = Entry.new path, self, storage
@entries << entry if entry.pages > 0 || entry.err_msg
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title,
@library.title_hash[b].title
end
sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b|
sorter.compare a.title, b.title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
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
end
def deep_titles
return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary.reverse
end
def size
@entries.size + @title_ids.size
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
def display_name
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
URI.encode display_name
end
def display_name(entry_name)
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
dn
end
def set_display_name(dn)
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "#{Config.current.base_url}img/icon.png"
readable_entries = @entries.select &.err_msg.nil?
if readable_entries.size > 0
url = readable_entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
e.save_progress username, e.pages
end
titles.each do |t|
t.read_all username
end
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
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
end
def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum +
titles.map { |t| t.deep_total_page_count }.flatten.sum
end
def load_percentage(username)
deep_read_page_count(username) / deep_total_page_count
end
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
ary.map do |e|
info_progress = 0
if progress && progress.has_key? e.title
info_progress = [progress[e.title], e.pages].min
end
info_progress
end
end
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
unsorted = false)
if unsorted
ary = @entries
else
ary = sorted_entries username, opt
end
progress = load_progress_for_all_entries username, opt, unsorted
ary.map_with_index do |e, i|
progress[i] / e.pages
end
end
# Returns the sorted entries array
#
# When `opt` is nil, it uses the preferred sorting options in info.json, or
# use the default (auto, ascending)
# When `opt` is not nil, it saves the options to info.json
def sorted_entries(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
case opt.not_nil!.method
when .title?
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
when .time_modified?
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .time_added?
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
compare_numerically a.title, b.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] }
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 }
ary = @entries.sort do |a, b|
sorter.compare(a.title, b.title).or \
compare_numerically a.title, b.title
end
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
# === helper methods ===
# Gets the last read entry in the title. If the entry has been completed,
# returns the next entry. Returns nil when no entry has been read yet,
# or when all entries are completed
def get_last_read_entry(username) : Entry?
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
return if progress.nil?
last_read_entry = nil
sorted_entries(username).reverse_each do |e|
if progress.has_key?(e.title) && progress[e.title] > 0
last_read_entry = e
break
end
end
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username
end
last_read_entry
end
# Equivalent to `@entries.map &. date_added`, but much more efficient
def get_date_added_for_all_entries
da = {} of String => Time
TitleInfo.new @dir do |info|
da = info.date_added
end
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.zip_path
end
TitleInfo.new @dir do |info|
info.date_added = da
info.save
end
@entries.map { |e| da[e.title] }
end
def deep_entries_with_date_added
da_ary = get_date_added_for_all_entries
zip = @entries.map_with_index do |e, i|
{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
end
def bulk_progress(action, ids : Array(String), username)
selected_entries = ids
.map { |id|
@entries.find { |e| e.id == id }
}
.select(Entry)
TitleInfo.new @dir do |info|
selected_entries.each do |e|
page = action == "read" ? e.pages : 0
if info.progress[username]?.nil?
info.progress[username] = {e.title => page}
else
info.progress[username][e.title] = page
end
end
info.save
end
end
end

102
src/library/types.cr Normal file
View File

@@ -0,0 +1,102 @@
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
enum SortMethod
Auto
Title
Progress
TimeModified
TimeAdded
end
class SortOptions
property method : SortMethod, ascend : Bool
def initialize(in_method : String? = nil, @ascend = true)
@method = SortMethod::Auto
SortMethod.each do |m, _|
if in_method && m.to_s.underscore == in_method
@method = m
return
end
end
end
def initialize(in_method : SortMethod? = nil, @ascend = true)
if in_method
@method = in_method
else
@method = SortMethod::Auto
end
end
def self.from_tuple(tp : Tuple(String, Bool))
method, ascend = tp
self.new method, ascend
end
def self.from_info_json(dir, username)
opt = SortOptions.new
TitleInfo.new dir do |info|
if info.sort_by.has_key? username
opt = SortOptions.from_tuple info.sort_by[username]
end
end
opt
end
def to_tuple
{@method.to_s.underscore, ascend}
end
end
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class TitleInfo
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
property sort_by = {} of String => Tuple(String, Bool)
@[JSON::Field(ignore: true)]
property dir : String = ""
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
end
end

View File

@@ -1,26 +0,0 @@
require "kemal"
require "./logger"
class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : MLogger)
end
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
@logger.debug(msg)
env
end
def write(msg)
@logger.debug(msg)
end
private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1
"#{(millis * 1000).round(2)}µs"
end
end

View File

@@ -1,59 +1,68 @@
require "./config"
require "logger"
require "log"
require "colorize"
LEVELS = ["debug", "error", "fatal", "info", "warn"]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
class Logger
LEVELS = ["debug", "error", "fatal", "info", "warn"]
SEVERITY_IDS = [0, 4, 5, 2, 3]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
class MLogger
def initialize(config : Config)
@logger = Logger.new STDOUT
@@severity : Log::Severity = :info
@log_off = false
log_level = config.log_level
if log_level == "off"
@log_off = true
return
end
use_default
{% begin %}
case log_level
{% for lvl in LEVELS %}
when {{lvl}}
@logger.level = Logger::{{lvl.upcase.id}}
{% end %}
else
raise "Unknown log level #{log_level}"
end
{% end %}
def initialize
level = Config.current.log_level
{% begin %}
case level.downcase
when "off"
@@severity = :none
{% for lvl, i in LEVELS %}
when {{lvl}}
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
@logger.formatter = Logger::Formatter.new do \
|severity, datetime, progname, message, io|
@log = Log.for("")
color = :default
{% begin %}
case severity.to_s().downcase
{% for lvl, i in LEVELS %}
when {{lvl}}
color = COLORS[{{i}}]
{% end %}
end
{% end %}
@backend = Log::IOBackend.new
@backend.formatter = ->(entry : Log::Entry, io : IO) do
color = :default
{% begin %}
case entry.severity.label.to_s().downcase
{% for lvl, i in LEVELS %}
when {{lvl}}, "#{{{lvl}}}ing"
color = COLORS[{{i}}]
{% end %}
else
end
{% end %}
io << "[#{severity}]".ljust(8).colorize(color)
io << datetime.to_s("%Y/%m/%d %H:%M:%S") << " | "
io << message
end
end
io << "[#{entry.severity.label}]".ljust(10).colorize(color)
io << entry.timestamp.to_s("%Y/%m/%d %H:%M:%S") << " | "
io << entry.message
end
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
return if @log_off
@logger.{{lvl.id}} msg
end
{% end %}
Log.builder.bind "*", @@severity, @backend
end
def to_json(json : JSON::Builder)
json.string self
end
# Ignores @@severity and always log msg
def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
end
def self.log(msg)
default.log msg
end
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg }
end
def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg
end
{% end %}
end

218
src/mangadex/api.cr Normal file
View File

@@ -0,0 +1,218 @@
require "http/client"
require "json"
require "csv"
require "../rename"
macro string_properties(names)
{% for name in names %}
property {{name.id}} = ""
{% end %}
end
macro parse_strings_from_json(names)
{% for name in names %}
@{{name.id}} = obj[{{name}}].as_s
{% end %}
end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex
class Chapter
string_properties ["lang_code", "title", "volume", "chapter"]
property manga : Manga
property time = Time.local
property id : String
property full_title = ""
property language = ""
property pages = [] of {String, String} # filename, url
property groups = [] of {Int32, String} # group_id, group_name
def initialize(@id, json_obj : JSON::Any, @manga,
lang : Hash(String, String))
self.parse_json json_obj, lang
end
def to_info_json
JSON.build do |json|
json.object do
{% for name in ["id", "title", "volume", "chapter",
"language", "full_title"] %}
json.field {{name}}, @{{name.id}}
{% end %}
json.field "time", @time.to_unix.to_s
json.field "manga_title", @manga.title
json.field "manga_id", @manga.id
json.field "groups" do
json.object do
@groups.each do |gid, gname|
json.field gname, gid
end
end
end
end
end
end
def parse_json(obj, lang)
parse_strings_from_json ["lang_code", "title", "volume",
"chapter"]
language = lang[@lang_code]?
@language = language if language
@time = Time.unix obj["timestamp"].as_i
suffixes = ["", "_2", "_3"]
suffixes.each do |s|
gid = obj["group_id#{s}"].as_i
next if gid == 0
gname = obj["group_name#{s}"].as_s
@groups << {gid, gname}
end
rename_rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
@full_title = rename rename_rule
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
hash = properties_to_hash ["id", "title", "volume", "chapter",
"lang_code", "language", "pages"]
hash["groups"] = @groups.map { |g| g[1] }.join ","
rule.render hash
end
end
class Manga
string_properties ["cover_url", "description", "title", "author", "artist"]
property chapters = [] of Chapter
property id : String
def initialize(@id, json_obj : JSON::Any)
self.parse_json json_obj
end
def to_info_json(with_chapters = true)
JSON.build do |json|
json.object do
{% for name in ["id", "title", "description", "author", "artist",
"cover_url"] %}
json.field {{name}}, @{{name.id}}
{% end %}
if with_chapters
json.field "chapters" do
json.array do
@chapters.each do |c|
json.raw c.to_info_json
end
end
end
end
end
end
end
def parse_json(obj)
parse_strings_from_json ["cover_url", "description", "title", "author",
"artist"]
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end
class API
use_default
def initialize
@base_url = Config.current.mangadex["api_url"].to_s ||
"https://mangadex.org/api/"
@lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0]
end
end
def get(url)
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
res = HTTP::Client.get url, headers
raise "Failed to get #{url}. [#{res.status_code}] " \
"#{res.status_message}" if !res.success?
JSON.parse res.body
end
def get_manga(id)
obj = self.get File.join @base_url, "manga/#{id}"
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
manga = Manga.new id, obj["manga"]
obj["chapter"].as_h.map do |k, v|
chapter = Chapter.new k, v, manga, @lang
manga.chapters << chapter
end
manga
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(chapter : Chapter)
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
server = obj["server"].as_s
hash = obj["hash"].as_s
chapter.pages = obj["page_array"].as_a.map do |fn|
{
fn.as_s,
"#{server}#{hash}/#{fn.as_s}",
}
end
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(id : String)
obj = self.get File.join @base_url, "chapter/#{id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
manga_id = ""
begin
manga_id = obj["manga_id"].as_i.to_s
rescue
raise "Failed to parse JSON"
end
manga = self.get_manga manga_id
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
self.get_chapter chapter
chapter
end
end
end

154
src/mangadex/downloader.cr Normal file
View File

@@ -0,0 +1,154 @@
require "./api"
require "zip"
module MangaDex
class PageJob
property success = false
property url : String
property filename : String
property writer : Zip::Writer
property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
end
end
class Downloader < Queue::Downloader
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
use_default
def initialize
@api = API.default
super
end
def pop : Queue::Job?
job = nil
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
job
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
chapter = @api.get_chapter(job.id)
rescue e
Logger.error e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
return
end
@queue.set_pages chapter.pages.size, job
lib_dir = @library_path
rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1
writer = Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size
spawn do
chapter.pages.each_with_index do |tuple, i|
fn, url = tuple
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job
end
end
spawn do
page_jobs = [] of PageJob
chapter.pages.size.times do
page_job = channel.receive
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
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,34 +1,106 @@
require "./config"
require "./queue"
require "./server"
require "./context"
require "./mangadex/*"
require "option_parser"
require "clim"
require "./plugin/*"
VERSION = "0.1.0"
MANGO_VERSION = "0.10.0"
config_path = nil
parser = OptionParser.parse do |parser|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
parser.on "-v", "--version", "Show version" do
puts "Version #{VERSION}"
exit
end
parser.on "-h", "--help", "Show help" do
puts parser
exit
end
parser.on "-c PATH", "--config=PATH", "Path to the config file. " \
"Default is `~/.config/mango/config.yml`" do |path|
config_path = path
end
macro common_option
option "-c PATH", "--config=PATH", type: String,
desc: "Path to the config file"
end
config = Config.load config_path
logger = MLogger.new config
library = Library.new config.library_path, config.scan_interval, logger
storage = Storage.new config.db_path, logger
macro throw(msg)
puts "ERROR: #{{{msg}}}"
puts
puts "Please see the `--help`."
exit 1
end
context = Context.new config, logger, library, storage
class CLI < Clim
main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
usage "mango [sub_command] [options]"
help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v"
common_option
run do |opts|
Config.load(opts.config).set_current
MangaDex::Downloader.default
Plugin::Downloader.default
server = Server.new context
server.start
# empty ARGV so it won't be passed to Kemal
ARGV.clear
server = Server.new
server.start
end
sub "admin" do
desc "Run admin tools"
usage "mango admin [tool]"
help short: "-h"
run do |opts|
puts opts.help_string
end
sub "user" do
desc "User management tool"
usage "mango admin user [arguments] [options]"
help short: "-h"
argument "action", type: String,
desc: "Action to perform. Can be add/delete/update/list"
argument "username", type: String,
desc: "Username to update or delete"
option "-u USERNAME", "--username=USERNAME", type: String,
desc: "Username"
option "-p PASSWORD", "--password=PASSWORD", type: String,
desc: "Password"
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
common_option
run do |opts, args|
Config.load(opts.config).set_current
storage = Storage.new nil, false
case args.action
when "add"
throw "Options `-u` and `-p` required." if opts.username.nil? ||
opts.password.nil?
storage.new_user opts.username.not_nil!,
opts.password.not_nil!, opts.admin
when "delete"
throw "Argument `username` required." if args.username.nil?
storage.delete_user args.username
when "update"
throw "Argument `username` required." if args.username.nil?
username = opts.username || args.username
password = opts.password || ""
storage.update_user args.username, username.not_nil!,
password.not_nil!, opts.admin
when "list"
users = storage.list_users
name_length = users.map(&.[0].size).max? || 0
l_cell_width = ["username".size, name_length].max
r_cell_width = "admin access".size
header = " #{"username".ljust l_cell_width} | admin access "
puts "-" * header.size
puts header
puts "-" * header.size
users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \
"#{admin.to_s.ljust r_cell_width} "
end
puts "-" * header.size
when nil
puts opts.help_string
else
throw "Unknown action \"#{args.action}\"."
end
end
end
end
end
end
CLI.start(ARGV)

131
src/plugin/downloader.cr Normal file
View File

@@ -0,0 +1,131 @@
class Plugin
class Downloader < Queue::Downloader
use_default
def initialize
super
end
def pop : Queue::Job?
job = nil
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id 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
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
begin
unless job.plugin_id
raise "Job does not have a plugin ID specificed"
end
plugin = Plugin.new job.plugin_id.not_nil!
info = plugin.select_chapter job.plugin_chapter_id.not_nil!
pages = info["pages"].as_i
manga_title = process_filename job.manga_title
chapter_title = process_filename info["title"].as_s
@queue.set_pages pages, job
lib_dir = @library_path
manga_dir = File.join lib_dir, manga_title
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
writer = Zip::Writer.new zip_path
rescue e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
raise e
end
fail_count = 0
while page = plugin.next_page
fn = process_filename page["filename"].as_s
url = page["url"].as_s
headers = HTTP::Headers.new
if page["headers"]?
page["headers"].as_h.each do |k, v|
headers.add k, v.as_s
end
end
page_success = false
tries = 4
loop do
sleep plugin.info.wait_seconds.seconds
Logger.debug "downloading #{url}"
tries -= 1
begin
HTTP::Client.get url, headers do |res|
unless res.success?
raise "Failed to download page #{url}. " \
"[#{res.status_code}] #{res.status_message}"
end
writer.add fn, res.body_io
end
rescue e
@queue.add_fail job
fail_count += 1
msg = "Failed to download page #{url}. Error: #{e}"
@queue.add_message msg, job
Logger.error msg
Logger.debug "[failed] #{url}"
else
@queue.add_success job
Logger.debug "[success] #{url}"
page_success = true
end
break if page_success || tries < 0
end
end
Logger.debug "Download completed. #{fail_count}/#{pages} 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
end

343
src/plugin/plugin.cr Normal file
View File

@@ -0,0 +1,343 @@
require "duktape/runtime"
require "myhtml"
require "http"
require "xml"
class Plugin
class Error < ::Exception
end
class MetadataError < Error
end
class PluginException < Error
end
class SyntaxError < Error
end
struct Info
{% for name in ["id", "title", "placeholder"] %}
getter {{name.id}} = ""
{% end %}
getter wait_seconds : UInt64 = 0
getter dir : String
def initialize(@dir)
info_path = File.join @dir, "info.json"
unless File.exists? info_path
raise MetadataError.new "File `info.json` not found in the " \
"plugin directory #{dir}"
end
@json = JSON.parse File.read info_path
begin
{% for name in ["id", "title", "placeholder"] %}
@{{name.id}} = @json[{{name}}].as_s
{% end %}
@wait_seconds = @json["wait_seconds"].as_i.to_u64
unless @id.alphanumeric_underscore?
raise "Plugin ID can only contain alphanumeric characters and " \
"underscores"
end
rescue e
raise MetadataError.new "Failed to retrieve metadata from plugin " \
"at #{@dir}. Error: #{e.message}"
end
end
def each(&block : String, JSON::Any -> _)
@json.as_h.each &block
end
end
struct Storage
@hash = {} of String => String
def initialize(@path : String)
unless File.exists? @path
save
end
json = JSON.parse File.read @path
json.as_h.each do |k, v|
@hash[k] = v.as_s
end
end
def []?(key)
@hash[key]?
end
def []=(key, val : String)
@hash[key] = val
end
def save
File.write @path, @hash.to_pretty_json
end
end
@@info_ary = [] of Info
@info : Info?
getter js_path = ""
getter storage_path = ""
def self.build_info_ary
@@info_ary.clear
dir = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f|
path = File.join dir, f
next unless File.directory? path
begin
@@info_ary << Info.new path
rescue e : MetadataError
Logger.warn e
end
end
end
def self.list
self.build_info_ary
@@info_ary.map do |m|
{id: m.id, title: m.title}
end
end
def info
@info.not_nil!
end
def initialize(id : String)
Plugin.build_info_ary
@info = @@info_ary.find { |i| i.id == id }
if @info.nil?
raise Error.new "Plugin with ID #{id} not found"
end
@js_path = File.join info.dir, "index.js"
@storage_path = File.join info.dir, "storage.json"
unless File.exists? @js_path
raise Error.new "Plugin script not found at #{@js_path}"
end
@rt = Duktape::Runtime.new do |sbx|
sbx.push_global_object
sbx.push_pointer @storage_path.as(Void*)
path = sbx.require_pointer(-1).as String
sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "storage_path"
def_helper_functions sbx
end
eval File.read @js_path
end
macro check_fields(ary)
{% for field in ary %}
unless json[{{field}}]?
raise "Field `{{field.id}}` is missing from the function outputs"
end
{% end %}
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"
end
title = obj["title"]?
raise "Field `title` missing from `listChapters` outputs" if title.nil?
end
rescue e
raise Error.new e.message
end
json
end
def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')"
begin
check_fields ["title", "pages"]
if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty"
end
rescue e
raise Error.new e.message
end
json
end
def next_page
json = eval_json "nextPage()"
return if json.size == 0
begin
check_fields ["filename", "url"]
rescue e
raise Error.new e.message
end
json
end
private def eval(str)
@rt.eval str
rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message
rescue e : Duktape::Error
raise Error.new e.message
end
private def eval_json(str)
JSON.parse eval(str).as String
end
private def def_helper_functions(sbx)
sbx.push_object
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
headers = HTTP::Headers.new
if env.get_top == 2
env.enum 1, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.get url, headers
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "get"
sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
selector = env.require_string 1
myhtml = Myhtml::Parser.new html
ary = myhtml.css(selector).map(&.to_html).to_a
ary_idx = env.push_array
ary.each_with_index do |str, i|
env.push_string str
env.put_prop_index ary_idx, i.to_u32
end
env.call_success
end
sbx.put_prop_string -2, "css"
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
str = XML.parse(html).inner_text
env.push_string str
env.call_success
end
sbx.put_prop_string -2, "text"
sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr
html = env.require_string 0
name = env.require_string 1
begin
attr = XML.parse(html).first_element_child.not_nil![name]
env.push_string attr
rescue
env.push_undefined
end
env.call_success
end
sbx.put_prop_string -2, "attribute"
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
msg = env.require_string 0
env.call_success
raise PluginException.new msg
end
sbx.put_prop_string -2, "raise"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
key = env.require_string 0
env.get_global_string "storage_path"
storage_path = env.require_string -1
env.pop
storage = Storage.new storage_path
if env.get_top == 2
val = env.require_string 1
storage[key] = val
storage.save
else
val = storage[key]?
if val
env.push_string val
else
env.push_undefined
end
end
env.call_success
end
sbx.put_prop_string -2, "storage"
sbx.put_prop_string -2, "mango"
end
end

275
src/queue.cr Normal file
View File

@@ -0,0 +1,275 @@
require "sqlite3"
require "./util/*"
class Queue
abstract class Downloader
property stopped = false
@library_path : String = Config.current.library_path
@downloading = false
def initialize
@queue = Queue.default
@queue << self
spawn do
loop do
sleep 1.second
next if @stopped || @downloading
begin
job = pop
next if job.nil?
download job
rescue e
Logger.error e
@downloading = false
end
end
end
end
abstract def pop : Job?
private abstract def download(job : Job)
end
enum JobStatus
Pending # 0
Downloading # 1
Error # 2
Completed # 3
MissingPages # 4
end
struct Job
property id : String
property manga_id : String
property title : String
property manga_title : String
property status : JobStatus
property status_message : String = ""
property pages : Int32 = 0
property success_count : Int32 = 0
property fail_count : Int32 = 0
property time : Time
property plugin_id : String?
property plugin_chapter_id : String?
def parse_query_result(res : DB::ResultSet)
@id = res.read String
@manga_id = res.read String
@title = res.read String
@manga_title = res.read String
status = res.read Int32
@status_message = res.read String
@pages = res.read Int32
@success_count = res.read Int32
@fail_count = res.read Int32
time = res.read Int64
@status = JobStatus.new status
@time = Time.unix_ms time
ary = @id.split("-")
if ary.size == 2
@plugin_id = ary[0]
@plugin_chapter_id = ary[1]
end
end
# Raises if the result set does not contain the correct set of columns
def self.from_query_result(res : DB::ResultSet)
job = Job.allocate
job.parse_query_result res
job
end
def initialize(@id, @manga_id, @title, @manga_title, @status, @time,
@plugin_id = nil)
end
def to_json(json)
json.object do
{% for name in ["id", "manga_id", "title", "manga_title",
"status_message"] %}
json.field {{name}}, @{{name.id}}
{% end %}
{% for name in ["pages", "success_count", "fail_count"] %}
json.field {{name}} do
json.number @{{name.id}}
end
{% end %}
json.field "status", @status.to_s
json.field "time" do
json.number @time.to_unix_ms
end
json.field "plugin_id", @plugin_id if @plugin_id
end
end
end
getter path : String
@downloaders = [] of Downloader
@paused = false
use_default
def initialize(db_path : String? = nil)
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
dir = File.dirname @path
unless Dir.exists? dir
Logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
begin
db.exec "create table if not exists queue " \
"(id text, manga_id text, title text, manga_title " \
"text, status integer, status_message text, " \
"pages integer, success_count integer, " \
"fail_count integer, time integer)"
db.exec "create unique index if not exists id_idx " \
"on queue (id)"
db.exec "create index if not exists manga_id_idx " \
"on queue (manga_id)"
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
Logger.error "Error when checking tables in DB: #{e}"
raise e
end
end
end
# Push an array of jobs into the queue, and return the number of jobs
# inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job))
start_count = self.count
DB.open "sqlite3://#{@path}" do |db|
jobs.each do |job|
db.exec "insert or ignore into queue values " \
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
job.id, job.manga_id, job.title, job.manga_title,
job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
end
end
self.count - start_count
end
def reset(id : String)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
end
end
def reset(job : Job)
self.reset job.id
end
# Reset all failed tasks (missing pages and error)
def reset
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end
def delete(id : String)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end
def delete(job : Job)
self.delete job.id
end
def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
end
end
def count_status(status : JobStatus)
num = 0
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
end
num
end
def count
num = 0
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
end
num
end
def set_status(status : JobStatus, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
end
end
def get_all
jobs = [] of Job
DB.open "sqlite3://#{@path}" do |db|
jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs
end
end
jobs
end
def add_success(job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
end
end
def add_fail(job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
end
end
def set_pages(pages : Int32, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id
end
end
def add_message(msg : String, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
end
def <<(downloader : Downloader)
@downloaders << downloader
end
def pause
@downloaders.each { |d| d.stopped = true }
@paused = true
end
def resume
@downloaders.each { |d| d.stopped = false }
@paused = false
end
def paused?
@paused
end
end

147
src/rename.cr Normal file
View File

@@ -0,0 +1,147 @@
module Rename
alias VHash = Hash(String, String)
abstract class Base(T)
@ary = [] of T
def push(var)
@ary.push var
end
abstract def render(hash : VHash)
end
class Variable < Base(String)
property id : String
def initialize(@id)
end
def render(hash : VHash)
hash[@id]? || ""
end
end
class Pattern < Base(Variable)
def render(hash : VHash)
@ary.each do |v|
if hash.has_key? v.id
return v.render hash
end
end
""
end
end
class Group < Base(Pattern | String)
def render(hash : VHash)
return "" if @ary.select(&.is_a? Pattern)
.any? &.as(Pattern).render(hash).empty?
@ary.map do |e|
if e.is_a? Pattern
e.render hash
else
e
end
end.join
end
end
class Rule < Base(Group | String | Pattern)
ESCAPE = ['/']
def initialize(str : String)
parse! str
rescue e
raise "Failed to parse rename rule #{str}. Error: #{e}"
end
private def parse!(str : String)
chars = [] of Char
pattern : Pattern? = nil
group : Group? = nil
str.each_char_with_index do |char, i|
if ['[', ']', '{', '}', '|'].includes?(char) && !chars.empty?
string = chars.join
if !pattern.nil?
pattern.push Variable.new string.strip
elsif !group.nil?
group.push string
else
@ary.push string
end
chars = [] of Char
end
case char
when '['
if !group.nil? || !pattern.nil?
raise "nested groups are not allowed"
end
group = Group.new
when ']'
if group.nil?
raise "unmatched ] at position #{i}"
end
if !pattern.nil?
raise "patterns (`{}`) should be closed before closing the " \
"group (`[]`)"
end
@ary.push group
group = nil
when '{'
if !pattern.nil?
raise "nested patterns are not allowed"
end
pattern = Pattern.new
when '}'
if pattern.nil?
raise "unmatched } at position #{i}"
end
if !group.nil?
group.push pattern
else
@ary.push pattern
end
pattern = nil
when '|'
if pattern.nil?
chars.push char
end
else
if ESCAPE.includes? char
raise "the character #{char} at position #{i} is not allowed"
end
chars.push char
end
end
unless chars.empty?
@ary.push chars.join
end
if !pattern.nil?
raise "unclosed pattern {"
end
if !group.nil?
raise "unclosed group ["
end
end
def render(hash : VHash)
str = @ary.map do |e|
if e.is_a? String
e
else
e.render hash
end
end.join.strip
post_process str
end
private def post_process(str)
return "_" if str == ".."
str.gsub "/", "_"
end
end
end

72
src/routes/admin.cr Normal file
View File

@@ -0,0 +1,72 @@
require "./router"
class AdminRouter < Router
def initialize
get "/admin" do |env|
layout "admin"
end
get "/admin/user" do |env|
users = @context.storage.list_users
username = get_username env
layout "user"
end
get "/admin/user/edit" do |env|
username = env.params.query["username"]?
admin = env.params.query["admin"]?
if admin
admin = admin == "true"
end
error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil?
layout "user-edit"
end
post "/admin/user/edit" do |env|
# creating new user
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body hash
# would not contain `admin`
admin = !env.params.body["admin"]?.nil?
@context.storage.new_user username, password, admin
redirect env, "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"error" => e.message})
redirect env, redirect_url.to_s
end
post "/admin/user/edit/:original_username" do |env|
# editing existing user
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body hash would not contain `admin`
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
@context.storage.update_user \
original_username, username, password, admin
redirect env, "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message})
redirect env, redirect_url.to_s
end
get "/admin/downloads" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager"
end
end
end

336
src/routes/api.cr Normal file
View File

@@ -0,0 +1,336 @@
require "./router"
require "../mangadex/*"
require "../upload"
class APIRouter < Router
def initialize
get "/api/page/:tid/:eid/:page" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
page = env.params.url["page"].to_i
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
img = entry.read_page page
raise "Failed to load page #{page} of " \
"`#{title.title}/#{entry.title}`" if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book/:tid" do |env|
begin
tid = env.params.url["tid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book" do |env|
send_json env, @context.library.to_json
end
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
ms = (Time.utc - start).total_milliseconds
send_json env, {
"milliseconds" => ms,
"titles" => @context.library.titles.size,
}.to_json
end
post "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/progress/:title/:page" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"])
.not_nil!
page = env.params.url["page"].to_i
entry_id = env.params.query["entry"]?
if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil!
raise "incorrect page value" if page < 0 || page > entry.pages
entry.save_progress username, page
elsif page == 0
title.unread_all username
else
title.read_all username
end
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/bulk-progress/:action/:title" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s
unless action.in? ["read", "unread"]
raise "Unknow action #{action}"
end
title.bulk_progress action, ids, username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/admin/display_name/:title/:name" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
name = env.params.url["name"]
entry = env.params.query["entry"]?
if entry.nil?
title.set_display_name name
else
eobj = title.get_entry entry
title.set_display_name eobj.not_nil!.title, name
end
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
api = MangaDex::API.default
manga = api.get_manga id
send_json env, manga.to_info_json
rescue e
@context.error e
send_json env, {"error" => e.message}.to_json
end
end
post "/api/admin/mangadex/download" do |env|
begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter|
Queue::Job.new(
chapter["id"].as_s,
chapter["manga_id"].as_s,
chapter["full_title"].as_s,
chapter["manga_title"].as_s,
Queue::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i
)
}
inserted_count = @context.queue.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
@context.error e
send_json env, {"error" => e.message}.to_json
end
end
get "/api/admin/mangadex/queue" do |env|
begin
jobs = @context.queue.get_all
send_json env, {
"jobs" => jobs,
"paused" => @context.queue.paused?,
"success" => true,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
post "/api/admin/mangadex/queue/:action" do |env|
begin
action = env.params.url["action"]
id = env.params.query["id"]?
case action
when "delete"
if id.nil?
@context.queue.delete_status Queue::JobStatus::Completed
else
@context.queue.delete id
end
when "retry"
if id.nil?
@context.queue.reset
else
@context.queue.reset id
end
when "pause"
@context.queue.pause
when "resume"
@context.queue.resume
else
raise "Unknown queue action #{action}"
end
send_json env, {"success" => true}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
post "/api/admin/upload/:target" do |env|
begin
target = env.params.url["target"]
HTTP::FormData.parse env.request do |part|
next if part.name != "file"
filename = part.filename
if filename.nil?
raise "No file uploaded"
end
case target
when "cover"
title_id = env.params.query["title"]
entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG"
end
ext = File.extname filename
upload = Upload.new Config.current.upload_path
url = upload.path_to_url upload.save "img", ext, part.body
if url.nil?
raise "Failed to generate a public URL for the uploaded file"
end
if entry_id.nil?
title.set_cover_url url
else
entry_name = title.get_entry(entry_id).not_nil!.title
title.set_cover_url entry_name, url
end
else
raise "Unkown upload target #{target}"
end
send_json env, {"success" => true}.to_json
env.response.close
end
raise "No part with name `file` found"
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
post "/api/admin/plugin/list" do |env|
begin
query = env.params.json["query"].as String
plugin = Plugin.new env.params.json["plugin"].as String
json = plugin.list_chapters query
chapters = json["chapters"]
title = json["title"]
send_json env, {
"success" => true,
"chapters" => chapters,
"title" => title,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
post "/api/admin/plugin/download" do |env|
begin
plugin = Plugin.new env.params.json["plugin"].as String
chapters = env.params.json["chapters"].as Array(JSON::Any)
manga_title = env.params.json["title"].as String
jobs = chapters.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{ch["id"]}",
"", # manga_id
ch["title"].as_s,
manga_title,
Queue::JobStatus::Pending,
Time.utc
)
}
inserted_count = @context.queue.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
end
end

117
src/routes/main.cr Normal file
View File

@@ -0,0 +1,117 @@
require "./router"
class MainRouter < Router
def initialize
get "/login" do |env|
base_url = Config.current.base_url
render "src/views/login.html.ecr"
end
get "/logout" do |env|
begin
env.session.delete_string "token"
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
redirect env, "/login"
end
end
post "/login" do |env|
begin
username = env.params.body["username"]
password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil!
env.session.string "token", token
callback = env.session.string? "callback"
if callback
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/"
end
rescue
redirect env, "/login"
end
end
get "/library" do |env|
begin
username = get_username env
sort_opt = SortOptions.from_info_json @context.library.dir, username
get_sort_opt
titles = @context.library.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
env.response.status_code = 500
end
end
get "/book/:title" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username
get_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
layout "title"
rescue e
@context.error e
env.response.status_code = 500
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
@context.error e
env.response.status_code = 500
end
end
get "/" do |env|
begin
username = get_username env
continue_reading = @context
.library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username
titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0
layout "home"
rescue e
@context.error e
env.response.status_code = 500
end
end
end
end

32
src/routes/opds.cr Normal file
View File

@@ -0,0 +1,32 @@
require "./router"
class OPDSRouter < Router
def initialize
get "/opds" do |env|
titles = @context.library.titles
render_xml "src/views/opds/index.xml.ecr"
end
get "/opds/book/:title_id" do |env|
begin
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.xml.ecr"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/opds/download/:title/:entry" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
end
end

69
src/routes/reader.cr Normal file
View File

@@ -0,0 +1,69 @@
require "./router"
class ReaderRouter < Router
def initialize
get "/reader/:title/:entry" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg
# load progress
page = entry.load_progress username
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
# start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/reader/:title/:entry/:page" do |env|
begin
base_url = Config.current.base_url
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
entry.save_progress username, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx|
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}"
next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end
render "src/views/reader.html.ecr"
rescue e
@context.error e
env.response.status_code = 404
end
end
end
end

3
src/routes/router.cr Normal file
View File

@@ -0,0 +1,3 @@
class Router
@context : Context = Context.default
end

View File

@@ -1,326 +1,81 @@
require "kemal"
require "./context"
require "./auth_handler"
require "./static_handler"
require "./log_handler"
require "./util"
require "kemal-session"
require "./library/*"
require "./handlers/*"
require "./util/*"
require "./routes/*"
class Context
property library : Library
property storage : Storage
property queue : Queue
use_default
def initialize
@storage = Storage.default
@library = Library.default
@queue = Queue.default
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
Logger.{{lvl.id}} msg
end
{% end %}
end
class Server
def initialize(@context : Context)
@context : Context = Context.default
error 403 do |env|
message = "You are not authorized to visit #{env.request.path}"
layout "message"
end
def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
end
error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message"
end
get "/" do |env|
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
end
{% if flag?(:release) %}
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
{% end %}
get "/book/:title" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
username = get_username env
percentage = title.entries.map { |e|
title.load_percetage username, e.title }
layout "title"
rescue e
@context.error e
env.response.status_code = 404
end
end
MainRouter.new
AdminRouter.new
ReaderRouter.new
APIRouter.new
OPDSRouter.new
get "/admin" do |env|
layout "admin"
end
Kemal.config.logging = false
add_handler LogHandler.new
add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new Config.current.upload_path
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files."
serve_static false
add_handler StaticHandler.new
{% end %}
get "/admin/user" do |env|
users = @context.storage.list_users
username = get_username env
layout "user"
end
Kemal::Session.config do |c|
c.timeout = 365.days
c.secret = Config.current.session_secret
c.cookie_name = "mango-sessid-#{Config.current.port}"
c.path = Config.current.base_url
end
end
get "/admin/user/edit" do |env|
username = env.params.query["username"]?
admin = env.params.query["admin"]?
if admin
admin = admin == "true"
end
error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil?
layout "user-edit"
end
post "/admin/user/edit" do |env|
# creating new user
begin
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body hash
# would not contain `admin`
admin = !env.params.body["admin"]?.nil?
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\
"and underscores only"
end
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
@context.storage.new_user username, password, admin
env.redirect "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",\
query: hash_to_query({"error" => e.message})
env.redirect redirect_url.to_s
end
end
post "/admin/user/edit/:original_username" do |env|
# editing existing user
begin
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body
# hash would not contain `admin`
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\
"and underscores only"
end
if password.size != 0
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
@context.storage.update_user \
original_username, username, password, admin
env.redirect "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",\
query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message})
env.redirect redirect_url.to_s
end
end
get "/reader/:title/:entry" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
# load progress
username = get_username env
page = title.load_progress username, entry.title
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
env.redirect "/reader/#{title.title}/#{entry.title}/#{page}"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/reader/:title/:entry/:page" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
title.save_progress username, entry.title, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"/api/page/#{title.title}/#{entry.title}/#{idx}" }
reader_urls = pages.map { |idx|
"/reader/#{title.title}/#{entry.title}/#{idx}" }
next_page = page + IMGS_PER_PAGE
next_url = next_page > entry.pages ? nil :
"/reader/#{title.title}/#{entry.title}/#{next_page}"
exit_url = "/book/#{title.title}"
next_entry = title.next_entry entry
next_entry_url = next_entry.nil? ? nil : \
"/reader/#{title.title}/#{next_entry.title}"
render "src/views/reader.ecr"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/login" do |env|
render "src/views/login.ecr"
end
get "/logout" do |env|
begin
cookie = env.request.cookies
.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
env.redirect "/login"
end
end
post "/login" do |env|
begin
username = env.params.body["username"]
password = env.params.body["password"]
token = @context.storage.verify_user(username, password)
.not_nil!
cookie = HTTP::Cookie.new "token", token
env.response.cookies << cookie
env.redirect "/"
rescue
env.redirect "/login"
end
end
get "/api/page/:title/:entry/:page" do |env|
begin
title = env.params.url["title"]
entry = env.params.url["entry"]
page = env.params.url["page"].to_i
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
e = t.get_entry entry
raise "Entry `#{entry}` of `#{title}` not found" if e.nil?
img = e.read_page page
raise "Failed to load page #{page} of `#{title}/#{entry}`"\
if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book/:title" do |env|
begin
title = env.params.url["title"]
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
send_json env, t.to_json
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book" do |env|
send_json env, @context.library.to_json
end
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
ms = (Time.utc - start).total_milliseconds
send_json env, {
"milliseconds" => ms,
"titles" => @context.library.titles.size
}.to_json
end
post "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/progress/:title/:entry/:page" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
Kemal.config.logging = false
add_handler LogHandler.new @context.logger
add_handler AuthHandler.new @context.storage
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embeded static files."
serve_static false
add_handler StaticHandler.new
{% end %}
end
def start
@context.debug "Starting Kemal server"
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}
Kemal.config.port = @context.config.port
Kemal.run
end
def start
@context.debug "Starting Kemal server"
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}
Kemal.config.port = Config.current.port
Kemal.run
end
end

View File

@@ -1,29 +0,0 @@
require "baked_file_system"
require "kemal"
require "gzip"
require "./util"
class FS
extend BakedFileSystem
{% if read_file? "./dist" %}
bake_folder "../dist"
{% else %}
bake_folder "../public"
{% end %}
end
class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env)
if request_path_startswith env, @dirs
file = FS.get? env.request.path
return call_next env if file.nil?
slice = Bytes.new file.size
file.read slice
return send_file env, slice, file.mime_type
end
call_next env
end
end

View File

@@ -2,149 +2,228 @@ require "sqlite3"
require "crypto/bcrypt"
require "uuid"
require "base64"
require "./util/*"
def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s
Crypto::Bcrypt::Password.create(pw).to_s
end
def verify_password(hash, pw)
(Crypto::Bcrypt::Password.new hash).verify pw
end
def random_str
Base64.strict_encode UUID.random().to_s
(Crypto::Bcrypt::Password.new hash).verify pw
end
class Storage
def initialize(@path : String, @logger : MLogger)
dir = File.dirname path
unless Dir.exists? dir
@logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{path}" do |db|
begin
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message == "table users already exists"
@logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
else
@logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
random_pw = random_str
hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)",
"admin", hash, nil, 1
puts "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}"
end
end
end
@path : String
@db : DB::Database?
@insert_ids = [] of IDTuple
def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db|
begin
hash, token = db.query_one "select password, token from "\
"users where username = (?)", \
username, as: {String, String?}
unless verify_password hash, password
@logger.debug "Password does not match the hash"
return nil
end
@logger.debug "User #{username} verified"
return token if token
token = random_str
@logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)",
token, username
return token
rescue e
@logger.error "Error when verifying user #{username}: #{e}"
return nil
end
end
end
alias IDTuple = NamedTuple(path: String,
id: String,
is_title: Bool)
def verify_token(token)
DB.open "sqlite3://#{@path}" do |db|
begin
username = db.query_one "select username from users where " \
"token = (?)", token, as: String
return username
rescue e
@logger.debug "Unable to verify token"
return nil
end
end
end
use_default
def verify_admin(token)
DB.open "sqlite3://#{@path}" do |db|
begin
return db.query_one "select admin from users where " \
"token = (?)", token, as: Bool
rescue e
@logger.debug "Unable to verify user as admin"
return false
end
end
end
def initialize(db_path : String? = nil, init_user = true, *,
@auto_close = true)
@path = db_path || Config.current.db_path
dir = File.dirname @path
unless Dir.exists? dir
Logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
begin
# We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
def list_users
results = Array(Tuple(String, Bool)).new
DB.open "sqlite3://#{@path}" do |db|
db.query "select username, admin from users" do |rs|
rs.each do
results << {rs.read(String), rs.read(Bool)}
end
end
end
results
end
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
def new_user(username, password, admin)
admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db|
hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
end
end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db|
if password.size == 0
db.exec "update users set username = (?), admin = (?) "\
"where username = (?)",\
username, admin, original_username
else
hash = hash_password password
db.exec "update users set username = (?), admin = (?),"\
"password = (?) where username = (?)",\
username, admin, hash, original_username
end
end
end
init_admin if init_user
end
end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
end
end
def delete_user(username)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from users where username = (?)", username
end
end
macro init_admin
random_pw = random_str
hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)",
"admin", hash, nil, 1
Logger.log "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}"
end
def logout(token)
DB.open "sqlite3://#{@path}" do |db|
begin
db.exec "update users set token = (?) where token = (?)", \
nil, token
rescue
end
end
end
private def get_db(&block : DB::Database ->)
if @db.nil?
DB.open "sqlite3://#{@path}" do |db|
yield db
end
else
yield @db.not_nil!
end
end
def verify_user(username, password)
get_db do |db|
begin
hash, token = db.query_one "select password, token from " \
"users where username = (?)",
username, as: {String, String?}
unless verify_password hash, password
Logger.debug "Password does not match the hash"
return nil
end
Logger.debug "User #{username} verified"
return token if token
token = random_str
Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)",
token, username
return token
rescue e
Logger.error "Error when verifying user #{username}: #{e}"
return nil
end
end
end
def verify_token(token)
username = nil
get_db do |db|
begin
username = db.query_one "select username from users where " \
"token = (?)", token, as: String
rescue e
Logger.debug "Unable to verify token"
end
end
username
end
def verify_admin(token)
is_admin = false
get_db do |db|
begin
is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool
rescue e
Logger.debug "Unable to verify user as admin"
end
end
is_admin
end
def list_users
results = Array(Tuple(String, Bool)).new
get_db do |db|
db.query "select username, admin from users" do |rs|
rs.each do
results << {rs.read(String), rs.read(Bool)}
end
end
end
results
end
def new_user(username, password, admin)
validate_username username
validate_password password
admin = (admin ? 1 : 0)
get_db do |db|
hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
end
end
def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0)
validate_username username
validate_password password unless password.empty?
get_db do |db|
if password.empty?
db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",
username, admin, original_username
else
hash = hash_password password
db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",
username, admin, hash, original_username
end
end
end
def delete_user(username)
get_db do |db|
db.exec "delete from users where username = (?)", username
end
end
def logout(token)
get_db do |db|
begin
db.exec "update users set token = (?) where token = (?)", nil, token
rescue
end
end
end
def get_id(path, is_title)
id = nil
get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
end
id
end
def insert_id(tp : IDTuple)
@insert_ids << tp
end
def bulk_insert_ids
get_db do |db|
db.transaction do |tx|
@insert_ids.each do |tp|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
end
end
end
@insert_ids.clear
end
def close
unless @db.nil?
@db.not_nil!.close
end
end
def to_json(json : JSON::Builder)
json.string self
end
end

60
src/upload.cr Normal file
View File

@@ -0,0 +1,60 @@
require "./util/*"
class Upload
def initialize(@dir : String)
unless Dir.exists? @dir
Logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
end
# Writes IO to a file with random filename in the uploads directory and
# returns the full path of created file
# e.g., save("image", ".png", <io>)
# ==> "~/mango/uploads/image/<random string>.png"
def save(sub_dir : String, ext : String, io : IO)
full_dir = File.join @dir, sub_dir
filename = random_str + ext
file_path = File.join full_dir, filename
unless Dir.exists? full_dir
Logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir
end
File.open file_path, "w" do |f|
IO.copy io, f
end
file_path
end
# Converts path to a file in the uploads directory to the URL path for
# accessing the file.
def path_to_url(path : String)
dir_mathed = false
ary = [] of String
# We fill it with parts until it equals to @upload_dir
dir_ary = [] of String
Path.new(path).each_part do |part|
if dir_mathed
ary << part
else
dir_ary << part
if File.same? @dir, File.join dir_ary
dir_mathed = true
end
end
end
if ary.empty?
Logger.warn "File #{path} is not in the upload directory #{@dir}"
return
end
ary.unshift UPLOAD_URL_PREFIX
File.join(ary).to_s
end
end

View File

@@ -1,34 +0,0 @@
IMGS_PER_PAGE = 5
macro layout(name)
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
# if the request gets here, its has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
end
macro send_json(env, json)
{{env}}.response.content_type = "application/json"
{{json}}
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
return false
end

112
src/util/chapter_sort.cr Normal file
View File

@@ -0,0 +1,112 @@
# Helper method used to sort chapters in a folder
# It respects the keywords like "Vol." and "Ch." in the filenames
# This sorting method was initially implemented in JS and done in the frontend.
# see https://github.com/hkalexling/Mango/blob/
# 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87
require "big"
private class Item
getter numbers : Hash(String, BigDecimal)
def initialize(@numbers)
end
# Compare with another Item using keys
def <=>(other : Item, keys : Array(String))
keys.each do |key|
if !@numbers.has_key?(key) && !other.numbers.has_key?(key)
next
elsif !@numbers.has_key? key
return 1
elsif !other.numbers.has_key? key
return -1
elsif @numbers[key] == other.numbers[key]
next
else
return @numbers[key] <=> other.numbers[key]
end
end
0
end
end
private class KeyRange
getter min : BigDecimal, max : BigDecimal, count : Int32
def initialize(value : BigDecimal)
@min = @max = value
@count = 1
end
def update(value : BigDecimal)
@min = value if value < @min
@max = value if value > @max
@count += 1
end
def range
@max - @min
end
end
class ChapterSorter
@sorted_keys = [] of String
def initialize(str_ary : Array(String))
keys = {} of String => KeyRange
str_ary.each do |str|
scan str do |k, v|
if keys.has_key? k
keys[k].update v
else
keys[k] = KeyRange.new v
end
end
end
# Get the array of keys string and sort them
@sorted_keys = keys.keys
# Only use keys that are present in over half of the strings
.select do |key|
keys[key].count >= str_ary.size / 2
end
.sort do |a_key, b_key|
a = keys[a_key]
b = keys[b_key]
# Sort keys by the number of times they appear
count_compare = b.count <=> a.count
if count_compare == 0
# Then sort by value range
b.range <=> a.range
else
count_compare
end
end
end
def compare(a : String, b : String)
item_a = str_to_item a
item_b = str_to_item b
item_a.<=>(item_b, @sorted_keys)
end
private def scan(str, &)
str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
key = match[1]
num = match[2].to_big_d
yield key, num
end
end
private def str_to_item(str)
numbers = {} of String => BigDecimal
scan str do |k, v|
numbers[k] = v
end
Item.new numbers
end
end

42
src/util/numeric_sort.cr Normal file
View File

@@ -0,0 +1,42 @@
# Properly sort alphanumeric strings
# Used to sort the images files inside the archives
# https://github.com/hkalexling/Mango/issues/12
require "big"
def is_numeric(str)
/^\d+/.match(str) != nil
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 != "" }
end
arr
end
def compare_numerically(c, d)
is_c_bigger = c.size <=> d.size
if c.size > d.size
d += [nil] * (c.size - d.size)
elsif c.size < d.size
c += [nil] * (d.size - c.size)
end
c.zip(d) do |a, b|
return -1 if a.nil?
return 1 if b.nil?
if is_numeric(a) && is_numeric(b)
compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0
else
compare = a <=> b
return compare if compare != 0
end
end
is_c_bigger
end
def compare_numerically(a : String, b : String)
compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end

63
src/util/util.cr Normal file
View File

@@ -0,0 +1,63 @@
IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def random_str
UUID.random.to_s.gsub "-", ""
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end
def register_mime_types
{
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar",
}.each do |k, v|
MIME.register k, v
end
end
struct Int
def or(other : Int)
if self == 0
other
else
self
end
end
end
struct Nil
def or(other : Int)
other
end
end
macro use_default
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
end
class String
def alphanumeric_underscore?
self.chars.all? { |c| c.alphanumeric? || c == '_' }
end
end

31
src/util/validation.cr Normal file
View File

@@ -0,0 +1,31 @@
def validate_username(username)
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
end
def validate_password(password)
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
def validate_archive(path : String) : Exception?
file = nil
begin
file = ArchiveFile.new path
file.check
file.close
return
rescue e
file.close unless file.nil?
e
end
end

83
src/util/web.cr Normal file
View File

@@ -0,0 +1,83 @@
# Web related helper functions/macros
macro layout(name)
base_url = Config.current.base_url
begin
is_admin = false
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def send_attachment(env, path)
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
def request_path_startswith(env, ary)
ary.each do |prefix|
if env.request.path.starts_with? prefix
return true
end
end
false
end
def requesting_static_file(env)
request_path_startswith env, STATIC_DIRS
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
macro get_sort_opt
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
end
end

View File

@@ -1,17 +0,0 @@
<ul class="uk-list uk-list-large uk-list-divider">
<li data-url="/admin/user">User Managerment</li>
<li onclick="if(!scanning){scan()}">
<span id="scan">Scan Library Files</span>
<span id="scan-status" class="uk-align-right">
<div uk-spinner hidden></div>
<span hidden></span>
</span>
</li>
</ul>
<hr class="uk-divider-icon">
<a class="uk-button uk-button-danger" href="/logout">Log Out</a>
<% content_for "script" do %>
<script src="/js/admin.js"></script>
<% end %>

26
src/views/admin.html.ecr Normal file
View File

@@ -0,0 +1,26 @@
<ul class="uk-list uk-list-large uk-list-divider">
<li data-url="<%= base_url %>admin/user">User Managerment</li>
<li onclick="if(!scanning){scan()}">
<span id="scan">Scan Library Files</span>
<span id="scan-status" class="uk-align-right">
<div uk-spinner hidden></div>
<span hidden></span>
</span>
</li>
<li class="nopointer">
<span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2">
<option>Dark</option>
<option>Light</option>
<option>System</option>
</select>
</li>
</ul>
<hr class="uk-divider-icon">
<p class="uk-text-meta">Version: v<%= MANGO_VERSION %></p>
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/admin.js"></script>
<% end %>

View File

@@ -0,0 +1,86 @@
<% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
<% grouped_count = item[:grouped_count] %>
<% if grouped_count == 1 %>
<% item = item[:entry] %>
<% else %>
<% item = item[:entry].book %>
<% end %>
<% else %>
<% grouped_count = 1 %>
<% end %>
<div class="item"
<% if item.is_a? Entry %>
id="<%= item.id %>"
<% end %>>
<div class="acard
<% if item.is_a? Entry && item.err_msg.nil? %>
<%= "is_entry" %>
<% end %>
"
<% if item.is_a? Entry %>
<% if item.err_msg %>
onclick="location='<%= base_url %>reader/<%= item.book.id %>/<%= item.id %>'"
<% else %>
data-encoded-path="<%= item.encoded_path %>"
data-pages="<%= item.pages %>"
data-progress="<%= (progress * 100).round(1) %>"
data-encoded-book-title="<%= item.book.encoded_display_name %>"
data-encoded-title="<%= item.encoded_display_name %>"
data-book-id="<%= item.book.id %>"
data-id="<%= item.id %>"
<% end %>
<% else %>
onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>>
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
x-init="disabled = false"
<% end %>>
<div class="uk-card-media-top uk-inline" @mouseenter="hover = true" @mouseleave="hover = false">
<img data-src="<%= item.cover_url %>" width="100%" height="100%" alt="" uk-img
<% if item.is_a? Entry && item.err_msg %>
class="grayscale"
<% end %>>
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
<div class="uk-position-center">
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
</div>
</div>
</div>
<div class="uk-card-body">
<% unless progress < 0 || progress > 100 || progress.nan? %>
<div class="uk-card-badge label"><%= (progress * 100).round(1) %>%</div>
<% end %>
<h3 class="uk-card-title break-word
<% if page == "home" && item.is_a? Entry %>
<%= "uk-margin-remove-bottom" %>
<% end %>
" data-title="<%= HTML.escape(item.display_name) %>"><%= 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>
<% end %>
<% if item.is_a? Entry %>
<% if item.err_msg %>
<p class="uk-text-meta uk-margin-remove-bottom">Error <span uk-icon="info"></span></p>
<div uk-dropdown><%= item.err_msg %></div>
<% else %>
<p class="uk-text-meta"><%= item.pages %> pages</p>
<% end %>
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.size %> entries</p>
<% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,32 @@
<div id="modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
&nbsp;
<% unless page == "home" %>
<% if is_admin %>
<a id="modal-edit-btn" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
<% end %>
<a id="modal-download-btn" class="uk-icon-button" uk-icon="icon:download"></a>
</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">
<p>Read</p>
<p uk-margin>
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
<a id="continue-btn" class="uk-button uk-button-primary"></a>
</p>
<p>Progress</p>
<p uk-margin>
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js" defer></script>
<script src="<%= base_url %>js/theme.js"></script>
</head>

View File

@@ -0,0 +1,14 @@
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<% hash.each do |k, v| %>
<option id="<%= k %>-up"
<% if sort_opt && k == sort_opt.method.to_s.underscore && sort_opt.ascend %>
<%= "selected" %>
<% end %>>â–˛ <%= v %></option>
<option id="<%= k %>-down"
<% if sort_opt && k == sort_opt.method.to_s.underscore && !sort_opt.ascend %>
<%= "selected" %>
<% end %>>â–Ľ <%= v %></option>
<% end %>
</select>
</div>

View File

@@ -0,0 +1,33 @@
<div class="uk-margin">
<div id="actions" class="uk-margin">
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
</div>
<div id="config" class="uk-margin">
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
</div>
</div>
<table class="uk-table uk-table-striped uk-overflow-auto">
<thead>
<tr>
<th>Chapter</th>
<th>Manga</th>
<th>Progress</th>
<th>Time</th>
<th>Status</th>
<th>Plugin</th>
<th>Actions</th>
</tr>
</thead>
</table>
<% content_for "script" do %>
<script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script>
<% end %>

View File

@@ -0,0 +1,83 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4">
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
</div>
<div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
</div>
</div>
<div class"uk-grid-small" uk-grid hidden id="manga-details">
<div class="uk-width-1-4@s">
<img id="cover">
</div>
<div class="uk-width-1-4@s">
<p id="title"></p>
<p id="artist"></p>
<p id="author"></p>
</div>
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
<div class="uk-margin">
<label class="uk-form-label" for="lang-select">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="lang-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" id="group-select">
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="volume-range">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="chapter-range">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</div>
</div>
</div>
</div>
<div id="selection-controls" class="uk-margin" hidden>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p id="filter-notification" hidden></p>
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
</table>
<% content_for "script" do %>
<script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %>

84
src/views/home.html.ecr Normal file
View File

@@ -0,0 +1,84 @@
<%- if new_user && empty_library -%>
<div class="uk-container uk-text-center">
<i class="fas fa-plus" style="font-size: 80px;"></i>
<h2>Add your first manga</h2>
<p style="margin-bottom: 40px;">We can't find any files yet. Add some to your library and they'll appear here.</p>
<dl class="uk-description-list">
<dt style="font-weight: 500;">Current library path</dt>
<dd><code><%= Config.current.library_path %></code></dd>
<dt style="font-weight: 500;">Want to change your library path?</dt>
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd>
<dt style="font-weight: 500;">Can't see your files yet?</dt>
<dd>
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete
<% if is_admin %>
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
<% end %>.
</dd>
</dl>
</div>
<%- elsif new_user && empty_library == false -%>
<div class="uk-container uk-text-center">
<i class="fas fa-book-open" style="font-size: 80px;"></i>
<h2>Read your first manga</h2>
<p>Once you start reading, Mango will remember where you left off
and show your entries here.</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- elsif new_user == false && empty_library == false -%>
<%- if continue_reading.empty? && recently_added.empty? -%>
<div class="uk-container uk-text-center">
<img src="<%= base_url %>img/banner.png" style="max-width: 400px; padding: 0 20px;">
<p>A self-hosted manga server and reader</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- end -%>
<%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- continue_reading.each do |cr| -%>
<% item = cr[:entry] %>
<% progress = cr[:percentage] %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%- unless start_reading.empty? -%>
<h2 class="uk-title home-headings">Start Reading</h2>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- start_reading.each do |t| -%>
<% item = t %>
<% progress = 0.0 %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%- unless recently_added.empty? -%>
<h2 class="uk-title home-headings">Recently Added</h2>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- recently_added.each do |ra| -%>
<% item = ra %>
<% progress = ra[:percentage] %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%= render_component "entry-modal" %>
<%- end -%>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<% end %>

View File

@@ -1,30 +0,0 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-margin">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item">
<a class="acard" href="/book/<%= t.title %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img src="<%= t.entries[0].cover_url %>" alt="">
</div>
<div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title"><%= t.title %></h3>
<p><%= t.entries.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<% content_for "script" do %>
<script src="/js/search.js"></script>
<% end %>

View File

@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="/css/mango.css" />
</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 uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="/">Home</a></li>
<li><a href="/admin">Admin</a></li>
<hr uk-divider>
<li><a href="/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@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="/"><img src="/img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="/">Home</a></li>
<li><a href="/admin">Admin</a></li>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<ul class="uk-navbar-nav">
<li><a href="/logout">Logout</a></li>
</ul>
</div>
</div>
</div>
<div class="uk-section uk-section-default uk-section-small">
</div>
<div class="uk-section uk-section-default uk-section-small">
<div class="uk-container uk-container-small">
<%= content %>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
</html>

86
src/views/layout.html.ecr Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<%= 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>
<% 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>
</div>
<div class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li>
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</div>
</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 class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
</div>
</div>
<script>
setTheme();
const base_url = "<%= base_url %>";
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
</head>
<body>
<div class="uk-section uk-section-muted uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1">
<div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
<div class="uk-width-1-1@m">
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
<h3 class="uk-card-title uk-text-center">Log In</h3>
<form action="/login" method="post">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body>
</html>

36
src/views/login.html.ecr Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<%= render_component "head" %>
<body>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1">
<div class="uk-container">
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
<div class="uk-width-1-1@m">
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
<h3 class="uk-card-title uk-text-center">Log In</h3>
<form action="<%= base_url %>login" method="post">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
setTheme();
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
</body>
</html>

View File

@@ -1 +0,0 @@
<p class="uk-text-lead"><%= message %></p>

View File

@@ -0,0 +1 @@
<p class="uk-text-lead uk-text-center"><%= message %></p>

Some files were not shown because too many files have changed in this diff Show More