Compare commits

...

200 Commits

Author SHA1 Message Date
Alex Ling ef1ab940f5 Fix GitHub tags of dependencies in the Dockerfiles
[skip ci]
2020-08-16 16:45:57 +00:00
Alex Ling 97a1c408d8 Bump version to v0.11.0 2020-08-16 12:44:02 +00:00
Alex Ling abbf77df13 Merge branch 'master' into dev 2020-08-10 14:32:53 +00:00
Alex Ling 3b4021f680 Workflow retry hack
I got random "Invalid memory access" when running `crystal build`.
This is probably a compiler or LLVM bug.
We use this temporary hack to retry until they fix it.
2020-08-10 13:14:05 +00:00
Alex Ling 68b1923cb6 Clear title ID at the end of scans
This minimizes the chance of getting an unexpected empty home page
2020-08-10 11:45:50 +00:00
Alex Ling 3cdd4b29a5 Add back to top button to all pages (#95) 2020-08-10 11:42:23 +00:00
Alex Ling af84c0f6de Fix typo 2020-08-08 17:04:42 +08:00
Alex Ling 85a65f84d0 Remove unnecessary "require" statements 2020-08-06 18:10:13 +00:00
Alex Ling 5027a911cd Respect the *_PROXY environment variables (#94) 2020-08-06 17:01:53 +00:00
Alex Ling ac63bf7599 Add sponsors [skip ci] 2020-08-06 12:49:43 +08:00
Alex Ling 30b0e0b8fb Pin down mythml and duktape versions in Dockerfile
[skip ci]
2020-08-05 12:00:49 +00:00
Alex Ling ddda058d8d Fix spec 2020-08-05 09:59:52 +00:00
Alex Ling 46db25e8e0 Fix wildcard in workflow 2020-08-05 09:50:46 +00:00
Alex Ling c07f421322 Fix CLI tool not exiting 2020-08-05 09:48:31 +00:00
Alex Ling 99a77966ad Add arm64v8 to Makefile and rename object files 2020-08-05 09:48:03 +00:00
Alex Ling d00b917575 Build the object file in Action 2020-08-04 17:24:36 +00:00
Alex Ling 4fd8334c37 Name the object file 2020-08-04 17:24:13 +00:00
Alex Ling 3aa4630558 Use Crystal 0.34.0 2020-08-04 17:23:19 +00:00
Alex Ling cde5af7066 Remove interactive prompt for easier use in docker 2020-08-04 12:57:40 +00:00
Alex Ling eb528e1726 Add the arm32v7 target to Makefile 2020-08-04 11:50:07 +00:00
Alex Ling 5e01cc38fe Fix downloaders 2020-08-04 11:36:36 +00:00
Alex Ling 9a787ccbc3 Formatting 2020-08-04 11:36:24 +00:00
Alex Ling 8a83c0df4e ARM support (#25, #78) 2020-08-04 11:00:33 +00:00
Alex Ling 87dea01917 Add ASCII banner, because we can :) 2020-08-02 17:52:52 +00:00
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
Jared Turner 4f5e05c008 refactor continue reading into Library class 2020-06-03 13:48:49 +01: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 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 4841f90cc1 Remove edit buttons from home 2020-05-29 15:51:01 +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 6a9105605d Fix library link in the breadcrumb menu 2020-05-23 12:16:08 +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
89 changed files with 4212 additions and 2019 deletions
+1
View File
@@ -1,4 +1,5 @@
# These are supported funding model platforms # These are supported funding model platforms
open_collective: mango
patreon: hkalexling patreon: hkalexling
ko_fi: hkalexling ko_fi: hkalexling
+4 -1
View File
@@ -8,10 +8,13 @@ assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] A clear and concise description of what the problem is. E.g. I'm always frustrated when [...]
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. 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** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
+11 -2
View File
@@ -19,13 +19,22 @@ jobs:
- name: Install dependencies - 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 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 - name: Build
run: make static run: make static || make static
- name: Linter - name: Linter
run: make check run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload artifact - name: Upload binary
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: mango name: mango
path: mango path: mango
- name: build arm32v7 object file
run: make arm32v7 || make arm32v7
- name: build arm64v8 object file
run: make arm64v8 || make arm64v8
- name: Upload object files
uses: actions/upload-artifact@v2
with:
name: object files
path: ./*.o
+3
View File
@@ -9,3 +9,6 @@ dist
mango mango
.env .env
*.md *.md
public/css/uikit.css
public/img/*.svg
public/js/*.min.js
+13
View File
@@ -0,0 +1,13 @@
FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+13
View File
@@ -0,0 +1,13 @@
FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+12 -2
View File
@@ -7,11 +7,15 @@ uglify:
yarn yarn
yarn uglify yarn uglify
setup: libs
yarn
yarn gulp dev
build: libs build: libs
crystal build src/mango.cr --release --progress crystal build src/mango.cr --release --progress --error-trace
static: uglify | libs static: uglify | libs
crystal build src/mango.cr --release --progress --static crystal build src/mango.cr --release --progress --static --error-trace
libs: libs:
shards install --production shards install --production
@@ -27,6 +31,12 @@ check:
./bin/ameba ./bin/ameba
./dev/linewidth.sh ./dev/linewidth.sh
arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
arm64v8:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
install: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango
+10 -5
View File
@@ -1,6 +1,3 @@
![banner](./public/img/banner-paddings.png) ![banner](./public/img/banner-paddings.png)
# Mango # Mango
@@ -10,14 +7,18 @@
Mango is a self-hosted manga server and reader. Its features include Mango is a self-hosted manga server and reader. Its features include
- Multi-user support - Multi-user support
- OPDS support
- Dark/light mode switch - Dark/light mode switch
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader - 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 - 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 - 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 ## Installation
### Pre-built Binary ### Pre-built Binary
@@ -50,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.5.2 Mango - Manga Server and Web Reader. Version 0.11.0
Usage: Usage:
@@ -138,8 +139,12 @@ Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png) ![mobile screenshot](./.github/screenshots/mobile.png)
## Sponsors
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
## Contributors ## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions. Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested 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) [![](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)
+31 -4
View File
@@ -1,29 +1,56 @@
const gulp = require('gulp'); const gulp = require('gulp');
const minify = require("gulp-babel-minify"); const minify = require("gulp-babel-minify");
const minifyCss = require('gulp-minify-css'); 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', () => { gulp.task('minify-js', () => {
return gulp.src('public/js/*.js') return gulp.src('public/js/*.js')
.pipe(minify({ .pipe(minify({
removeConsole: true removeConsole: true,
builtIns: false
})) }))
.pipe(gulp.dest('dist/js')); .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', () => { gulp.task('minify-css', () => {
return gulp.src('public/css/*.css') return gulp.src('public/css/*.css')
.pipe(minifyCss()) .pipe(minifyCss())
.pipe(gulp.dest('dist/css')); .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', () => { gulp.task('img', () => {
return gulp.src('public/img/*') return gulp.src('public/img/*')
.pipe(gulp.dest('dist/img')); .pipe(gulp.dest('dist/img'));
}); });
gulp.task('favicon', () => { gulp.task('copy-files', () => {
return gulp.src('public/favicon.ico') return gulp.src('public/*.*')
.pipe(gulp.dest('dist')); .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'
));
+6 -1
View File
@@ -8,9 +8,14 @@
"devDependencies": { "devDependencies": {
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
"gulp-minify-css": "^1.2.4" "gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4",
"less": "^3.11.3"
}, },
"scripts": { "scripts": {
"uglify": "gulp" "uglify": "gulp"
},
"dependencies": {
"uikit": "^3.5.4"
} }
} }
+94 -14
View File
@@ -1,74 +1,154 @@
.uk-alert-close { .uk-alert-close {
color: black !important; color: black !important;
} }
.uk-card-body { .uk-card-body {
padding: 20px; padding: 20px;
} }
.uk-card-media-top { .uk-card-media-top {
width: 100%;
height: 250px; height: 250px;
} }
@media (min-width: 600px) { @media (min-width: 600px) {
.uk-card-media-top { .uk-card-media-top {
height: 300px; height: 300px;
}
} }
}
.uk-card-media-top > img { .uk-card-media-top>img {
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
} }
.uk-card-title { .uk-card-title {
height: 3em; max-height: 3em;
} }
.acard:hover { .acard:hover {
text-decoration: none;
}
.uk-list li {
cursor: pointer; cursor: pointer;
} }
.reader-bg {
background-color: black; .uk-list li:not(.nopointer) {
cursor: pointer;
} }
#scan-status { #scan-status {
cursor: auto; cursor: auto;
} }
.reader-bg {
background-color: black;
}
.break-word { .break-word {
word-wrap: break-word; word-wrap: break-word;
} }
.uk-logo > img {
.uk-logo>img {
height: 90px; height: 90px;
width: 90px; width: 90px;
} }
.uk-search { .uk-search {
width: 100%; width: 100%;
} }
#selectable .ui-selecting { #selectable .ui-selecting {
background: #EEE6B9; background: #EEE6B9;
} }
#selectable .ui-selected { #selectable .ui-selected {
background: #F4E487; background: #F4E487;
} }
#selectable .ui-selecting.dark {
.uk-light #selectable .ui-selecting {
background: #5E5731; background: #5E5731;
} }
#selectable .ui-selected.dark {
.uk-light #selectable .ui-selected {
background: #9D9252; background: #9D9252;
} }
td > .uk-dropdown {
td>.uk-dropdown {
white-space: pre-line; white-space: pre-line;
} }
#edit-modal .uk-grid > div {
#edit-modal .uk-grid>div {
height: 300px; height: 300px;
} }
#edit-modal #cover { #edit-modal #cover {
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
} }
#edit-modal #cover-upload { #edit-modal #cover-upload {
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
#edit-modal .uk-modal-body .uk-inline { #edit-modal .uk-modal-body .uk-inline {
width: 100%; width: 100%;
} }
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
+45
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.

After

Width:  |  Height:  |  Size: 272 KiB

+24 -9
View File
@@ -1,13 +1,14 @@
var scanning = false; let scanning = false;
function scan() {
const scan = () => {
scanning = true; scanning = true;
$('#scan-status > div').removeAttr('hidden'); $('#scan-status > div').removeAttr('hidden');
$('#scan-status > span').attr('hidden', ''); $('#scan-status > span').attr('hidden', '');
var color = $('#scan').css('color'); const color = $('#scan').css('color');
$('#scan').css('color', 'gray'); $('#scan').css('color', 'gray');
$.post(base_url + 'api/admin/scan', function (data) { $.post(base_url + 'api/admin/scan', (data) => {
var ms = data.milliseconds; const ms = data.milliseconds;
var titles = data.titles; const titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms'); $('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
$('#scan-status > span').removeAttr('hidden'); $('#scan-status > span').removeAttr('hidden');
$('#scan').css('color', color); $('#scan').css('color', color);
@@ -15,11 +16,25 @@ function scan() {
scanning = false; scanning = false;
}); });
} }
$(function() {
$('li').click(function() { String.prototype.capitalize = function() {
url = $(this).attr('data-url'); return this.charAt(0).toUpperCase() + this.slice(1);
}
$(() => {
$('li').click((e) => {
const url = $(e.currentTarget).attr('data-url');
if (url) { if (url) {
$(location).attr('href', 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();
});
}); });
+2 -3
View File
@@ -1,13 +1,12 @@
const truncate = () => { const truncate = () => {
$('.acard .uk-card-title').each((i, e) => { $('.uk-card-title').each((i, e) => {
$(e).dotdotdot({ $(e).dotdotdot({
truncate: 'letter', truncate: 'letter',
watch: true, watch: true,
callback: (truncated) => { callback: (truncated) => {
if (truncated) { if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title')); $(e).attr('uk-tooltip', $(e).attr('data-title'));
} } else {
else {
$(e).removeAttr('uk-tooltip'); $(e).removeAttr('uk-tooltip');
} }
} }
+15 -8
View File
@@ -24,7 +24,9 @@ const loadConfig = () => {
const remove = (id) => { const remove = (id) => {
var url = base_url + 'api/admin/mangadex/queue/delete'; var url = base_url + 'api/admin/mangadex/queue/delete';
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({id: id}); url += '?' + $.param({
id: id
});
console.log(url); console.log(url);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -45,7 +47,9 @@ const remove = (id) => {
const refresh = (id) => { const refresh = (id) => {
var url = base_url + 'api/admin/mangadex/queue/retry'; var url = base_url + 'api/admin/mangadex/queue/retry';
if (id !== undefined) if (id !== undefined)
url += '?' + $.param({id: id}); url += '?' + $.param({
id: id
});
console.log(url); console.log(url);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
@@ -100,24 +104,27 @@ const load = () => {
$('#pause-resume-btn').text(btnText); $('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden'); $('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => { const rows = data.jobs.map(obj => {
var cls = 'uk-label '; var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending';
if (obj.status === 'Completed') if (obj.status === 'Completed')
cls += 'uk-label-success'; cls += 'label-success';
if (obj.status === 'Error') if (obj.status === 'Error')
cls += 'uk-label-danger'; cls += 'label-danger';
if (obj.status === 'MissingPages') if (obj.status === 'MissingPages')
cls += 'uk-label-warning'; cls += 'label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : ''; const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
const statusSpan = `<span class="${cls}">${obj.status} ${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 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>` : ''; const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}"> return `<tr id="chapter-${obj.id}">
<td><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td> <td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
<td><a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_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>${obj.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td> <td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td> <td>${statusSpan} ${dropdown}</td>
<td>${obj.plugin_id || ""}</td>
<td> <td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a> <a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn} ${retryBtn}
+16 -19
View File
@@ -34,7 +34,9 @@ const download = () => {
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: base_url + 'api/admin/mangadex/download', url: base_url + 'api/admin/mangadex/download',
data: JSON.stringify({chapters: chapters}), data: JSON.stringify({
chapters: chapters
}),
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
}) })
@@ -49,7 +51,6 @@ const download = () => {
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { 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'; window.location.href = base_url + 'admin/downloads';
}); });
styleModal();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
@@ -59,15 +60,13 @@ const download = () => {
$('#download-btn').removeAttr('hidden'); $('#download-btn').removeAttr('hidden');
}); });
}); });
styleModal();
}; };
const toggleSpinner = () => { const toggleSpinner = () => {
var attr = $('#spinner').attr('hidden'); var attr = $('#spinner').attr('hidden');
if (attr) { if (attr) {
$('#spinner').removeAttr('hidden'); $('#spinner').removeAttr('hidden');
$('#search-btn').attr('hidden', ''); $('#search-btn').attr('hidden', '');
} } else {
else {
$('#search-btn').removeAttr('hidden'); $('#search-btn').removeAttr('hidden');
$('#spinner').attr('hidden', ''); $('#spinner').attr('hidden', '');
} }
@@ -98,8 +97,7 @@ const search = () => {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path); const match = /\/title\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} } catch (e) {
catch(e) {
int_id = parseInt(input); int_id = parseInt(input);
} }
@@ -139,8 +137,12 @@ const search = () => {
const comp = (a, b) => { const comp = (a, b) => {
var ai; var ai;
var bi; var bi;
try {ai = parseFloat(a);} catch(e) {} try {
try {bi = parseFloat(b);} catch(e) {} ai = parseFloat(a);
} catch (e) {}
try {
bi = parseFloat(b);
} catch (e) {}
if (typeof ai === 'undefined') return -1; if (typeof ai === 'undefined') return -1;
if (typeof bi === 'undefined') return 1; if (typeof bi === 'undefined') return 1;
if (ai < bi) return 1; if (ai < bi) return 1;
@@ -176,8 +178,7 @@ const parseRange = str => {
if (!matches) { if (!matches) {
alert('danger', `Failed to parse filter input ${str}`); alert('danger', `Failed to parse filter input ${str}`);
return [null, null]; return [null, null];
} } else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30 // e.g., <= 30
num = parseInt(matches[2]); num = parseInt(matches[2]);
if (isNaN(num)) { if (isNaN(num)) {
@@ -194,8 +195,7 @@ const parseRange = str => {
case '>=': case '>=':
return [num, null]; return [num, null];
} }
} } else if (typeof matches[3] !== 'undefined') {
else if (typeof matches[3] !== 'undefined') {
// a single number // a single number
num = parseInt(matches[3]); num = parseInt(matches[3]);
if (isNaN(num)) { if (isNaN(num)) {
@@ -203,8 +203,7 @@ const parseRange = str => {
return [null, null]; return [null, null];
} }
return [num, num]; return [num, num];
} } else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23 // e.g., 10 - 23
num = parseInt(matches[4]); num = parseInt(matches[4]);
const n2 = parseInt(matches[5]); const n2 = parseInt(matches[5]);
@@ -213,8 +212,7 @@ const parseRange = str => {
return [null, null]; return [null, null];
} }
return [num, n2]; return [num, n2];
} } else {
else {
// empty or space only // empty or space only
return [null, null]; return [null, null];
} }
@@ -280,8 +278,7 @@ const buildTable = () => {
const group_str = Object.entries(chp.groups).map(([k, v]) => { const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`; return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | '); }).join(' | ');
const dark = getTheme() === 'dark' ? 'dark' : ''; return `<tr class="ui-widget-content">
return `<tr class="ui-widget-content ${dark}">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td> <td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td> <td>${chp.title}</td>
<td>${chp.language}</td> <td>${chp.language}</td>
+142
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');
});
});
};
+10 -7
View File
@@ -3,18 +3,18 @@ $(function() {
var controller = new ScrollMagic.Controller(); var controller = new ScrollMagic.Controller();
// replace history on scroll // replace history on scroll
$('img').each(function(idx){ $('img').each(function(idx) {
var scene = new ScrollMagic.Scene({ var scene = new ScrollMagic.Scene({
triggerElement: $(this).get(), triggerElement: $(this).get(),
triggerHook: 'onEnter', triggerHook: 'onEnter',
reverse: true reverse: true
}) })
.addTo(controller) .addTo(controller)
.on('enter', function(event){ .on('enter', function(event) {
current = $(event.target.triggerElement()).attr('id'); current = $(event.target.triggerElement()).attr('id');
replaceHistory(current); replaceHistory(current);
}) })
.on('leave', function(event){ .on('leave', function(event) {
var prev = $(event.target.triggerElement()).prev(); var prev = $(event.target.triggerElement()).prev();
current = $(prev).attr('id'); current = $(prev).attr('id');
replaceHistory(current); replaceHistory(current);
@@ -28,7 +28,7 @@ $(function() {
offset: -500 offset: -500
}) })
.addTo(controller) .addTo(controller)
.on('enter', function(){ .on('enter', function() {
var nextURL = $('.next-url').attr('href'); var nextURL = $('.next-url').attr('href');
$('.next-url').remove(); $('.next-url').remove();
if (!nextURL) { if (!nextURL) {
@@ -39,7 +39,7 @@ $(function() {
$('#next-btn').removeAttr('hidden'); $('#next-btn').removeAttr('hidden');
return; 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 === 'error') console.log(xhr.statusText);
if (status === 'success') { if (status === 'success') {
console.log(nextURL + ' loaded'); console.log(nextURL + ' loaded');
@@ -54,17 +54,18 @@ $(function() {
bind(); bind();
}); });
$('#page-select').change(function(){ $('#page-select').change(function() {
jumpTo(parseInt($('#page-select').val())); jumpTo(parseInt($('#page-select').val()));
}); });
function showControl(idx) { function showControl(idx) {
const pageCount = $('#page-select > option').length; const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText); $('#progress-label').text(progressText);
$('#page-select').val(idx); $('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show(); UIkit.modal($('#modal-sections')).show();
styleModal();
} }
function jumpTo(page) { function jumpTo(page) {
var ary = window.location.pathname.split('/'); var ary = window.location.pathname.split('/');
ary[ary.length - 1] = page; ary[ary.length - 1] = page;
@@ -72,10 +73,12 @@ function jumpTo(page) {
ary.unshift(window.location.origin); ary.unshift(window.location.origin);
window.location.replace(ary.join('/')); window.location.replace(ary.join('/'));
} }
function replaceHistory(url) { function replaceHistory(url) {
history.replaceState(null, "", url); history.replaceState(null, "", url);
console.log('reading ' + url); console.log('reading ' + url);
} }
function redirect(url) { function redirect(url) {
window.location.replace(url); window.location.replace(url);
} }
+7 -115
View File
@@ -1,123 +1,15 @@
$(() => { $(() => {
const sortItems = () => { $('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id'); const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-'); const ary = sort.split('-');
const by = ary[0]; const by = ary[0];
const dir = ary[1]; const dir = ary[1];
let items = $('.item'); const url = `${location.protocol}//${location.host}${location.pathname}`;
items.remove(); const newURL = `${url}?${$.param({
sort: by,
const ctxAry = []; ascend: dir === 'up' ? 1 : 0
const keyRange = {}; })}`;
if (by === 'auto') { window.location.href = newURL;
// intelligent sorting
items.each((i, item) => {
const name = $(item).find('.uk-card-title').text();
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
const numbers = {};
let match = regex.exec(name);
while (match) {
const key = match[1];
const num = parseFloat(match[2]);
numbers[key] = num;
if (!keyRange[key]) {
keyRange[key] = [num, num, 1];
}
else {
keyRange[key][2] += 1;
if (num < keyRange[key][0]) {
keyRange[key][0] = num;
}
else if (num > keyRange[key][1]) {
keyRange[key][1] = num;
}
}
match = regex.exec(name);
}
ctxAry.push({index: i, numbers: numbers});
}); });
console.log(keyRange);
const sortedKeys = Object.keys(keyRange).filter(k => {
return keyRange[k][2] >= items.length / 2;
});
sortedKeys.sort((a, b) => {
// sort by frequency of the key first
if (keyRange[a][2] !== keyRange[b][2]) {
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
}
// then sort by range of the key
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
});
console.log(sortedKeys);
ctxAry.sort((a, b) => {
for (let i = 0; i < sortedKeys.length; i++) {
const key = sortedKeys[i];
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
continue;
if (a.numbers[key] === undefined)
return 1;
if (b.numbers[key] === undefined)
return -1;
if (a.numbers[key] === b.numbers[key])
continue;
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
}
return 0;
});
const sortedItems = [];
ctxAry.forEach(ctx => {
sortedItems.push(items[ctx.index]);
});
items = sortedItems;
if (dir === 'down') {
items.reverse();
}
}
else {
items.sort((a, b) => {
var res;
if (by === 'name')
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else if (by === 'progress') {
const ap = parseFloat($(a).attr('data-progress'));
const bp = parseFloat($(b).attr('data-progress'));
if (ap === bp)
// if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else
res = ap > bp;
}
if (dir === 'up')
return res ? 1 : -1;
else
return !res ? 1 : -1;
});
}
$('#item-container').append(items);
};
$('#sort-select').change(() => {
sortItems();
});
if ($('option#auto-up').length > 0)
$('option#auto-up').attr('selected', '');
else
$('option#name-up').attr('selected', '');
sortItems();
}); });
+49 -20
View File
@@ -1,29 +1,50 @@
const getTheme = () => { // https://flaviocopes.com/javascript-detect-dark-mode/
var theme = localStorage.getItem('theme'); const preferDarkMode = () => {
if (!theme) theme = 'light'; return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return theme;
}; };
const saveTheme = theme => { const validThemeSetting = (theme) => {
localStorage.setItem('theme', 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 toggleTheme = () => {
const theme = getTheme(); const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark'; const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme); setTheme(newTheme);
saveTheme(newTheme);
}; };
const setTheme = themeStr => { const setTheme = (theme) => {
if (themeStr === 'dark') { if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)'); $('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light'); $('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary'); $('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default'); $('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark'); $('.ui-widget-content').addClass('dark');
} } else {
else {
$('html').css('background', ''); $('html').css('background', '');
$('body').removeClass('uk-light'); $('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary'); $('.uk-card').removeClass('uk-card-secondary');
@@ -32,12 +53,20 @@ const setTheme = themeStr => {
} }
}; };
const styleModal = () => { // do it before document is ready to prevent the initial flash of white on
const color = getTheme() === 'dark' ? '#222' : ''; // most pages
$('.uk-modal-header').css('background', color); setTheme();
$('.uk-modal-body').css('background', color);
$('.uk-modal-footer').css('background', color);
};
// do it before document is ready to prevent the initial flash of white $(() => {
setTheme(getTheme()); // 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');
});
}
});
+107 -22
View File
@@ -1,53 +1,76 @@
$(() => {
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) { function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath); const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle); const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle); const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function(){ $('#modal button, #modal a').each(function() {
$(this).removeAttr('hidden'); $(this).removeAttr('hidden');
}); });
if (percentage === 0) { if (percentage === 0) {
$('#continue-btn').attr('hidden', ''); $('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', ''); $('#unread-btn').attr('hidden', '');
} } else if (percentage === 100) {
else { $('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%'); $('#continue-btn').text('Continue from ' + percentage + '%');
} }
if (percentage === 100) {
$('#read-btn').attr('hidden', ''); $('#modal-entry-title').find('span').text(entry);
} $('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-title').find('span').text(entry); $('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().attr('data-id', titleID); $('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#modal-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`); $('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`); $('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function(){ $('#read-btn').click(function() {
updateProgress(titleID, entryID, pages); updateProgress(titleID, entryID, pages);
}); });
$('#unread-btn').click(function(){ $('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
styleModal();
} }
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({entry: eid}); const query = $.param({
entry: eid
});
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.post(url, (data) => { $.post(url, (data) => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} } else {
else {
error = data.error; error = data.error;
alert('danger', error); alert('danger', error);
} }
@@ -65,7 +88,9 @@ const renameSubmit = (name, eid) => {
return; return;
} }
const query = $.param({ entry: eid }); const query = $.param({
entry: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
@@ -98,8 +123,7 @@ const edit = (eid) => {
url = item.find('img').attr('data-src'); url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title'); displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', ''); $('#title-progress-control').attr('hidden', '');
} } else {
else {
$('#title-progress-control').removeAttr('hidden'); $('#title-progress-control').removeAttr('hidden');
} }
@@ -119,14 +143,15 @@ const edit = (eid) => {
setupUpload(eid); setupUpload(eid);
UIkit.modal($('#edit-modal')).show(); UIkit.modal($('#edit-modal')).show();
styleModal();
}; };
const setupUpload = (eid) => { const setupUpload = (eid) => {
const upload = $('.upload-field'); const upload = $('.upload-field');
const bar = $('#upload-progress').get(0); const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId}; const queryObj = {
title: titleId
};
if (eid) if (eid)
queryObj['entry'] = eid; queryObj['entry'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
@@ -157,3 +182,63 @@ const setupUpload = (eid) => {
} }
}); });
}; };
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();
});
};
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+17 -1
View File
@@ -6,7 +6,7 @@ shards:
archive: archive:
github: hkalexling/archive.cr github: hkalexling/archive.cr
version: 0.2.0 version: 0.4.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
@@ -20,18 +20,34 @@ shards:
github: crystal-lang/crystal-db github: crystal-lang/crystal-db
version: 0.9.0 version: 0.9.0
duktape:
github: jessedoyle/duktape.cr
version: 0.20.0
exception_page: exception_page:
github: crystal-loot/exception_page github: crystal-loot/exception_page
version: 0.1.4 version: 0.1.4
http_proxy:
github: mamantoha/http_proxy
version: 0.7.1
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: 0.26.1 version: 0.26.1
kemal-session:
github: kemalcr/kemal-session
version: 0.12.1
kilt: kilt:
github: jeromegn/kilt github: jeromegn/kilt
version: 0.4.0 version: 0.4.0
myhtml:
github: kostya/myhtml
version: 1.5.1
radix: radix:
github: luislavena/radix github: luislavena/radix
version: 0.3.9 version: 0.3.9
+10 -1
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.5.2 version: 0.11.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -15,6 +15,8 @@ license: MIT
dependencies: dependencies:
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
kemal-session:
github: kemalcr/kemal-session
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
@@ -25,3 +27,10 @@ dependencies:
github: crystal-ameba/ameba github: crystal-ameba/ameba
clim: clim:
github: at-grandpa/clim github: at-grandpa/clim
duktape:
github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml:
github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
-104
View File
@@ -1,104 +0,0 @@
require "./spec_helper"
include MangaDex
describe Queue do
it "creates DB at given path" do
with_queue do |_, path|
File.exists?(path).should be_true
end
end
it "pops nil when empty" do
with_queue do |queue|
queue.pop.should be_nil
end
end
it "inserts multiple jobs" do
with_queue do |queue|
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
Time.utc
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
Time.utc
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
Time.utc
j4 = Job.new "4", "4", "title", "manga_title",
JobStatus::Downloading, Time.utc
count = queue.push [j1, j2, j3, j4]
count.should eq 4
end
end
it "pops pending job" do
with_queue do |queue|
job = queue.pop
job.should_not be_nil
job.not_nil!.id.should eq "3"
end
end
it "correctly counts jobs" do
with_queue do |queue|
queue.count.should eq 4
end
end
it "deletes job" do
with_queue do |queue|
queue.delete "4"
queue.count.should eq 3
end
end
it "sets status" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.set_status JobStatus::Downloading, job
job = queue.pop
job.should_not be_nil
job.not_nil!.status.should eq JobStatus::Downloading
end
end
it "sets number of pages" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.set_pages 100, job
job = queue.pop
job.should_not be_nil
job.not_nil!.pages.should eq 100
end
end
it "adds fail/success counts" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.add_success job
queue.add_success job
queue.add_fail job
job = queue.pop
job.should_not be_nil
job.not_nil!.success_count.should eq 2
job.not_nil!.fail_count.should eq 1
end
end
it "appends status message" do
with_queue do |queue|
job = queue.pop.not_nil!
queue.add_message "hello", job
queue.add_message "world", job
job = queue.pop
job.should_not be_nil
job.not_nil!.status_message.should eq "\nhello\nworld"
end
end
it "cleans up" do
with_queue do
true
end
State.reset
end
end
+5
View File
@@ -68,4 +68,9 @@ describe Rule do
.should eq "Ch. CH ID testing" .should eq "Ch. CH ID testing"
rule.render({} of String => String).should eq "testing" rule.render({} of String => String).should eq "testing"
end end
it "escapes slash" do
rule = Rule.new "{id}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
end
end end
+2 -11
View File
@@ -1,6 +1,8 @@
require "spec" require "spec"
require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@@ -52,14 +54,3 @@ def with_storage
end end
end end
end end
def with_queue
with_default_config do
temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path
clear = yield queue, temp_queue_db.path
if clear == true
temp_queue_db.delete
end
end
end
+15 -5
View File
@@ -1,10 +1,10 @@
require "./spec_helper" require "./spec_helper"
describe "compare_alphanumerically" do describe "compare_numerically" do
it "sorts filenames with leading zeros correctly" do it "sorts filenames with leading zeros correctly" do
ary = ["010.jpg", "001.jpg", "002.png"] ary = ["010.jpg", "001.jpg", "002.png"]
ary.sort! { |a, b| ary.sort! { |a, b|
compare_alphanumerically a, b compare_numerically a, b
} }
ary.should eq ["001.jpg", "002.png", "010.jpg"] ary.should eq ["001.jpg", "002.png", "010.jpg"]
end end
@@ -12,7 +12,7 @@ describe "compare_alphanumerically" do
it "sorts filenames without leading zeros correctly" do it "sorts filenames without leading zeros correctly" do
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"] ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
ary.sort! { |a, b| ary.sort! { |a, b|
compare_alphanumerically a, b compare_numerically a, b
} }
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"] ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
end end
@@ -22,7 +22,7 @@ describe "compare_alphanumerically" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2", ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"] "text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort { |a, b| ary.reverse.sort { |a, b|
compare_alphanumerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
@@ -30,7 +30,17 @@ describe "compare_alphanumerically" do
it "handles numbers larger than Int32" do it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"] ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b| ary.reverse.sort { |a, b|
compare_alphanumerically a, b compare_numerically a, b
}.should eq ary }.should eq ary
end end
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
+9 -6
View File
@@ -3,8 +3,11 @@ require "yaml"
class Config class Config
include YAML::Serializable include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
property port : Int32 = 9000 property port : Int32 = 9000
property base_url : String = "/" property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library", property library_path : String = File.expand_path "~/mango/library",
home: true home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
@@ -13,6 +16,8 @@ class Config
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
property plugin_path : String = File.expand_path "~/mango/plugins",
home: true
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -43,16 +48,14 @@ class Config
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.preprocess config.preprocess
config.path = path
config.fill_defaults config.fill_defaults
return config return config
end end
puts "The config file #{cfg_path} does not exist." \ puts "The config file #{cfg_path} does not exist. " \
" Do you want mango to dump the default config there? [Y/n]" "Dumping the default config there."
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate default = self.allocate
default.path = path
default.fill_defaults default.fill_defaults
cfg_dir = File.dirname cfg_path cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir unless Dir.exists? cfg_dir
+73 -8
View File
@@ -1,27 +1,92 @@
require "kemal" require "kemal"
require "../storage" require "../storage"
require "../util" require "../util/*"
class AuthHandler < Kemal::Handler 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) def initialize(@storage : Storage)
end end
def call(env) def require_basic_auth(env)
return call_next(env) if request_path_startswith env, ["/login", "/logout"] env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
cookie = env.request.cookies.find do |c| env.response.print AUTH_MESSAGE
c.name == "token-#{Config.current.port}" call_next env
end end
if cookie.nil? || !@storage.verify_token cookie.value
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" return redirect env, "/login"
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value unless validate_token_admin env
env.response.status_code = 403 env.response.status_code = 403
end end
end end
call_next env call_next env
end end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end end
+2 -4
View File
@@ -1,6 +1,6 @@
require "baked_file_system" require "baked_file_system"
require "kemal" require "kemal"
require "../util" require "../util/*"
class FS class FS
extend BakedFileSystem extend BakedFileSystem
@@ -16,10 +16,8 @@ class FS
end end
class StaticHandler < Kemal::Handler class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env) def call(env)
if request_path_startswith env, @dirs if requesting_static_file env
file = FS.get? env.request.path file = FS.get? env.request.path
return call_next env if file.nil? return call_next env if file.nil?
+1 -1
View File
@@ -1,5 +1,5 @@
require "kemal" require "kemal"
require "../util" require "../util/*"
class UploadHandler < Kemal::Handler class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String) def initialize(@upload_dir : String)
-443
View File
@@ -1,443 +0,0 @@
require "mime"
require "json"
require "uri"
require "./util"
require "./archive"
struct Image
property data : Bytes
property mime : String
property filename : String
property size : Int32
def initialize(@data, @mime, @filename, @size)
end
end
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = ArchiveFile.new path
@pages = file.entries.count do |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
end
file.close
@id = storage.get_id @zip_path, false
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
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
url = "#{Config.current.base_url}api/page/#{@title_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)
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_alphanumerically 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
end
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
@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
unless File.readable? path
Logger.warn "File #{path} is not readable. Please make sure the " \
"file permission is configured correctly."
next
end
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
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_alphanumerically @library.title_hash[a].title,
@library.title_hash[b].title
end
@entries.sort! do |a, b|
compare_alphanumerically 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
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary
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"
if @entries.size > 0
url = @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|
save_progress username, e.title, 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|
save_progress username, e.title, 0
end
titles.each do |t|
t.unread_all username
end
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
TitleInfo.new @dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
else
info.progress[username][entry] = page
end
info.save
end
end
def load_progress(username, entry)
progress = 0
TitleInfo.new @dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end
end
progress
end
def load_percetage(username, entry)
page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry }
return 0.0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
return 0.0 if @entries.empty?
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
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
@[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
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
storage : Storage, title_hash : Hash(String, Title)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@storage = Storage.default
@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 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
(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
Logger.debug "Scan completed"
end
end
+182
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
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
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 }
.tap { |_| @title_ids.clear }
.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
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
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
+1 -6
View File
@@ -8,12 +8,7 @@ class Logger
@@severity : Log::Severity = :info @@severity : Log::Severity = :info
def self.default : self use_default
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize def initialize
level = Config.current.log_level level = Config.current.log_level
+34
View File
@@ -0,0 +1,34 @@
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
# This is a temporary workaround that forces the relevant code to run in the
# main fiber
class MainFiber
@@channel = Channel(-> Nil).new
@@done = Channel(Bool).new
@@main_fiber = Fiber.current
def self.start_and_block
loop do
if proc = @@channel.receive
begin
proc.call
ensure
@@done.send true
end
end
Fiber.yield
end
end
def self.run(&block : -> Nil)
if @@main_fiber == Fiber.current
block.call
else
@@channel.send block
until @@done.receive
Fiber.yield
end
end
end
end
+1 -7
View File
@@ -1,4 +1,3 @@
require "http/client"
require "json" require "json"
require "csv" require "csv"
require "../rename" require "../rename"
@@ -131,12 +130,7 @@ module MangaDex
end end
class API class API
def self.default : self use_default
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize def initialize
@base_url = Config.current.mangadex["api_url"].to_s || @base_url = Config.current.mangadex["api_url"].to_s ||
+30 -282
View File
@@ -1,5 +1,4 @@
require "./api" require "./api"
require "sqlite3"
require "zip" require "zip"
module MangaDex module MangaDex
@@ -14,297 +13,43 @@ module MangaDex
end end
end end
enum JobStatus class Downloader < Queue::Downloader
Pending # 0 @wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
Downloading # 1 .to_i32
Error # 2 @retries : Int32 = Config.current.mangadex["download_retries"].to_i32
Completed # 3
MissingPages # 4 use_default
def initialize
@api = API.default
super
end end
struct Job def pop : Queue::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
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
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)
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
end
end
end
class Queue
property downloader : Downloader?
@path : String
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
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
# Returns the earliest job in queue or nil if the job cannot be parsed.
# Returns nil if queue is empty
def pop
job = nil job = nil
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin begin
db.query_one "select * from queue where status = 0 " \ db.query_one "select * from queue where id not like '%-%' " \
"or status = 1 order by time limit 1" do |res| "and (status = 0 or status = 1) " \
job = Job.from_query_result res "order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end end
rescue rescue
end end
end end
end
job job
end end
# Push an array of jobs into the queue, and return the number of jobs private def download(job : Queue::Job)
# 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 pause
@downloader.not_nil!.stopped = true
end
def resume
@downloader.not_nil!.stopped = false
end
def paused?
@downloader.not_nil!.stopped
end
end
class Downloader
property stopped = false
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
@library_path : String = Config.current.library_path
@downloading = false
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@queue = Queue.default
@api = API.default
@queue.downloader = self
spawn do
loop do
sleep 1.second
next if @stopped || @downloading
begin
job = @queue.pop
next if job.nil?
download job
rescue e
Logger.error e
end
end
end
end
private def download(job : Job)
@downloading = true @downloading = true
@queue.set_status JobStatus::Downloading, job @queue.set_status Queue::JobStatus::Downloading, job
begin begin
chapter = @api.get_chapter(job.id) chapter = @api.get_chapter(job.id)
rescue e rescue e
Logger.error e Logger.error e
@queue.set_status JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@queue.add_message e.message.not_nil!, job @queue.add_message e.message.not_nil!, job
end end
@@ -319,7 +64,7 @@ module MangaDex
unless File.exists? manga_dir unless File.exists? manga_dir
Dir.mkdir_p manga_dir Dir.mkdir_p manga_dir
end end
zip_path = File.join manga_dir, "#{job.title}.cbz" zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages # Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1 len = Math.log10(chapter.pages.size).to_i + 1
@@ -369,17 +114,20 @@ module MangaDex
Logger.debug "Download completed. " \ Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed" "#{fail_count}/#{page_jobs.size} failed"
writer.close writer.close
Logger.debug "cbz File created at #{zip_path}" 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 zip_path zip_exception = validate_archive filename
if !zip_exception.nil? if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \ @queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job "Error: #{zip_exception}", job
@queue.set_status JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
elsif fail_count > 0 elsif fail_count > 0
@queue.set_status JobStatus::MissingPages, job @queue.set_status Queue::JobStatus::MissingPages, job
else else
@queue.set_status JobStatus::Completed, job @queue.set_status Queue::JobStatus::Completed, job
end end
@downloading = false @downloading = false
end end
+34 -6
View File
@@ -1,10 +1,29 @@
require "./config" require "./config"
require "./queue"
require "./server" require "./server"
require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*"
MANGO_VERSION = "0.5.2" MANGO_VERSION = "0.11.0"
# From http://www.network-science.de/ascii/
BANNER = %{
_| _|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
}
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
macro common_option macro common_option
option "-c PATH", "--config=PATH", type: String, option "-c PATH", "--config=PATH", type: String,
@@ -20,19 +39,28 @@ end
class CLI < Clim class CLI < Clim
main do main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" desc DESCRIPTION
usage "mango [sub_command] [options]" usage "mango [sub_command] [options]"
help short: "-h" help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v" version "Version #{MANGO_VERSION}", short: "-v"
common_option common_option
run do |opts| run do |opts|
Config.load(opts.config).set_current puts BANNER
MangaDex::Downloader.default puts DESCRIPTION
puts
# empty ARGV so it won't be passed to Kemal # empty ARGV so it won't be passed to Kemal
ARGV.clear ARGV.clear
server = Server.new
server.start Config.load(opts.config).set_current
MangaDex::Downloader.default
Plugin::Downloader.default
spawn do
Server.new.start
end
MainFiber.start_and_block
end end
sub "admin" do sub "admin" do
+133
View File
@@ -0,0 +1,133 @@
class Plugin
class Downloader < Queue::Downloader
use_default
def initialize
super
end
def pop : Queue::Job?
job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
end
end
job
end
private def 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
+342
View File
@@ -0,0 +1,342 @@
require "duktape/runtime"
require "myhtml"
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
+303
View File
@@ -0,0 +1,303 @@
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
MainFiber.run do
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
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
MainFiber.run do
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
end
self.count - start_count
end
def reset(id : String)
MainFiber.run do
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
end
def reset(job : Job)
self.reset job.id
end
# Reset all failed tasks (missing pages and error)
def reset
MainFiber.run do
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
end
def delete(id : String)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end
end
def delete(job : Job)
self.delete job.id
end
def delete_status(status : JobStatus)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
end
end
end
def count_status(status : JobStatus)
num = 0
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
end
end
num
end
def count
num = 0
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
end
end
num
end
def set_status(status : JobStatus, job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
end
end
end
def get_all
jobs = [] of Job
MainFiber.run do
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
end
jobs
end
def add_success(job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
end
end
end
def add_fail(job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
end
end
end
def set_pages(pages : Int32, job : Job)
MainFiber.run do
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
end
def add_message(msg : String, job : Job)
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
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
+7 -1
View File
@@ -129,13 +129,19 @@ module Rename
end end
def render(hash : VHash) def render(hash : VHash)
@ary.map do |e| str = @ary.map do |e|
if e.is_a? String if e.is_a? String
e e
else else
e.render hash e.render hash
end end
end.join.strip end.join.strip
post_process str
end
private def post_process(str)
return "_" if str == ".."
str.gsub "/", "_"
end end
end end
end end
+78 -5
View File
@@ -80,7 +80,7 @@ class APIRouter < Router
if !entry_id.nil? if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil! entry = title.get_entry(entry_id).not_nil!
raise "incorrect page value" if page < 0 || page > entry.pages raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page entry.save_progress username, page
elsif page == 0 elsif page == 0
title.unread_all username title.unread_all username
else else
@@ -97,6 +97,28 @@ class APIRouter < Router
end end
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| post "/api/admin/display_name/:title/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"])
@@ -136,12 +158,12 @@ class APIRouter < Router
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter| jobs = chapters.map { |chapter|
MangaDex::Job.new( Queue::Job.new(
chapter["id"].as_s, chapter["id"].as_s,
chapter["manga_id"].as_s, chapter["manga_id"].as_s,
chapter["full_title"].as_s, chapter["full_title"].as_s,
chapter["manga_title"].as_s, chapter["manga_title"].as_s,
MangaDex::JobStatus::Pending, Queue::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i Time.unix chapter["time"].as_s.to_i
) )
} }
@@ -179,7 +201,7 @@ class APIRouter < Router
case action case action
when "delete" when "delete"
if id.nil? if id.nil?
@context.queue.delete_status MangaDex::JobStatus::Completed @context.queue.delete_status Queue::JobStatus::Completed
else else
@context.queue.delete id @context.queue.delete id
end end
@@ -224,7 +246,7 @@ class APIRouter < Router
entry_id = env.params.query["entry"]? entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil! title = @context.library.get_title(title_id).not_nil!
unless ["image/jpeg", "image/png"].includes? \ unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG" raise "The uploaded image must be either JPEG or PNG"
end end
@@ -259,5 +281,56 @@ class APIRouter < Router
}.to_json }.to_json
end end
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
end end
+64 -17
View File
@@ -4,15 +4,12 @@ class MainRouter < Router
def initialize def initialize
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url base_url = Config.current.base_url
render "src/views/login.ecr" render "src/views/login.html.ecr"
end end
get "/logout" do |env| get "/logout" do |env|
begin begin
cookie = env.request.cookies.find do |c| env.session.delete_string "token"
c.name == "token-#{Config.current.port}"
end.not_nil!
@context.storage.logout cookie.value
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" @context.error "Error when attempting to log out: #{e}"
ensure ensure
@@ -26,22 +23,31 @@ class MainRouter < Router
password = env.params.body["password"] password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil! token = @context.storage.verify_user(username, password).not_nil!
cookie = HTTP::Cookie.new "token-#{Config.current.port}", token env.session.string "token", token
cookie.path = Config.current.base_url
cookie.expires = Time.local.shift years: 1 callback = env.session.string? "callback"
env.response.cookies << cookie if callback
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/" redirect env, "/"
end
rescue rescue
redirect env, "/login" redirect env, "/login"
end end
end end
get "/" do |env| get "/library" do |env|
begin begin
titles = @context.library.titles
username = get_username env username = get_username env
percentage = titles.map &.load_percetage username
layout "index" 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 rescue e
@context.error e @context.error e
env.response.status_code = 500 env.response.status_code = 500
@@ -52,13 +58,18 @@ class MainRouter < Router
begin begin
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env username = get_username env
percentage = title.entries.map { |e|
title.load_percetage username, e.title 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" layout "title"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 500
end end
end end
@@ -66,5 +77,41 @@ class MainRouter < Router
mangadex_base_url = Config.current.mangadex["base_url"] mangadex_base_url = Config.current.mangadex["base_url"]
layout "download" layout "download"
end 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
end end
+32
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
+11 -5
View File
@@ -4,17 +4,23 @@ class ReaderRouter < Router
def initialize def initialize
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg
# load progress # load progress
username = get_username env page = entry.load_progress username
page = title.load_progress username, entry.title
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll # we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user # library perloads a few pages in advance, and the user
# might not have actually read them # might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max 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}" redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
rescue e rescue e
@context.error e @context.error e
@@ -33,7 +39,7 @@ class ReaderRouter < Router
# save progress # save progress
username = get_username env username = get_username env
title.save_progress username, entry.title, page entry.save_progress username, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx| urls = pages.map { |idx|
@@ -45,7 +51,7 @@ class ReaderRouter < Router
next_page = page + IMGS_PER_PAGE next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
next_entry = title.next_entry entry next_entry = entry.next_entry username
unless next_page > entry.pages unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}" next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end end
@@ -53,7 +59,7 @@ class ReaderRouter < Router
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end end
render "src/views/reader.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
+14 -10
View File
@@ -1,25 +1,21 @@
require "kemal" require "kemal"
require "./library" require "kemal-session"
require "./library/*"
require "./handlers/*" require "./handlers/*"
require "./util" require "./util/*"
require "./routes/*" require "./routes/*"
class Context class Context
property library : Library property library : Library
property storage : Storage property storage : Storage
property queue : MangaDex::Queue property queue : Queue
def self.default : self use_default
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize def initialize
@storage = Storage.default @storage = Storage.default
@library = Library.default @library = Library.default
@queue = MangaDex::Queue.default @queue = Queue.default
end end
{% for lvl in Logger::LEVELS %} {% for lvl in Logger::LEVELS %}
@@ -53,6 +49,7 @@ class Server
AdminRouter.new AdminRouter.new
ReaderRouter.new ReaderRouter.new
APIRouter.new APIRouter.new
OPDSRouter.new
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
@@ -64,6 +61,13 @@ class Server
serve_static false serve_static false
add_handler StaticHandler.new add_handler StaticHandler.new
{% end %} {% 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 end
def start def start
+87 -26
View File
@@ -2,7 +2,7 @@ require "sqlite3"
require "crypto/bcrypt" require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util" require "./util/*"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -14,15 +14,17 @@ end
class Storage class Storage
@path : String @path : String
@db : DB::Database?
@insert_ids = [] of IDTuple
def self.default : self alias IDTuple = NamedTuple(path: String,
unless @@default id: String,
@@default = new is_title: Bool)
end
@@default.not_nil!
end
def initialize(db_path : String? = nil, init_user = true) use_default
def initialize(db_path : String? = nil, init_user = true, *,
@auto_close = true)
@path = db_path || Config.current.db_path @path = db_path || Config.current.db_path
dir = File.dirname @path dir = File.dirname @path
unless Dir.exists? dir unless Dir.exists? dir
@@ -30,6 +32,7 @@ class Storage
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
# We create the `ids` table first. even if the uses has an # We create the `ids` table first. even if the uses has an
@@ -60,6 +63,10 @@ class Storage
init_admin if init_user init_admin if init_user
end end
end end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
end
end
end end
macro init_admin macro init_admin
@@ -71,33 +78,50 @@ class Storage
"#{{"username" => "admin", "password" => random_pw}}" "#{{"username" => "admin", "password" => random_pw}}"
end end
def verify_user(username, password) private def get_db(&block : DB::Database ->)
if @db.nil?
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
yield db
end
else
yield @db.not_nil!
end
end
def verify_user(username, password)
out_token = nil
MainFiber.run do
get_db do |db|
begin begin
hash, token = db.query_one "select password, token from " \ hash, token = db.query_one "select password, token from " \
"users where username = (?)", "users where username = (?)",
username, as: {String, String?} username, as: {String, String?}
unless verify_password hash, password unless verify_password hash, password
Logger.debug "Password does not match the hash" Logger.debug "Password does not match the hash"
return nil next
end end
Logger.debug "User #{username} verified" Logger.debug "User #{username} verified"
return token if token if token
out_token = token
next
end
token = random_str token = random_str
Logger.debug "Updating token for #{username}" Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)", db.exec "update users set token = (?) where username = (?)",
token, username token, username
return token out_token = token
rescue e rescue e
Logger.error "Error when verifying user #{username}: #{e}" Logger.error "Error when verifying user #{username}: #{e}"
return nil
end end
end end
end end
out_token
end
def verify_token(token) def verify_token(token)
username = nil username = nil
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
begin begin
username = db.query_one "select username from users where " \ username = db.query_one "select username from users where " \
"token = (?)", token, as: String "token = (?)", token, as: String
@@ -105,12 +129,14 @@ class Storage
Logger.debug "Unable to verify token" Logger.debug "Unable to verify token"
end end
end end
end
username username
end end
def verify_admin(token) def verify_admin(token)
is_admin = false is_admin = false
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
begin begin
is_admin = db.query_one "select admin from users where " \ is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool "token = (?)", token, as: Bool
@@ -118,18 +144,21 @@ class Storage
Logger.debug "Unable to verify user as admin" Logger.debug "Unable to verify user as admin"
end end
end end
end
is_admin is_admin
end end
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
db.query "select username, admin from users" do |rs| db.query "select username, admin from users" do |rs|
rs.each do rs.each do
results << {rs.read(String), rs.read(Bool)} results << {rs.read(String), rs.read(Bool)}
end end
end end
end end
end
results results
end end
@@ -137,18 +166,21 @@ class Storage
validate_username username validate_username username
validate_password password validate_password password
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
hash = hash_password password hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin username, hash, nil, admin
end end
end end
end
def update_user(original_username, username, password, admin) def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
validate_username username validate_username username
validate_password password unless password.empty? validate_password password unless password.empty?
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
if password.empty? if password.empty?
db.exec "update users set username = (?), admin = (?) " \ db.exec "update users set username = (?), admin = (?) " \
"where username = (?)", "where username = (?)",
@@ -161,35 +193,64 @@ class Storage
end end
end end
end end
end
def delete_user(username) def delete_user(username)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
db.exec "delete from users where username = (?)", username db.exec "delete from users where username = (?)", username
end end
end end
end
def logout(token) def logout(token)
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
get_db do |db|
begin begin
db.exec "update users set token = (?) where token = (?)", nil, token db.exec "update users set token = (?) where token = (?)", nil, token
rescue rescue
end end
end end
end end
end
def get_id(path, is_title) def get_id(path, is_title)
id = random_str id = nil
DB.open "sqlite3://#{@path}" do |db| MainFiber.run do
begin get_db do |db|
id = db.query_one "select id from ids where path = (?)", path, id = db.query_one? "select id from ids where path = (?)", path,
as: {String} as: {String}
rescue
db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
end end
end end
id id
end end
def insert_id(tp : IDTuple)
@insert_ids << tp
end
def bulk_insert_ids
MainFiber.run do
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
end
def close
MainFiber.run do
unless @db.nil?
@db.not_nil!.close
end
end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.string self json.string self
end end
+1 -1
View File
@@ -1,4 +1,4 @@
require "./util" require "./util/*"
class Upload class Upload
def initialize(@dir : String) def initialize(@dir : String)
-127
View File
@@ -1,127 +0,0 @@
require "big"
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
macro layout(name)
base_url = Config.current.base_url
begin
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
end
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.ecr", "src/views/layout.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
cookie = {{env}}.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
end
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print 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
false
end
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_alphanumerically(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_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end
def validate_archive(path : String) : Exception?
file = ArchiveFile.new path
file.check
file.close
return
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
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
+112
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
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
+42
View File
@@ -0,0 +1,42 @@
require "http_proxy"
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
# environment variables
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Using monkey-patched HTTP::Client"
previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri
yield client, path
end
end
end
end
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
return if no_proxy &&
no_proxy.split(",").any? &.== uri.hostname
case uri.scheme
when "http"
env_to_proxy "http_proxy"
when "https"
env_to_proxy "https_proxy"
else
nil
end
end
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
val = ENV[key.downcase]? || ENV[key.upcase]?
return if val.nil?
begin
uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!
rescue
nil
end
end
+63
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
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
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
@@ -7,12 +7,20 @@
<span hidden></span> <span hidden></span>
</span> </span>
</li> </li>
<li data-url="<%= base_url %>admin/downloads">Download Manager</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> </ul>
<hr class="uk-divider-icon"> <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> <a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %> <% content_for "script" do %>
<script src="<%= base_url %>js/admin.js"></script> <script src="<%= base_url %>js/admin.js"></script>
<% end %> <% end %>
+86
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>
+32
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>
+15
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>
+14
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>
@@ -17,16 +17,17 @@
<th>Progress</th> <th>Progress</th>
<th>Time</th> <th>Time</th>
<th>Status</th> <th>Status</th>
<th>Plugin</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
</table> </table>
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
<% end %> <% end %>
@@ -73,11 +73,11 @@
</table> </table>
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, ""); var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script> <script src="<%= base_url %>js/download.js"></script>
<% end %> <% end %>
+84
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 %>
-49
View File
@@ -1,49 +0,0 @@
<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">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<%- end -%>
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- 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 %>
-79
View File
@@ -1,79 +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 and Web Reader">
<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="<%= 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="<%= base_url %>js/theme.js"></script>
</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="<%= base_url %>">Home</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></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>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></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(getTheme());
const base_url = "<%= base_url %>";
</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>
+89
View File
@@ -0,0 +1,89 @@
<!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" id="main-section">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div>
</div>
</div>
<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>
+31
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 %>
-46
View File
@@ -1,46 +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" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script>
</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(getTheme());
</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>
</body>
</html>
+36
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>
-1
View File
@@ -1 +0,0 @@
<p class="uk-text-lead uk-text-center"><%= message %></p>
+1
View File
@@ -0,0 +1 @@
<p class="uk-text-lead uk-text-center"><%= message %></p>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:index</id>
<link rel="self" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title>Library</title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
</feed>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:<%= title.id %></id>
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title><%= HTML.escape(title.display_name) %></title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% title.titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
<% title.entries.each do |e| %>
<entry>
<title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id>
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
</entry>
<% end %>
</feed>
+75
View File
@@ -0,0 +1,75 @@
<% if plugins.empty? %>
<div class="uk-container uk-text-center">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<% else %>
<h2 class=uk-title>Download with Plugins</h2>
<div id="controls" class="uk-grid-small" uk-grid hidden>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls">
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
</div>
</div>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
<div class="uk-form-controls">
<select id="plugin-select" class="uk-select">
<% plugins.each do |p| %>
<option value="<%= p[:id] %>"><%= p[:title] %></option>
<% end %>
</select>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</div>
</div>
</div>
</div>
<dl class="uk-description-list" id="toggle" hidden>
<% plugin.not_nil!.info.each do |k, v| %>
<dt><%= k %></dt>
<dd><%= v.to_s %></dd>
<% end %>
</dl>
<div id="table" class="uk-margin-large-top" hidden>
<h3 id="title-text"></h3>
<div class="uk-margin">
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
</table>
</div>
<% end %>
<% content_for "script" do %>
<% if plugin %>
<script>
var pid = "<%= plugin.not_nil!.info.id %>";
</script>
<% end %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script>
<% end %>
+31
View File
@@ -0,0 +1,31 @@
<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 uk-margin-remove-top">Error</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div>
<div class="uk-modal-body">
<p uk-margin>
<% if next_entry = entry.next_entry username %>
<a class="uk-button uk-button-default" href="<%= base_url %>reader/<%= entry.book.id %>/<%= next_entry.id %>">Next Entry</a>
<% end %>
<a class="uk-button uk-button-primary" href="<%= base_url %>book/<%= entry.book.id %>">Return to Title</a>
</p>
</div>
</div>
</div>
<% content_for "script" do %>
<script>
UIkit.modal('#modal').show().then(function() {
styleModal();
});
UIkit.util.on('#modal', 'hide', function() {
location.href = "<%= base_url %>book/<%= entry.book.id %>";
});
</script>
<% end %>
-70
View File
@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html class="reader-bg">
<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="<%= base_url %>css/mango.css" />
</head>
<body>
<script src="<%= base_url %>js/theme.js"></script>
<div class="uk-section uk-section-default uk-section-small reader-bg">
<div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%>
<img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
<%- end -%>
<%- if next_url -%>
<a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
</div>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div id="hidden" hidden></div>
<div id="modal-sections" 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">
<h3 class="uk-modal-title">Options</h3>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<p id="progress-label"></p>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
<div class="uk-form-controls">
<select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
</select>
</div>
</div>
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
</div>
</div>
</div>
<script>
const base_url = "<%= base_url %>"
</script>
<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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html class="reader-bg">
<%= render_component "head" %>
<body>
<div class="uk-section uk-section-default uk-section-small reader-bg">
<div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%>
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
<%- end -%>
<%- if next_url -%>
<a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
</div>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div id="hidden" hidden></div>
<div id="modal-sections" 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">
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<p id="progress-label"></p>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
<div class="uk-form-controls">
<select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
</select>
</div>
</div>
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
</div>
</div>
</div>
<script>
const base_url = "<%= base_url %>"
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script>
</body>
</html>
-159
View File
@@ -1,159 +0,0 @@
<div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
</div>
<ul class="uk-breadcrumb">
<li><a href="<%= base_url %>">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries 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">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="auto-up">â–˛ Auto</option>
<option id="auto-down">â–Ľ Auto</option>
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- title.titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
<a class="acard">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top">
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<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" id="modal-title"><span></span>
&nbsp;
<% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</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>
<div id="edit-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">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-width-1-2@s">
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="image/jpeg, image/png">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical">
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p>
</div>
</div>
</div>
</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/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>
+126
View File
@@ -0,0 +1,126 @@
<div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid>
<div>
<p x-text="count + ' items selected'" style="color:orange"></p>
</div>
<div class="uk-text-center" id="select-bar-controls">
<a class="uk-icon uk-margin-right" uk-tooltip="title: Mark selected as read" href="" @click.prevent="bulkProgress('read', $el)">
<i class="fas fa-check-circle"></i>
</a>
<a class="uk-icon" uk-tooltip="title: Mark selected as unread" href="" @click.prevent="bulkProgress('unread', $el)">
<i class="fas fa-times-circle"></i>
</a>
</div>
<div class="uk-text-right">
<a @click="selectAll()" uk-tooltip="title: Select all"><i class="fas fa-check-double uk-margin-small-right"></i></a>
<a @click="deselectAll();" uk-tooltip="title: Deselect all"><i class="fas fa-times"></i></a>
</div>
</div>
</div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
</div>
<ul class="uk-breadcrumb">
<li><a href="<%= base_url %>library">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries 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",
"title" => "Name",
"time_modified" => "Date Modified",
"time_added" => "Date Added",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<%= render_component "entry-modal" %>
<div id="edit-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">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-width-1-2@s">
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="<%= SUPPORTED_IMG_TYPES.join ", " %>">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical">
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p>
</div>
</div>
</div>
</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/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>
-46
View File
@@ -1,46 +0,0 @@
<form action="<%= base_url %>admin/user/edit" method="post" accept-charset="utf-8">
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Username</label>
<input class="uk-input" type="text" name="username" <%- if username -%> value=<%= username %> <%- end -%>>
</div>
<%- if new_user -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Password</label>
<input class="uk-input" type="password" name="password">
</div>
<%- end -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
<input class="uk-checkbox" type="checkbox" name="admin" <%- if admin == true -%> checked <%- end -%>>
</div>
<%- if !new_user -%>
<div>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #change-password">Change Password</button>
<div id="change-password" class="uk-margin" hidden>
<label class="uk-form-label" for="form-stacked-text">New Password</label>
<input class="uk-input" type="password" name="password">
</div>
</div>
<%- end -%>
<hr class="uk-divider-icon">
<input type="submit" value="Save" class="uk-button uk-button-primary">
</form>
<% content_for "script" do %>
<script>
var username;
var error;
<%- if !new_user -%>
username = '/<%= username %>';
<%- end -%>
<%- if error -%>
error = '<%= error %>';
<%- end -%>
</script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user-edit.js"></script>
<% end %>
+46
View File
@@ -0,0 +1,46 @@
<form action="<%= base_url %>admin/user/edit" method="post" accept-charset="utf-8">
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Username</label>
<input class="uk-input" type="text" name="username" <%- if username -%> value=<%= username %> <%- end -%>>
</div>
<%- if new_user -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Password</label>
<input class="uk-input" type="password" name="password">
</div>
<%- end -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
<input class="uk-checkbox" type="checkbox" name="admin" <%- if admin == true -%> checked <%- end -%>>
</div>
<%- unless new_user -%>
<div>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #change-password">Change Password</button>
<div id="change-password" class="uk-margin" hidden>
<label class="uk-form-label" for="form-stacked-text">New Password</label>
<input class="uk-input" type="password" name="password">
</div>
</div>
<%- end -%>
<hr class="uk-divider-icon">
<input type="submit" value="Save" class="uk-button uk-button-primary">
</form>
<% content_for "script" do %>
<script>
var username;
var error;
<%- if !new_user -%>
username = '/<%= username %>';
<%- end -%>
<%- if error -%>
error = '<%= error %>';
<%- end -%>
</script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user-edit.js"></script>
<% end %>
-31
View File
@@ -1,31 +0,0 @@
<table class="uk-table uk-table-divider">
<thead>
<tr>
<th>Username</th>
<th>Admin Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%- users.each do |u| -%>
<tr>
<td><%= u[0] %></td>
<td><%= u[1] %></td>
<td>
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
<%- if u[0] != username %>
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
<%- end %>
</td>
</tr>
<%- end -%>
</tbody>
</table>
<a href="<%= base_url %>admin/user/edit" class="uk-button uk-button-primary">New User</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user.js"></script>
<% end %>
+31
View File
@@ -0,0 +1,31 @@
<table class="uk-table uk-table-divider">
<thead>
<tr>
<th>Username</th>
<th>Admin Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%- users.each do |u| -%>
<tr>
<td><%= u[0] %></td>
<td><%= u[1] %></td>
<td>
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
<%- if u[0] != username %>
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
<%- end %>
</td>
</tr>
<%- end -%>
</tbody>
</table>
<a href="<%= base_url %>admin/user/edit" class="uk-button uk-button-primary">New User</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user.js"></script>
<% end %>