Compare commits

..

446 Commits

Author SHA1 Message Date
Alex Ling 0b457a2797 Merge branch 'master' of https://github.com/hkalexling/Mango 2021-01-11 15:37:34 +00:00
Alex Ling 653751bede Merge branch 'dev' 2021-01-11 15:37:06 +00:00
Alex Ling a02bf4a81e Bump version to v0.18.2 2021-01-11 15:22:51 +00:00
Alex Ling 5271d12f4c Respect base URL in WS connections 2021-01-11 15:05:58 +00:00
Alex Ling c2e2f0b9b3 Merge pull request #143 from hkalexling/all-contributors/add-h45h74x
docs: add h45h74x as a contributor
2021-01-11 19:32:47 +08:00
allcontributors[bot] 72d319902e docs: update .all-contributorsrc [skip ci] 2021-01-11 11:31:21 +00:00
allcontributors[bot] bbd0fd68cb docs: update README.md [skip ci] 2021-01-11 11:31:20 +00:00
Alex Ling 0fb1e1598d Remove sourcerer.io HoF and use all-contributors
[skip ci]
RIP sourcerer.io https://github.com/sourcerer-io/sourcerer-app/issues/632
2021-01-11 11:28:30 +00:00
Alex Ling 4645582f5d Bump version to v0.18.1 2021-01-11 05:29:28 +00:00
Alex Ling ac9c51dd33 Remove non-existing #root from css selectors (#142) 2021-01-11 05:28:44 +00:00
Alex Ling f51d27860a Validate input index before flipping page 2021-01-09 15:49:34 +00:00
Alex Ling 4a7439a1ea Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-09 06:40:49 +00:00
Alex Ling 00e19399d7 Check login is disabled before accessing default username 2021-01-09 06:35:26 +00:00
Alex Ling cb723acef7 Update config in README 2021-01-09 06:35:11 +00:00
Alex Ling 794bed12bd Merge pull request #139 from h45h74x/feature/plugin-helper-function-post
Added post helper function
2021-01-09 14:30:52 +08:00
Simon bae8220e75 Added post helper function 2021-01-08 21:17:58 +01:00
Alex Ling 0cc5e1626b Fix broken buttons on download manager page 2021-01-08 11:38:51 +00:00
Alex Ling da0ca665a6 Mark entry as read when exiting reader at the end 2021-01-08 11:38:25 +00:00
Alex Ling a91cf21aa9 Bump version to v0.18.0 2021-01-07 16:27:22 +00:00
Alex Ling 39b2636711 Sort tags in title 2021-01-07 16:21:23 +00:00
Alex Ling 2618d8412b Update the API doc to include margin in dimensions 2021-01-07 16:06:43 +00:00
Alex Ling 445ebdf357 Merge pull request #136 from h45h74x/feature/adjustable-page-gaps
Feature/adjustable page gaps
2021-01-07 01:11:34 +08:00
Simon 60134dc364 Formatting 2021-01-06 17:44:02 +01:00
Simon aa70752244 Moved margin value to the dimensions API 2021-01-06 17:30:55 +01:00
Simon 0f39535097 Added new entry in example config 2021-01-06 15:28:09 +01:00
Simon e086bec9da Added adjustable page gaps via config 2021-01-06 15:27:48 +01:00
Alex Ling dcdcf29114 Sort tags on the tags page 2021-01-05 07:34:31 +00:00
Alex Ling c5c73ddff3 Rewrite download-manager.js 2021-01-01 09:19:16 +00:00
Alex Ling f18ee4284f Rewrite admin.js with Alpine component 2021-01-01 09:04:53 +00:00
Alex Ling 0fbc11386e Fix broken "Exit Reader" button 2021-01-01 09:04:18 +00:00
Alex Ling a68282b4bf Rewrite reader.js with a reusable alpine function 2020-12-31 16:21:00 +00:00
Alex Ling e64908ad06 Remove the outdated styleModal call 2020-12-31 14:08:14 +00:00
Alex Ling af0913df64 Dynamic HTML title 2020-12-31 14:08:14 +00:00
Alex Ling 5685dd1cc5 Use tallboy to draw CLI table 2020-12-30 16:44:23 +00:00
Alex Ling af2fd2a66a Remove the Context and Router classes 2020-12-30 15:58:51 +00:00
Alex Ling db2a51a26b Clean up library classes 2020-12-30 15:23:38 +00:00
Alex Ling cf930418cb Update rename spec 2020-12-30 12:53:48 +00:00
Alex Ling 911848ad11 Merge branch 'feature/tagging' into dev 2020-12-30 11:15:44 +00:00
Alex Ling 93f745aecb Only admins can add or delete tags 2020-12-30 11:13:43 +00:00
Alex Ling 981a1f0226 Add /tags to nav bar 2020-12-30 11:13:43 +00:00
Alex Ling 8188456788 Finish tagging 2020-12-30 11:13:43 +00:00
Alex Ling 1eace2c64c Add the /tags/:tag page 2020-12-30 11:13:43 +00:00
Alex Ling c6ee5409f8 Trim input tag 2020-12-30 11:13:43 +00:00
Alex Ling b05ed57762 Add API endpoints for tags 2020-12-30 11:13:43 +00:00
Alex Ling 0f1d1099f6 Add unique constraint to tags and error handling 2020-12-30 11:13:43 +00:00
Alex Ling 40a24f4247 Add tags to the web UI 2020-12-30 11:13:43 +00:00
Alex Ling a6862e86d4 Update alpine 2020-12-30 11:13:43 +00:00
Alex Ling bfc1b697bd Add tag related methods for Title 2020-12-30 11:13:43 +00:00
Alex Ling 276f62cb76 Update DB for tags 2020-12-30 11:13:43 +00:00
Alex Ling 45a81ad5f6 Display the entries and sub-titles count 2020-12-30 11:13:43 +00:00
Alex Ling ce88acb9e5 Simplify the request_path_startswith helper method 2020-12-30 11:13:43 +00:00
Alex Ling bd34b803f1 Tokens take precedence over default user setting 2020-12-30 11:13:43 +00:00
Alex Ling 2559f65f35 Display the entries and sub-titles count 2020-12-29 04:33:55 +00:00
Alex Ling 93c21ea659 Simplify the request_path_startswith helper method 2020-12-28 16:29:29 +00:00
Alex Ling 85ad38c321 Allow disable login 2020-12-28 16:13:51 +00:00
Alex Ling b6a204f5bd Escape illegal filename characters in Windows 2020-12-28 15:20:09 +00:00
Alex Ling f7b8e2d852 Bump version to v0.17.1 2020-12-27 09:46:14 +00:00
Alex Ling 946017c8bd Fix function redeclaration 2020-12-27 09:42:06 +00:00
Alex Ling ec5256dabd Improve batch mark UX (#97) 2020-12-27 09:42:06 +00:00
Alex Ling 4e707076a1 By default use the system theme setting (#111) 2020-12-27 09:42:06 +00:00
Alex Ling 66a3cc268b Merge branch 'master' into dev 2020-12-26 09:34:23 +00:00
Alex Ling 96949905b9 Cache entry display names
This improves the title page load time (#116)
2020-12-26 09:32:03 +00:00
Alex Ling 30c0199039 Merge branch 'dev' 2020-12-26 07:33:04 +00:00
Alex Ling 7a7cb78f82 Check bool environment variables are "1" or "true" 2020-12-26 07:11:10 +00:00
Alex Ling 8931ba8c43 Bump version to 0.17.0 2020-12-26 04:02:58 +00:00
Alex Ling d50981c151 Fix typos 2020-12-26 04:01:57 +00:00
Alex Ling df4deb1415 Allow proxy with authentication 2020-12-22 02:32:07 +00:00
Alex Ling aa5e999ed4 Allow users to disable SSL verification 2020-12-21 06:04:26 +00:00
Alex Ling 84d4b0c529 Switch to redoc and update API description 2020-12-21 06:04:26 +00:00
Alex Ling d3e5691478 Add overall description of the API 2020-12-14 15:20:50 +00:00
Alex Ling 1000b02ae0 Exclude /src/routes/api.cr from line width check 2020-12-14 14:59:18 +00:00
Alex Ling 1f795889a9 Move the entry download route to API 2020-12-14 13:03:23 +00:00
Alex Ling d33b45233a Use the correct verbs in the API 2020-12-14 12:49:56 +00:00
Alex Ling 4f6df5b9a3 Rename bulk-progress to bulk_progress 2020-12-14 11:54:25 +00:00
Alex Ling 341b586cb3 Add API documentation 2020-12-14 11:09:38 +00:00
Alex Ling 9dcc9665ce Cancel a download job when deleted from web UI 2020-12-12 16:15:16 +00:00
Alex Ling 1cd90926df Bind boolean attributes 2020-12-11 10:22:08 +00:00
Alex Ling ac1ff61e6d Move theme.js to common.js
This reduces the number of JS files to include when loading
2020-12-11 10:11:39 +00:00
Alex Ling 6ea41f79e9 Simplify the showControl calls on reader page 2020-12-11 09:47:32 +00:00
Alex Ling dad02a2a30 Move getProp and setProp to common.js 2020-12-11 09:46:56 +00:00
Alex Ling 280490fb36 Rewrite the download manager page 2020-12-11 07:46:47 +00:00
Alex Ling 455315a362 Upgrade to Crystal 0.35.1 2020-12-11 07:46:47 +00:00
Alex Ling df51406638 Use $GITHUB_ENV [skip ci] 2020-11-24 13:43:54 +08:00
Alex Ling 531d42ef18 [skip ci] enable set-env
https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
2020-11-24 13:38:15 +08:00
Alex Ling 2645e8cd05 Merge branch 'dev' 2020-11-24 05:31:06 +00:00
Alex Ling b2dc44a919 Reverse J and K for page navigation 2020-11-24 05:09:06 +00:00
Alex Ling c8db397a3b Bump version to v0.16.0 2020-11-24 04:30:47 +00:00
Alex Ling 6384d4b77a Log "DB optimization finished" in the info level 2020-11-24 04:05:07 +00:00
Alex Ling 1039732d87 Log the full file path in error messages (#123) 2020-11-24 04:03:53 +00:00
Alex Ling 011123f690 Allow keyboard navigation on reader page (#124) 2020-11-24 03:57:38 +00:00
Alex Ling e602a35b0c Merge branch 'dev' 2020-11-02 16:32:08 +00:00
Alex Ling 7792d3426e Bump version to v0.15.1 2020-11-01 09:22:05 +00:00
Alex Ling b59c8f85ad Fix scroller issues in continuous reader (#121) 2020-10-31 04:29:46 +00:00
Alex Ling 18834ac28e Set thumbnail size and mimetype 2020-10-29 04:06:44 +00:00
Alex Ling bf68e32ac8 Merge branch 'dev' 2020-10-25 07:57:26 +00:00
Alex Ling 54eb041fe4 Update README 2020-10-25 07:29:19 +00:00
Alex Ling 57d8c100f9 Bump version to v0.15.0 2020-10-25 07:22:38 +00:00
Alex Ling 56d973b99d Get progress when page loads and when post 2020-10-25 07:21:08 +00:00
Alex Ling 670e5cdf6a Better logging when optimizing DB 2020-10-25 07:09:37 +00:00
Alex Ling 1b35392f9c Remove unnecessary properties 2020-10-25 07:09:21 +00:00
Alex Ling c4e1ffe023 Trigger thumbnail generation from the admin page 2020-10-25 05:41:27 +00:00
Alex Ling 44f4959477 Finish thumbnail generation and DB optimization
(#93)
2020-10-24 04:13:11 +00:00
Alex Ling 0582b57d60 Add config options for optimization tasks 2020-10-24 03:50:26 +00:00
Alex Ling 83d96fd2a1 Add the route to serve thumbnails 2020-10-23 12:30:47 +00:00
Alex Ling 8ac89c420c Add helper methods for thumbnail generation 2020-10-23 12:30:29 +00:00
Alex Ling 968c2f4ad5 Update DB to save thumbnails 2020-10-23 12:29:20 +00:00
Alex Ling ad940f30d5 Update image_size.cr to 0.4.0 for better err msg 2020-10-23 12:21:05 +00:00
Alex Ling 308ad4e063 Only truncate visible titles to improve load time 2020-10-20 14:36:56 +00:00
Alex Ling 4d709b7eb5 Update default config in README 2020-10-18 12:53:43 +00:00
Alex Ling 5760ad924e Bump version to v0.14.0 2020-10-18 12:22:26 +00:00
Alex Ling fff171c8c9 Bump version to v0.13.0 2020-10-18 11:39:24 +00:00
Alex Ling 44ff566a1d Merge branch 'feature/paged-reader' into dev 2020-10-15 11:52:15 +00:00
Alex Ling 853f422964 Configurable read timeout (#108) 2020-10-15 11:51:04 +00:00
Alex Ling 3bb0917374 Allow /manga/<id> URL for MangaDex 2020-10-15 11:38:22 +00:00
Alex Ling a86f0d0f34 Add paged reading mode 2020-10-09 10:09:42 +00:00
Alex Ling 16a9d7fc2e Merge pull request #110 from XavierSchiller/master
[arm64] Fix Wrong libgc.so location when building Image
2020-09-27 20:07:44 +08:00
Xavier ee2b4abc85 Fix Wrong libgc.so location when building Image.
The Repo Maintainer was using the location of libgc.so from the armhf package, however, according to:
https://debian.pkgs.org/9/debian-main-arm64/libgc-dev_7.4.2-8_arm64.deb.html and
https://packages.ubuntu.com/focal/arm64/libgc-dev/filelist
it exists under /usr/lib/aarch64-linux-gnu/
2020-09-27 14:35:43 +05:30
Alex Ling a6c2799521 Bump version to v0.12.3 2020-09-22 08:55:29 +00:00
Alex Ling 2370e4d2c6 Add browserstack as a sponsor 2020-09-22 08:54:20 +00:00
Alex Ling 32b0384ea0 Clearer gulpfile 2020-09-22 08:46:53 +00:00
Alex Ling 50d4ffdb7b Use babel and polyfill.io 2020-09-22 07:40:47 +00:00
Alex Ling 96463641f9 Update progress on last page (#105) 2020-09-21 04:35:23 +00:00
Alex Ling ddbba5d596 Bump version to v0.12.2 2020-09-17 16:08:52 +00:00
Alex Ling 2a04f4531e Bound the page number in the reader route
fixes #104
2020-09-17 16:06:01 +00:00
Alex Ling a5b6fb781f Bump version to v0.12.1 2020-09-17 13:32:00 +00:00
Alex Ling 8dfdab9d73 Respect the base URL in direct download link (#103) 2020-09-17 13:29:52 +00:00
Alex Ling 3a95270dfb Don't copy unused UIKit files 2020-09-17 13:25:35 +00:00
Alex Ling 2960ca54df Move fontawesome to NPM 2020-09-17 13:20:24 +00:00
Alex Ling f5fe3c6b1c Use image_size.cr v0.2.0 2020-09-16 15:40:01 +00:00
Alex Ling a612cc15fb Bump version to v0.12.0 2020-09-12 14:35:56 +00:00
Alex Ling c9c0818069 Add inline documentation to reader.js 2020-09-12 14:32:29 +00:00
Alex Ling 2f8efc382f Clean up 2020-09-12 14:05:15 +00:00
Alex Ling a0fb1880bd Update Dockerfile [skip ci] 2020-09-12 13:59:32 +00:00
Alex Ling a408f14425 Add .dockerignore 2020-09-12 13:59:14 +00:00
Alex Ling 243b6c8927 Typo fix [skip ci] 2020-09-12 07:13:15 +00:00
Alex Ling ff3a44d017 Update ARM dockerfiles to use image_size.cr 2020-09-12 07:03:30 +00:00
Alex Ling 67ef1f7112 DRY when listing archive entries 2020-09-12 06:58:03 +00:00
Alex Ling 5d7b8a1ef9 Skip error entries in OPDS feed 2020-09-12 06:57:42 +00:00
Alex Ling a68f3eea95 Allow hyphens in username (#99) 2020-09-12 05:29:25 +00:00
Alex Ling 220fc42bf2 Add system dependencies for image_size.cr 2020-09-11 17:02:45 +00:00
Alex Ling a45e6ea3da Rewrite web reader 2020-09-11 17:00:42 +00:00
Alex Ling 88394d4636 Expose page ratios through API 2020-09-11 17:00:28 +00:00
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
Alex Ling bec257c99f Update HTML description meta tag 2020-06-04 15:07:32 +00:00
Alex Ling f2df493d79 Add Ko-Fi link [skip ci] 2020-06-04 14:54:46 +00:00
Alex Ling b74f61c025 Bump version to v0.5.2 [skip ci] 2020-06-04 14:52:38 +00:00
Alex Ling c76c287e66 Fix URL of uploaded images when using base URL 2020-06-04 12:38:38 +00:00
Alex Ling 8e7eaa680a Fix favicon for base URL (#55) [skip-ci] 2020-06-04 05:43:37 +00:00
Alex Ling 30cdb3ec8f Remove duplicate title ID (#56) 2020-06-04 05:37:20 +00:00
Alex Ling 9c367e7d35 Format HTML files with html-beautify 2020-06-04 05:36:39 +00:00
Jared Turner 4f5e05c008 refactor continue reading into Library class 2020-06-03 13:48:49 +01:00
Alex Ling d2f95e5970 Bump version to v0.5.1 2020-06-03 08:22:05 +00:00
Alex Ling 82bcd03f15 Always create initial user if the DB is empty when started 2020-06-03 08:20:40 +00:00
Alex Ling fe799f30c8 Make the user listing command handles empty DB 2020-06-03 08:19:40 +00:00
Alex Ling 54123917af Empty ARGV before starting Kemal (#53) 2020-06-03 07:55:18 +00:00
Alex Ling 3b737c0bee Add library URL in README [skip ci] 2020-06-02 15:57:14 +00:00
Alex Ling 14bf4da06c Merge branch 'dev' 2020-06-02 15:45:11 +00:00
Alex Ling a72dfcecd3 Bump version to v0.5.0 2020-06-02 15:29:32 +00:00
Alex Ling 160a249dc6 Update CLI help message in README 2020-06-02 15:26:38 +00:00
Alex Ling f9a2534f80 Mention CBR support in README 2020-06-02 15:21:08 +00:00
Alex Ling 06fe2ccf16 Handle escaped characters when filtering (#51) [skip ci] 2020-06-02 15:08:43 +00:00
Jared Turner 13c0878357 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-01 15:29:36 +01:00
Jared Turner 3ef6a7bfc4 continue reading sorted by last read 2020-06-01 15:29:18 +01:00
Alex Ling e214e00dfb Include port number in token 2020-06-01 13:50:51 +00:00
Alex Ling 9b5aea223d Promote archive error log level to warning 2020-06-01 13:38:15 +00:00
Alex Ling 60100c51fe Add send_attachment function for direct download 2020-06-01 13:21:10 +00:00
Alex Ling 27c111d273 Handle basic auth for OPDS 2020-06-01 13:20:05 +00:00
Alex Ling 1b9d83f367 Report if archive is not readable #49 2020-06-01 04:54:28 +00:00
Alex Ling 96b8186add Merge branch 'feature/admin-cli' into dev 2020-06-01 04:33:27 +00:00
Alex Ling 27dab3c989 Disable initial user creation in spec 2020-05-31 15:26:11 +00:00
Alex Ling bcb95d1462 Make validate_archive more thorough 2020-05-31 15:14:17 +00:00
Alex Ling 4371c7877d Use base URL in cookies path 2020-05-31 14:34:42 +00:00
Alex Ling d72d635c68 Add admin/user sub-command 2020-05-31 14:30:45 +00:00
Alex Ling b724b4d508 Move username/password validation to Storage class 2020-05-31 14:26:20 +00:00
Alex Ling 8bbbe650f1 Allow skipping initial user creation 2020-05-31 14:25:15 +00:00
Alex Ling 651bd17612 Rewrite option parsing using clim and add the admin subcommand 2020-05-30 15:14:39 +00:00
Alex Ling dd01e632a2 Promote ameba from development dependency to regular dependency
So I can use it in CI while keeping the `--production` flag in Makefile
2020-05-29 16:33:15 +00:00
Alex Ling 43ee8f3b85 Pass in production flag when installing shards 2020-05-29 16:23:48 +00:00
Alex Ling 4841f90cc1 Remove edit buttons from home 2020-05-29 15:51:01 +00:00
Alex Ling bedcac4e35 Add missing libarchive-dev library 2020-05-29 14:28:18 +00:00
Alex Ling 5260a82e88 Add libarchive libraries to Docker and build files 2020-05-29 14:04:17 +00:00
Alex Ling 1efb300988 Use archive.cr v0.1.0 2020-05-29 14:00:59 +00:00
Alex Ling 6b43ee7fe5 Add RAR/CBR support 2020-05-29 13:45:25 +00:00
Jared Turner e99d7b8b29 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-05-29 13:31:00 +01:00
Jared Turner d2ad7fef77 WIP last_read property for Entries 2020-05-29 13:26:47 +01:00
Jared Turner ddb6a860ae add 'jump to title' to home modal 2020-05-24 10:35:35 +01:00
Alex Ling 3039031924 Merge branch 'master' of https://github.com/hkalexling/Mango 2020-05-24 06:47:19 +00:00
Alex Ling 8665616c2e Bump version to v0.4.0 2020-05-24 06:36:40 +00:00
Alex Ling 4453b0ee9f Link to development guideline in README [skip ci] 2020-05-24 14:34:19 +08:00
Alex Ling 487154e68c Add base url and rename rules to README [skip ci] 2020-05-24 14:33:05 +08:00
Alex Ling 60609263ab Explicitly set icon size (#40) [skip ci] 2020-05-23 14:47:35 +00:00
Alex Ling 4a245d2504 Check supplied base url has leading slash and append tailing slash if needed 2020-05-23 14:30:41 +00:00
Alex Ling 48c3a82078 Use base url when generating cover URLs 2020-05-23 14:16:56 +00:00
Alex Ling 4a59459773 Use base url in JS files 2020-05-23 14:18:14 +00:00
Alex Ling eefa8c3982 Use base url in some hardcoded URLs 2020-05-23 14:17:11 +00:00
Alex Ling 8fe2f3b4cc Use base url in views 2020-05-23 14:16:56 +00:00
Alex Ling 6a9105605d Fix library link in the breadcrumb menu 2020-05-23 12:16:08 +00:00
Alex Ling 60d4cee0a9 Respect base url setting when redirecting 2020-05-23 10:42:59 +00:00
Alex Ling 8658cb8306 Add base url to config 2020-05-23 10:42:39 +00:00
Alex Ling c74a01f546 Remove unnecessary JS files from home.ecr 2020-05-21 09:10:46 +00:00
Alex Ling 2aeb38a271 Remove edit button from home screen 2020-05-21 09:06:50 +00:00
Jared Turner a2c7638141 refactor on deck to continue reading and show percentages on home 2020-05-20 10:38:23 +01:00
Alex Ling c35e840694 Refactor the / route 2020-05-19 12:16:32 +00:00
Alex Ling ff6e64f12a Refactor get_on_deck_entry 2020-05-19 12:05:15 +00:00
Jared Turner 16fa27e4f6 update comments 2020-05-18 21:09:11 +01:00
Jared Turner 16734c2c59 rename root to library and add home with on deck WIP 2020-05-18 21:06:14 +01:00
Jared Turner 392b3d8339 fix load_percetage method name typo 2020-05-18 20:32:09 +01:00
Alex Ling d4e523c337 [skip ci] allow skip CI 2020-05-17 14:08:46 +00:00
Alex Ling d49c0092c2 Generate artifact 2020-05-17 13:57:28 +00:00
Alex Ling d75009f088 Rename scripts/ to dev/ 2020-05-17 13:44:10 +00:00
Alex Ling d416dc6618 Use rename when downloading 2020-05-17 06:29:13 +00:00
Alex Ling 7233e6e5c3 Type annotate the self.default methods 2020-05-17 06:28:33 +00:00
Alex Ling bd8ae9497f Initialize the downloader when started 2020-05-07 15:42:31 +00:00
Alex Ling 34b11dc2c7 Only hijack HTTP 500 when in release mode 2020-05-07 15:41:02 +00:00
Alex Ling 30dea57346 Use singleton in tests 2020-05-07 10:12:58 +00:00
Alex Ling 7448592216 Optionally pass in db path for testing 2020-05-07 10:12:58 +00:00
Alex Ling 049bd3ab2c Fix long lines 2020-05-07 10:12:58 +00:00
Alex Ling c3608c101b Enforce 80 characters limit in make check 2020-05-07 10:12:58 +00:00
Alex Ling 1bec9f0108 Use singleton in various classes 2020-05-07 10:12:58 +00:00
Alex Ling 09b297cd8e Add rename method to Manga and Chapter 2020-05-07 10:12:06 +00:00
Alex Ling b7cd55e692 Add rename rules to config 2020-05-07 10:11:45 +00:00
Alex Ling 986939ecb6 Add tests for the Rename module 2020-05-07 10:01:32 +00:00
Alex Ling a5e97af3a3 Use abstract class in the Rename module 2020-05-03 16:31:00 +00:00
Alex Ling 4cee5faecd Allow | character outside of patterns 2020-05-03 16:30:35 +00:00
Alex Ling 711add74ef Allow spaces in patterns 2020-05-03 16:29:54 +00:00
Alex Ling f6f09c54bc Add Rename module 2020-05-03 12:02:12 +00:00
Alex Ling 0f58ebb87b Ignore markdown files 2020-05-03 12:01:57 +00:00
Alex Ling 46347a8fe4 Update README.md 2020-04-23 14:49:06 +08:00
Alex Ling a354d811d9 Merge branch 'dev' 2020-04-22 14:36:36 +00:00
Alex Ling 22d757362a Update README.md 2020-04-22 22:34:02 +08:00
Alex Ling 8afcea7e87 Update README.md 2020-04-22 22:31:40 +08:00
Alex Ling fb05e913a0 Limit cover image types to png/jpeg in the web UI 2020-04-20 07:36:55 +00:00
Alex Ling 490888ad71 Bump version to 0.3.0 2020-04-19 16:23:00 +00:00
Alex Ling 20d71bfa81 Finish #30 2020-04-19 16:11:23 +00:00
Alex Ling ec6a7bd3d9 Read/unread a directory with API 2020-04-19 15:47:36 +00:00
Alex Ling b449d906ec Merge branch 'cover' into dev 2020-04-19 14:39:19 +00:00
Alex Ling f66bec5545 Update frontend for cover upload 2020-04-19 14:33:24 +00:00
Alex Ling ce5f444012 Remove debug code in upload handler 2020-04-19 14:32:58 +00:00
Alex Ling 8506044232 Handle errors in the "/" endpoint 2020-04-14 06:08:10 +00:00
Alex Ling 079dd8e280 Fix layout macro message displaying bug 2020-04-14 06:08:10 +00:00
Alex Ling 8262a163db Finish the API endpoint for cover upload 2020-04-14 06:09:23 +00:00
Alex Ling d6b22ef736 Don't return from DB blocks 2020-04-10 15:24:49 +00:00
Alex Ling 39f4897fc5 Set status as "Error" if downloaded zip is invalid
(#29)
2020-04-08 10:31:30 +00:00
Alex Ling fc6a33e5fd Update Makefile 2020-04-08 07:18:25 +00:00
Alex Ling 7d97d21d40 Run Ameba and Crystal formatting tool on push 2020-04-08 07:09:54 +00:00
Alex Ling fcf9d39047 Project-wise refactoring to follow Ameba 2020-04-08 06:45:45 +00:00
Alex Ling d33cae7618 Use Ameba 2020-04-08 06:45:45 +00:00
Alex Ling 8b184ed48d Project-wise code formatting 2020-04-08 05:25:12 +00:00
Alex Ling d3309a810b Update bug_report.md 2020-04-07 22:15:07 +08:00
Alex Ling 3866c81588 Use the updated Logger class in spec 2020-04-07 13:26:09 +00:00
Alex Ling 2c31f594a4 Use the new Log module in Crystal 0.34.0 2020-04-07 12:58:42 +00:00
Alex Ling c572c56a39 Upgrade Crystal version to 0.34.0 2020-04-07 12:57:50 +00:00
Alex Ling e670a083a3 Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling 9b23e1759d Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling 14e3470b12 Hide rename buttons when the login user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling 8ce51a6163 Hide the "Admin" and "Download" buttons when user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling 1d4237d687 Pass in admin information when rendering all pages 2020-04-07 12:57:50 +00:00
Alex Ling b7c0515af7 Fix dark mode on login page 2020-04-07 12:57:50 +00:00
Alex Ling 75edfcdb5b Set and load display names in frontend 2020-04-07 12:57:50 +00:00
Alex Ling 51d19328be Set up API endpoint for setting display names 2020-04-07 12:57:50 +00:00
Alex Ling d405498af4 Update shards.lock 2020-04-07 04:01:04 +00:00
Alex Ling 696f79aea1 Merge pull request #28 from noirscape/env-file
Use a .env file for docker-compose configuration.
2020-04-07 11:43:32 +08:00
noirscape d2da8d0b9a docker: Use a .env file 2020-04-06 21:49:14 +02:00
Alex Ling 4e961192d4 Update README.md 2020-04-06 22:44:45 +08:00
Alex Ling 8b90524a2c Create dockerhub.yml 2020-04-06 21:45:08 +08:00
120 changed files with 7976 additions and 3147 deletions
+102
View File
@@ -0,0 +1,102 @@
{
"projectName": "Mango",
"projectOwner": "hkalexling",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "none",
"contributors": [
{
"login": "hkalexling",
"name": "Alex Ling",
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
"profile": "https://github.com/hkalexling/",
"contributions": [
"code",
"doc",
"infra"
]
},
{
"login": "jaredlt",
"name": "jaredlt",
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
"profile": "https://github.com/jaredlt",
"contributions": [
"code",
"ideas",
"design"
]
},
{
"login": "shincurry",
"name": "ココロ",
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
"profile": "https://windisco.com/",
"contributions": [
"infra"
]
},
{
"login": "noirscape",
"name": "Valentijn",
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
"profile": "https://catgirlsin.space/",
"contributions": [
"infra"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc",
"ideas"
]
},
{
"login": "XavierSchiller",
"name": "Xavier",
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
"profile": "https://github.com/XavierSchiller",
"contributions": [
"infra"
]
},
{
"login": "WROIATE",
"name": "Jarao",
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
"profile": "https://github.com/WROIATE",
"contributions": [
"infra"
]
},
{
"login": "Leeingnyo",
"name": "이인용",
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
"profile": "https://github.com/Leeingnyo",
"contributions": [
"code"
]
},
{
"login": "h45h74x",
"name": "Simon",
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
"profile": "http://h45h74x.eu.org",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}
+9
View File
@@ -0,0 +1,9 @@
Lint/UselessAssign:
Excluded:
- src/routes/*
- src/server.cr
Lint/UnusedArgument:
Excluded:
- src/routes/*
Metrics/CyclomaticComplexity:
Enabled: false
+2
View File
@@ -0,0 +1,2 @@
node_modules
lib
+2
View File
@@ -1,3 +1,5 @@
# These are supported funding model platforms # These are supported funding model platforms
open_collective: mango
patreon: hkalexling patreon: hkalexling
ko_fi: hkalexling
+1 -1
View File
@@ -26,7 +26,7 @@ A clear and concise description of what you expected to happen.
- Mango Version [e.g. v0.1.0] - Mango Version [e.g. v0.1.0]
**Docker (if you are running Mango in a Docker container)** **Docker (if you are running Mango in a Docker container)**
- The `docker-compose.yml` file you are using - The `docker-compose.yml` file you are using, or your `.env` file.
**Additional context** **Additional context**
Add any other context about the problem here. Add screenshots if applicable. Add any other context about the problem here. Add screenshots if applicable.
+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.
+20 -4
View File
@@ -8,17 +8,33 @@ on:
jobs: jobs:
build: build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.32.1-alpine image: crystallang/crystal:0.35.1-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install dependencies - name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
- name: Build - name: Build
run: make run: make static || make static
- name: Linter
run: make check
- name: Run tests - name: Run tests
run: make test run: make test
- name: Upload binary
uses: actions/upload-artifact@v2
with:
name: 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
+19
View File
@@ -0,0 +1,19 @@
name: Publish Dockerhub
on:
release:
types: [published]
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Get release version
id: get_version
run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
- name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master
with:
name: hkalexling/mango
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
tags: "latest,${{ env.RELEASE_VERSION }}"
+5 -1
View File
@@ -7,4 +7,8 @@ node_modules
yarn.lock yarn.lock
dist dist
mango mango
docker-compose.yml .env
*.md
public/css/uikit.css
public/img/*.svg
public/js/*.min.js
+3 -4
View File
@@ -1,11 +1,10 @@
FROM crystallang/crystal:0.32.1-alpine AS builder FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
COPY package*.json . RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN apk add --no-cache yarn yaml sqlite-static \ RUN make static || make static
&& make static
FROM library/alpine FROM library/alpine
+14
View File
@@ -0,0 +1,14 @@
FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+14
View File
@@ -0,0 +1,14 @@
FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
+19 -4
View File
@@ -1,4 +1,4 @@
PREFIX=/usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
all: uglify | build all: uglify | build
@@ -7,14 +7,18 @@ 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 shards install --production
run: run:
crystal run src/mango.cr --error-trace crystal run src/mango.cr --error-trace
@@ -22,6 +26,17 @@ run:
test: test:
crystal spec crystal spec
check:
crystal tool format --check
./bin/ameba
./dev/linewidth.sh
arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
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
+75 -18
View File
@@ -1,6 +1,3 @@
![banner](./public/img/banner-paddings.png) ![banner](./public/img/banner-paddings.png)
# Mango # Mango
@@ -10,32 +7,41 @@
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
- Supports both `.zip` and `.cbz` formats - 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
- Thumbnail generation
- 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
1. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
### Docker ### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose` 1. Make sure you have docker installed and running. You will also need `docker-compose`
2. Clone the repository 2. Clone the repository
3. Copy `docker-compose.example.yml` to `docker-compose.yml` 3. Copy the `env.example` file to `.env`
4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine 4. Fill out the values in the `.env` file. Note that the main and config directories will be created if they don't already exist. The files in these folders will be owned by the root user
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside 5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
6. Head over to `localhost:9000` to log in 6. Head over to `localhost:9000` (or a different port if you changed it) to log in
### Docker (via Dockerhub)
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
### Build from source ### Build from source
1. Make sure you have Crystal, Node and Yarn installed. You might also need to install the development headers for `libsqlite3` and `libyaml`. 1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
2. Clone the repository 2. Clone the repository
3. `make && sudo make install` 3. `make && sudo make install`
4. Start Mango by running the command `mango` 4. Start Mango by running the command `mango`
@@ -46,11 +52,21 @@ Mango is a self-hosted manga server and reader. Its features include
### CLI ### CLI
``` ```
Mango e-manga server/reader. Version 0.2.0 Mango - Manga Server and Web Reader. Version 0.18.2
-v, --version Show version Usage:
-h, --help Show help
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml` mango [sub_command] [options]
Options:
-c PATH, --config=PATH Path to the config file [type:String]
-h, --help Show this help.
-v, --version Show version.
Sub Commands:
admin Run admin tools
``` ```
### Config ### Config
@@ -60,24 +76,37 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
port: 9000 port: 9000
base_url: /
session_secret: mango-session-secret
library_path: ~/mango/library library_path: ~/mango/library
db_path: ~/mango/mango.db db_path: ~/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
download_queue_db_path: ~/mango/queue.db download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}'
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
### Library Structure ### Library Structure
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example: You can organize your archive files in nested folders in the library directory. Here's an example:
``` ```
. .
@@ -89,8 +118,8 @@ You can organize your `.cbz/.zip` files in nested folders in the library directo
└── Manga 2 └── Manga 2
   └── Vol. 1    └── Vol. 1
   └── Ch.1 - Ch.3    └── Ch.1 - Ch.3
   ├── 1.zip    ├── 1.zip
   ├── 2.zip    ├── 2.zip
   └── 3.zip    └── 3.zip
``` ```
@@ -120,6 +149,34 @@ 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>
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
## Contributors ## Contributors
[![](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) Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \
|| exit 0
@@ -7,9 +7,9 @@ services:
context: . context: .
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
expose: expose:
- 9000 - ${PORT}
ports: ports:
- 9000:9000 - "${PORT}:9000"
volumes: volumes:
- ~/mango:/root/mango - ${MAIN_DIRECTORY_PATH}:/root/mango
- ~/.config/mango:/root/.config/mango - ${CONFIG_DIRECTORY_PATH}:/root/.config/mango
+10
View File
@@ -0,0 +1,10 @@
# Port that exposes the HTTP frontend
PORT=9000
# Path to the mango main directory
# This directory holds the database and the library files
MAIN_DIRECTORY_PATH=
# Path to the mango config directory
# This directory holds the mango configuration path
CONFIG_DIRECTORY_PATH=
+53 -12
View File
@@ -1,29 +1,70 @@
const gulp = require('gulp'); const gulp = require('gulp');
const minify = require("gulp-babel-minify"); const babel = require('gulp-babel');
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('minify-js', () => { // Copy libraries from node_moduels to public/js
return gulp.src('public/js/*.js') gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
// Compile less
gulp.task('less', () => {
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
// Transpile and minify JS files and output to dist
gulp.task('babel', () => {
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
.pipe(babel({
presets: [
['@babel/preset-env', {
targets: '>0.25%, not dead, ios>=9'
}]
],
}))
.pipe(minify({ .pipe(minify({
removeConsole: true removeConsole: true,
builtIns: false
})) }))
.pipe(gulp.dest('dist/js')); .pipe(gulp.dest('dist/js'));
}); });
// Minify CSS and output to dist
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('img', () => { // Copy static files (includeing images) to dist
return gulp.src('public/img/*') gulp.task('copy-files', () => {
.pipe(gulp.dest('dist/img')); return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
}); base: 'public'
})
gulp.task('favicon', () => {
return gulp.src('public/favicon.ico')
.pipe(gulp.dest('dist')); .pipe(gulp.dest('dist'));
}); });
gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon')); // Set up the public folder for development
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
// Set up the dist folder for deployment
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
// Default task
gulp.task('default', gulp.series('dev', 'deploy'));
+23 -14
View File
@@ -1,16 +1,25 @@
{ {
"name": "mango", "name": "mango",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/hkalexling/Mango.git", "repository": "https://github.com/hkalexling/Mango.git",
"author": "Alex Ling <hkalexling@gmail.com>", "author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"gulp": "^4.0.2", "@babel/preset-env": "^7.11.5",
"gulp-babel-minify": "^0.5.1", "all-contributors-cli": "^6.19.0",
"gulp-minify-css": "^1.2.4" "gulp": "^4.0.2",
}, "gulp-babel": "^8.0.0",
"scripts": { "gulp-babel-minify": "^0.5.1",
"uglify": "gulp" "gulp-less": "^4.0.1",
} "gulp-minify-css": "^1.2.4",
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "^3.5.4"
}
} }
+119 -31
View File
@@ -1,58 +1,146 @@
.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 {
height: 250px; width: 100%;
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 {
height: 100%;
width: 100%;
object-fit: cover;
} }
.uk-card-media-top > img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title { .uk-card-title {
height: 3em; max-height: 3em;
} }
.acard:hover { .acard:hover {
text-decoration: none; cursor: pointer;
}
.uk-list li {
cursor: pointer;
} }
.reader-bg { .reader-bg {
background-color: black; background-color: black;
}
#scan-status {
cursor: auto;
} }
.break-word { .break-word {
word-wrap: break-word; word-wrap: break-word;
} }
.uk-logo > img {
max-height: 90px; .uk-logo>img {
height: 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 {
background: #5E5731; .uk-light #selectable .ui-selecting {
background: #5E5731;
} }
#selectable .ui-selected.dark {
background: #9D9252; .uk-light #selectable .ui-selected {
background: #9D9252;
} }
td > .uk-dropdown {
white-space: pre-line; td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
} }
+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

+55 -25
View File
@@ -1,25 +1,55 @@
var scanning = false; const component = () => {
function scan() { return {
scanning = true; progress: 1.0,
$('#scan-status > div').removeAttr('hidden'); generating: false,
$('#scan-status > span').attr('hidden', ''); scanning: false,
var color = $('#scan').css('color'); scanTitles: 0,
$('#scan').css('color', 'gray'); scanMs: -1,
$.post('/api/admin/scan', function (data) { themeSetting: '',
var ms = data.milliseconds;
var titles = data.titles; init() {
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms'); this.getProgress();
$('#scan-status > span').removeAttr('hidden'); setInterval(() => {
$('#scan').css('color', color); this.getProgress();
$('#scan-status > div').attr('hidden', ''); }, 5000);
scanning = false;
}); const setting = loadThemeSetting();
} this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
$(function() { },
$('li').click(function() { themeChanged(event) {
url = $(this).attr('data-url'); const newSetting = $(event.currentTarget).val().toLowerCase();
if (url) { saveThemeSetting(newSetting);
$(location).attr('href', url); setTheme();
} },
}); scan() {
}); if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
};
+147
View File
@@ -0,0 +1,147 @@
/**
* --- Alpine helper functions
*/
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
* @param {string} selector - The jQuery selector to the root element
*/
const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @param {string} selector - The jQuery selector to the root element
* @return {*} The data property
*/
const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key];
};
/**
* --- Theme related functions
* Note: In the comments below we treat "theme" and "theme setting"
* differently. A theme can have only two values, either "dark" or
* "light", while a theme setting can have the third value "system".
*/
/**
* Check if the system setting prefers dark theme.
* from https://flaviocopes.com/javascript-detect-dark-mode/
*
* @function preferDarkMode
* @return {bool}
*/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
* Check whether a given string represents a valid theme setting
*
* @function validThemeSetting
* @param {string} theme - The string representing the theme setting
* @return {bool}
*/
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
/**
* Load theme setting from local storage, or use 'light'
*
* @function loadThemeSetting
* @return {string} A theme setting ('dark', 'light', or 'system')
*/
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system';
return str;
};
/**
* Load the current theme (not theme setting)
*
* @function loadTheme
* @return {string} The current theme to use ('dark' or 'light')
*/
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
/**
* Save a theme setting
*
* @function saveThemeSetting
* @param {string} setting - A theme setting
*/
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting);
};
/**
* Toggle the current theme. When the current theme setting is 'system', it
* will be changed to either 'light' or 'dark'
*
* @function toggleTheme
*/
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
/**
* Apply a theme, or load a theme and then apply it
*
* @function setTheme
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
* `loadTheme` to get a theme and apply it.
*/
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+22 -14
View File
@@ -1,18 +1,26 @@
const truncate = () => { /**
$('.acard .uk-card-title').each((i, e) => { * Truncate a .uk-card-title element
$(e).dotdotdot({ *
truncate: 'letter', * @function truncate
watch: true, * @param {object} e - The title element to truncate
callback: (truncated) => { */
if (truncated) { const truncate = (e) => {
$(e).attr('uk-tooltip', $(e).attr('data-title')); $(e).dotdotdot({
} truncate: 'letter',
else { watch: true,
$(e).removeAttr('uk-tooltip'); callback: (truncated) => {
} if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
} }
}); }
}); });
}; };
truncate(); $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});
+103 -134
View File
@@ -1,138 +1,107 @@
$(() => { const component = () => {
$('input.uk-checkbox').each((i, e) => { return {
$(e).change(() => { jobs: [],
loadConfig(); paused: undefined,
}); loading: false,
}); toggling: false,
loadConfig();
load();
const intervalMS = 5000; init() {
setTimeout(() => { const ws = new WebSocket(`ws://${location.host}${base_url}api/admin/mangadex/queue`);
setInterval(() => { ws.onmessage = event => {
if (globalConfig.autoRefresh !== true) return; const data = JSON.parse(event.data);
load(); this.jobs = data.jobs;
}, intervalMS); this.paused = data.paused;
}, intervalMS); };
}); ws.onerror = err => {
var globalConfig = {}; alert('danger', `Socket connection failed. Error: ${err}`);
var loading = false; };
ws.onclose = err => {
alert('danger', 'Socket connection failed');
};
const loadConfig = () => { this.load();
globalConfig.autoRefresh = $('#auto-refresh').prop('checked'); },
}; load() {
const remove = (id) => { this.loading = true;
var url = '/api/admin/mangadex/queue/delete'; $.ajax({
if (id !== undefined) type: 'GET',
url += '?' + $.param({id: id}); url: base_url + 'api/admin/mangadex/queue',
console.log(url); dataType: 'json'
$.ajax({ })
type: 'POST', .done(data => {
url: url, if (!data.success && data.error) {
dataType: 'json' alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
}) return;
.done(data => { }
if (!data.success && data.error) { this.jobs = data.jobs;
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`); this.paused = data.paused;
return; })
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-')[1];
url = `${url}?${$.param({
id: id
})}`;
}
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
}
this.load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
toggle() {
this.toggling = true;
const action = this.paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.load();
this.toggling = false;
});
},
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
} }
load(); };
})
.fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const refresh = (id) => {
var url = '/api/admin/mangadex/queue/retry';
if (id !== undefined)
url += '?' + $.param({id: id});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to restart download job. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const toggle = () => {
$('#pause-resume-btn').attr('disabled', '');
const paused = $('#pause-resume-btn').text() === 'Resume download';
const action = paused ? 'resume' : 'pause';
const url = `/api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
load();
$('#pause-resume-btn').removeAttr('disabled');
});
};
const load = () => {
if (loading) return;
loading = true;
console.log('fetching');
$.ajax({
type: 'GET',
url: '/api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
console.log(data);
const btnText = data.paused ? "Resume download" : "Pause download";
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'uk-label ';
if (obj.status === 'Completed')
cls += 'uk-label-success';
if (obj.status === 'Error')
cls += 'uk-label-danger';
if (obj.status === 'MissingPages')
cls += 'uk-label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}">
<td><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.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td>
<td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn}
</td>
</tr>`;
});
const tbody = `<tbody>${rows.join('')}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
loading = false;
});
}; };
+51 -45
View File
@@ -32,42 +32,41 @@ const download = () => {
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0); const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids); console.log(ids);
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: '/api/admin/mangadex/download', url: base_url + 'api/admin/mangadex/download',
data: JSON.stringify({chapters: chapters}), data: JSON.stringify({
contentType: "application/json", chapters: chapters
dataType: 'json' }),
}) contentType: "application/json",
.done(data => { dataType: 'json'
console.log(data); })
if (data.error) { .done(data => {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`); console.log(data);
return; if (data.error) {
} alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
const successCount = parseInt(data.success); return;
const failCount = parseInt(data.fail); }
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => { const successCount = parseInt(data.success);
window.location.href = '/admin/downloads'; 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');
}); });
styleModal();
})
.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');
});
}); });
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', '');
} }
@@ -96,10 +95,9 @@ const search = () => {
try { try {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path); const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} } catch (e) {
catch(e) {
int_id = parseInt(input); int_id = parseInt(input);
} }
@@ -109,7 +107,7 @@ const search = () => {
return; return;
} }
$.getJSON("/api/admin/mangadex/manga/" + int_id) $.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => { .done((data) => {
if (data.error) { if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error); alert('danger', 'Failed to get manga info. Error: ' + data.error);
@@ -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];
} }
@@ -242,7 +240,10 @@ const buildTable = () => {
Object.entries(filters).forEach(([k, v]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return; if (v === 'All') return;
if (k === 'group') { if (k === 'group') {
chapters = chapters.filter(c => v in c.groups); chapters = chapters.filter(c => {
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return; return;
} }
if (k === 'lang') { if (k === 'lang') {
@@ -277,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>
@@ -297,3 +297,9 @@ const buildTable = () => {
}); });
$('#selection-controls').removeAttr('hidden'); $('#selection-controls').removeAttr('hidden');
}; };
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};
-5
View File
File diff suppressed because one or more lines are too long
+141
View File
@@ -0,0 +1,141 @@
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 = $.param({
query: $('#search-input').val(),
plugin: pid
});
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Search failed. Error: ${data.error}`);
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');
});
});
};
+273 -73
View File
@@ -1,81 +1,281 @@
$(function() { const readerComponent = () => {
function bind() { return {
var controller = new ScrollMagic.Controller(); loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null,
longPages: false,
lastSavedPage: page,
// replace history on scroll /**
$('img').each(function(idx){ * Initialize the component by fetching the page dimensions
var scene = new ScrollMagic.Scene({ */
triggerElement: $(this).get(), init(nextTick) {
triggerHook: 'onEnter', $.get(`${base_url}api/dimensions/${tid}/${eid}`)
reverse: true .then(data => {
}) if (!data.success && data.error)
.addTo(controller) throw new Error(resp.error);
.on('enter', function(event){ const dimensions = data.dimensions;
current = $(event.target.triggerElement()).attr('id');
replaceHistory(current); this.items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
};
});
const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / this.items.length;
console.log(avgRatio);
this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
// Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
}) })
.on('leave', function(event){ .catch(e => {
var prev = $(event.target.triggerElement()).prev(); const errMsg = `Failed to get the page dimensions. ${e}`;
current = $(prev).attr('id'); console.error(e);
replaceHistory(current); this.alertClass = 'uk-alert-danger';
}); this.msg = errMsg;
}); })
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
// poor man's infinite scroll this.updateMode(mode, curIdx, nextTick);
var scene = new ScrollMagic.Scene({ },
triggerElement: $('.next-url').get(), /**
triggerHook: 'onEnter', * Handles the window `resize` event
offset: -500 */
}) resized() {
.addTo(controller) if (this.mode === 'continuous') return;
.on('enter', function(){
var nextURL = $('.next-url').attr('href'); const wideScreen = $(window).width() > $(window).height();
$('.next-url').remove(); this.mode = wideScreen ? 'height' : 'width';
if (!nextURL) { },
console.log('No .next-url found. Reached end of page'); /**
var lastURL = $('img').last().attr('id'); * Handles the window `keydown` event
// load the reader URL for the last page to update reading progrss to 100% *
$.get(lastURL); * @param {Event} event - The triggering event
$('#next-btn').removeAttr('hidden'); */
return; keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0 || newIdx > this.items.length) return;
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
setTimeout(() => {
this.flipAnimation = null;
}, 500);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
} }
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){ }
if (status === 'error') console.log(xhr.statusText); this.replaceHistory(idx);
if (status === 'success') { UIkit.modal($('#modal-sections')).hide();
console.log(nextURL + ' loaded'); },
// new page loaded to #hidden, we now append it /**
$('.uk-section > .uk-container').append($('#hidden .uk-container').children()); * Replace the address bar history and save the reading progress if necessary
$('#hidden').empty(); *
bind(); * @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
const pageCount = this.items.length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
} }
}); });
}); });
} },
/**
bind(); * Marks progress as 100% and jumps to the next entry
}); *
$('#page-select').change(function(){ * @param {string} nextUrl - URL of the next entry
jumpTo(parseInt($('#page-select').val())); */
}); nextEntry(nextUrl) {
function showControl(idx) { this.saveProgress(this.items.length, () => {
const pageCount = $('#page-select > option').length; this.redirect(nextUrl);
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; });
$('#progress-label').text(progressText); },
$('#page-select').val(idx); /**
UIkit.modal($('#modal-sections')).show(); * Exits the reader, and optionally sets the reading progress tp 100%
styleModal(); *
} * @param {string} exitUrl - The Exit URL
function jumpTo(page) { * @param {boolean} [markCompleted] - Whether we should mark the
var ary = window.location.pathname.split('/'); * reading progress to 100%
ary[ary.length - 1] = page; */
ary.shift(); // remove leading `/` exitReader(exitUrl, markCompleted = false) {
ary.unshift(window.location.origin); if (!markCompleted) {
window.location.replace(ary.join('/')); return this.redirect(exitUrl);
} }
function replaceHistory(url) { this.saveProgress(this.items.length, () => {
history.replaceState(null, "", url); this.redirect(exitUrl);
console.log('reading ' + url); });
} }
function redirect(url) { };
window.location.replace(url);
} }
-5
View File
File diff suppressed because one or more lines are too long
+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();
}); });
-43
View File
@@ -1,43 +0,0 @@
const getTheme = () => {
var theme = localStorage.getItem('theme');
if (!theme) theme = 'light';
return theme;
};
const saveTheme = theme => {
localStorage.setItem('theme', theme);
};
const toggleTheme = () => {
const theme = getTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
saveTheme(newTheme);
};
const setTheme = themeStr => {
if (themeStr === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
}
else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
const styleModal = () => {
const color = getTheme() === 'dark' ? '#222' : '';
$('.uk-modal-header').css('background', color);
$('.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());
+294 -20
View File
@@ -1,45 +1,319 @@
$(() => {
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').text(entry); $('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-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', '/reader/' + titleID + '/' + entryID + '/1'); $('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', '/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);
}); });
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
styleModal();
} }
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) { const updateProgress = (tid, eid, page) => {
if (data.success) { let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
eid: eid
});
if (eid)
url += `?${query}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.success) {
location.reload();
} else {
error = data.error;
alert('danger', error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
console.log(name);
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
}
const query = $.param({
eid: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload(); location.reload();
} })
else { .fail((jqXHR, status) => {
error = data.error; alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
alert('danger', error); });
};
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
} else {
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
console.log(displayNameField);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
} }
}); });
} displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
});
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
};
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {
tid: titleId
};
if (eid)
queryObj['eid'] = eid;
const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};
const deselectAll = () => {
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
data['selected'] = false;
});
$('#select-bar')[0].__x.$data['count'] = 0;
};
const selectAll = () => {
let count = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled']) {
data['selected'] = true;
count++;
}
});
$('#select-bar')[0].__x.$data['count'] = count;
};
const selectedIDs = () => {
const ary = [];
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item');
ary.push($(item).attr('id'));
}
});
return ary;
};
const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({
type: 'PUT',
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();
});
};
const tagsComponent = () => {
return {
loading: true,
isAdmin: false,
tags: [],
newTag: '',
inputShown: false,
tid: $('.upload-field').attr('data-title-id'),
load(admin) {
this.isAdmin = admin;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => {
this.tags = data.tags;
this.loading = false;
});
},
add() {
const tag = this.newTag.trim();
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT', () => {
this.tags.push(tag);
this.newTag = '';
});
},
keydown(event) {
if (event.key === 'Enter')
this.add()
},
rm(event) {
const tag = event.currentTarget.id.split('-')[0];
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE', () => {
const idx = this.tags.indexOf(tag);
if (idx < 0) return;
this.tags.splice(idx, 1);
});
},
toggleInput(nextTick) {
this.inputShown = !this.inputShown;
if (this.inputShown) {
nextTick(() => {
$('#tag-input').get(0).focus();
});
}
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success)
cb(data);
else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};
+1 -1
View File
@@ -1,5 +1,5 @@
$(() => { $(() => {
var target = '/admin/user/edit'; var target = base_url + 'admin/user/edit';
if (username) target += username; if (username) target += username;
$('form').attr('action', target); $('form').attr('action', target);
if (error) alert('danger', error); if (error) alert('danger', error);
+16 -11
View File
@@ -1,11 +1,16 @@
function remove(username) { const remove = (username) => {
$.post('/api/admin/user/delete/' + username, function(data) { $.ajax({
if (data.success) { url: `${base_url}api/admin/user/delete/${username}`,
location.reload(); type: 'DELETE',
} dataType: 'json'
else { })
error = data.error; .done(data => {
alert('danger', error); if (data.success)
} location.reload();
}); else
} alert('danger', data.error);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+65 -21
View File
@@ -1,30 +1,74 @@
version: 1.0 version: 2.0
shards: shards:
db: ameba:
github: crystal-lang/crystal-db git: https://github.com/crystal-ameba/ameba.git
version: 0.8.0 version: 0.12.1
exception_page: archive:
github: crystal-loot/exception_page git: https://github.com/hkalexling/archive.cr.git
version: 0.1.2
kemal:
github: kemalcr/kemal
version: 0.26.1
kemal-basic-auth:
github: kemalcr/kemal-basic-auth
version: 0.2.0
kilt:
github: jeromegn/kilt
version: 0.4.0 version: 0.4.0
baked_file_system:
git: https://github.com/schovi/baked_file_system.git
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim:
git: https://github.com/at-grandpa/clim.git
version: 0.12.0
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0
duktape:
git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0
exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4
http_proxy:
git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1
image_size:
git: https://github.com/hkalexling/image_size.cr.git
version: 0.4.0
kemal:
git: https://github.com/kemalcr/kemal.git
version: 0.27.0
kemal-session:
git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1
kilt:
git: https://github.com/jeromegn/kilt.git
version: 0.4.0
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
myhtml:
git: https://github.com/kostya/myhtml.git
version: 1.5.1
open_api:
git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
radix: radix:
github: luislavena/radix git: https://github.com/luislavena/radix.git
version: 0.3.9 version: 0.3.9
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.15.0 version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3
+24 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.2.5 version: 0.18.2
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -8,14 +8,36 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.32.1 crystal: 0.35.1
license: MIT 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:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive:
github: hkalexling/archive.cr
ameba:
github: crystal-ameba/ameba
clim:
github: at-grandpa/clim
duktape:
github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml:
github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
tallboy:
github: epoch/tallboy
+9 -9
View File
@@ -1,14 +1,14 @@
require "./spec_helper" require "./spec_helper"
describe Config do describe Config do
it "creates config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |config, logger, path| with_default_config do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
it "correctly loads config" do it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml" config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000 config.port.should eq 3000
end end
end end
-105
View File
@@ -1,105 +0,0 @@
require "./spec_helper"
include MangaDex
describe Queue do
it "creates DB at given path" do
with_queue do |queue, 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
+76
View File
@@ -0,0 +1,76 @@
require "./spec_helper"
require "../src/rename"
include Rename
describe Rule do
it "raises on nested brackets" do
expect_raises Exception do
Rule.new "[[]]"
end
expect_raises Exception do
Rule.new "{{}}"
end
end
it "raises on unclosed brackets" do
expect_raises Exception do
Rule.new "["
end
expect_raises Exception do
Rule.new "{"
end
expect_raises Exception do
Rule.new "[{]}"
end
end
it "raises when closing unopened brackets" do
expect_raises Exception do
Rule.new "]"
end
expect_raises Exception do
Rule.new "[}"
end
end
it "handles `|` in patterns" do
rule = Rule.new "{a|b|c}"
rule.render({"b" => "b"}).should eq "b"
rule.render({"a" => "a", "b" => "b"}).should eq "a"
end
it "raises on escaped characters" do
expect_raises Exception do
Rule.new "hello/world"
end
end
it "handles spaces in patterns" do
rule = Rule.new "{ a }"
rule.render({"a" => "a"}).should eq "a"
end
it "strips leading and tailing spaces" do
rule = Rule.new " hello "
rule.render({"a" => "a"}).should eq "hello"
end
it "renders a few examples correctly" do
rule = Rule.new "[Ch. {chapter }] {title | id} testing"
rule.render({"id" => "ID"}).should eq "ID testing"
rule.render({"chapter" => "CH", "id" => "ID"})
.should eq "Ch. CH ID testing"
rule.render({} of String => String).should eq "testing"
end
it "escapes illegal characters" do
rule = Rule.new "{a}"
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
end
it "strips trailing spaces and dots" do
rule = Rule.new "hello. world. .."
rule.render({} of String => String).should eq "hello. world"
end
end
+38 -47
View File
@@ -1,65 +1,56 @@
require "spec" require "spec"
require "../src/context" require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config"
require "../src/main_fiber"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
def self.get(key) def self.get(key)
@@hash[key]? @@hash[key]?
end end
def self.get!(key) def self.get!(key)
@@hash[key] @@hash[key]
end end
def self.set(key, value) def self.set(key, value)
return if value.nil? return if value.nil?
@@hash[key] = value @@hash[key] = value
end end
def self.reset def self.reset
@@hash.clear @@hash.clear
end end
end end
def get_tempfile(name) def get_tempfile(name)
path = State.get name path = State.get name
if path.nil? || !File.exists? path if path.nil? || !File.exists? path
file = File.tempfile name file = File.tempfile name
State.set name, file.path State.set name, file.path
return file file
else else
return File.new path File.new path
end end
end end
def with_default_config def with_default_config
temp_config = get_tempfile "mango-test-config" temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path config = Config.load temp_config.path
logger = MLogger.new config config.set_current
yield config, logger, temp_config.path yield config, temp_config.path
temp_config.delete temp_config.delete
end end
def with_storage def with_storage
with_default_config do |config, logger| with_default_config do
temp_db = get_tempfile "mango-test-db" temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, logger storage = Storage.new temp_db.path, false
clear = yield storage, temp_db.path clear = yield storage, temp_db.path
if clear == true if clear == true
temp_db.delete temp_db.delete
end end
end end
end
def with_queue
with_default_config do |config, logger|
temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path, logger
clear = yield queue, temp_queue_db.path
if clear == true
temp_queue_db.delete
end
end
end end
+77 -77
View File
@@ -1,91 +1,91 @@
require "./spec_helper" require "./spec_helper"
describe Storage do describe Storage do
it "creates DB at given path" do it "creates DB at given path" do
with_storage do |storage, path| with_storage do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
it "deletes user" do it "deletes user" do
with_storage do |storage| with_storage do |storage|
storage.delete_user "admin" storage.delete_user "admin"
end end
end end
it "creates new user" do it "creates new user" do
with_storage do |storage| with_storage do |storage|
storage.new_user "user", "123456", false storage.new_user "user", "123456", false
storage.new_user "admin", "123456", true storage.new_user "admin", "123456", true
end end
end end
it "verifies username/password combination" do it "verifies username/password combination" do
with_storage do |storage| with_storage do |storage|
user_token = storage.verify_user "user", "123456" user_token = storage.verify_user "user", "123456"
admin_token = storage.verify_user "admin", "123456" admin_token = storage.verify_user "admin", "123456"
user_token.should_not be_nil user_token.should_not be_nil
admin_token.should_not be_nil admin_token.should_not be_nil
State.set "user_token", user_token State.set "user_token", user_token
State.set "admin_token", admin_token State.set "admin_token", admin_token
end end
end end
it "rejects duplicate username" do it "rejects duplicate username" do
with_storage do |storage| with_storage do |storage|
expect_raises SQLite3::Exception, expect_raises SQLite3::Exception,
"UNIQUE constraint failed: users.username" do "UNIQUE constraint failed: users.username" do
storage.new_user "admin", "123456", true storage.new_user "admin", "123456", true
end end
end end
end end
it "verifies token" do it "verifies token" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
user = storage.verify_token user_token user = storage.verify_token user_token
user.should eq "user" user.should eq "user"
end end
end end
it "verfies admin token" do it "verfies admin token" do
with_storage do |storage| with_storage do |storage|
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
storage.verify_admin(admin_token).should be_true storage.verify_admin(admin_token).should be_true
end end
end end
it "rejects non-admin token" do it "rejects non-admin token" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
storage.verify_admin(user_token).should be_false storage.verify_admin(user_token).should be_false
end end
end end
it "updates user" do it "updates user" do
with_storage do |storage| with_storage do |storage|
storage.update_user "admin", "admin", "654321", true storage.update_user "admin", "admin", "654321", true
token = storage.verify_user "admin", "654321" token = storage.verify_user "admin", "654321"
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
token.should eq admin_token token.should eq admin_token
end end
end end
it "logs user out" do it "logs user out" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
storage.logout user_token storage.logout user_token
storage.logout admin_token storage.logout admin_token
storage.verify_token(user_token).should be_nil storage.verify_token(user_token).should be_nil
storage.verify_token(admin_token).should be_nil storage.verify_token(admin_token).should be_nil
end end
end end
it "cleans up" do it "cleans up" do
with_storage do with_storage do
true true
end end
State.reset State.reset
end end
end end
+40 -30
View File
@@ -1,36 +1,46 @@
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
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
# https://ux.stackexchange.com/a/95441 # https://ux.stackexchange.com/a/95441
it "sorts like the stack exchange post" do it "sorts like the stack exchange post" 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
# https://github.com/hkalexling/Mango/issues/22 # https://github.com/hkalexling/Mango/issues/22
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
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 end
+59
View File
@@ -0,0 +1,59 @@
require "compress/zip"
require "archive"
# A unified class to handle all supported archive formats. It uses the
# Compress::Zip module in crystal standard library if the target file is
# a zip archive. Otherwise it uses `archive.cr`.
class ArchiveFile
def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Compress::Zip::File.new filename
else
@archive_file = Archive::File.new filename
end
end
def self.open(filename : String, &)
s = self.new filename
yield s
s.close
end
def close
if @archive_file.is_a? Compress::Zip::File
@archive_file.as(Compress::Zip::File).close
end
end
# Lists all file entries
def entries
ary = [] of Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e|
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?)
ary.push e
end
end
ary
end
def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Compress::Zip::File::Entry
data = nil
e.open do |io|
slice = Bytes.new e.uncompressed_size
bytes_read = io.read_fully? slice
data = slice if bytes_read
end
data
else
e.read
end
end
def check
if @archive_file.is_a? Archive::File
@archive_file.as(Archive::File).check
end
end
end
-26
View File
@@ -1,26 +0,0 @@
require "kemal"
require "./storage"
require "./util"
class AuthHandler < Kemal::Handler
def initialize(@storage : Storage)
end
def call(env)
return call_next(env) \
if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find { |c| c.name == "token" }
if cookie.nil? || ! @storage.verify_token cookie.value
return env.redirect "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value
env.response.status_code = 403
end
end
call_next env
end
end
+88 -52
View File
@@ -1,60 +1,96 @@
require "yaml" require "yaml"
class Config class Config
include YAML::Serializable include YAML::Serializable
property port : Int32 = 9000 @[YAML::Field(ignore: true)]
property library_path : String = \ property path : String = ""
File.expand_path "~/mango/library", home: true property port : Int32 = 9000
property db_path : String = \ property base_url : String = "/"
File.expand_path "~/mango/mango.db", home: true property session_secret : String = "mango-session-secret"
@[YAML::Field(key: "scan_interval_minutes")] property library_path : String = File.expand_path "~/mango/library",
property scan_interval : Int32 = 5 home: true
property log_level : String = "info" property db_path : String = File.expand_path "~/mango/mango.db", home: true
property mangadex = Hash(String, String|Int32).new property scan_interval_minutes : Int32 = 5
property thumbnail_generation_interval_hours : Int32 = 24
property db_optimization_interval_hours : Int32 = 24
property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
property plugin_path : String = File.expand_path "~/mango/plugins",
home: true
property download_timeout_seconds : Int32 = 30
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@mangadex_defaults = { @mangadex_defaults = {
"base_url" => "https://mangadex.org", "base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api", "api_url" => "https://mangadex.org/api",
"download_wait_seconds" => 5, "download_wait_seconds" => 5,
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path "~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true home: true),
} "chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
}
def self.load(path : String?) @@singlet : Config?
path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.fill_defaults
return config
end
puts "The config file #{cfg_path} does not exist." \
" Do you want mango to dump the default config there? [Y/n]"
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
default = self.allocate
default.fill_defaults
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir
end
File.write cfg_path, default.to_yaml
puts "The config file has been created at #{cfg_path}."
default
end
def fill_defaults def self.current
{% for hash_name in ["mangadex"] %} @@singlet.not_nil!
@{{hash_name.id}}_defaults.map do |k, v| end
if @{{hash_name.id}}[k]?.nil?
@{{hash_name.id}}[k] = v def set_current
end @@singlet = self
end end
{% end %}
end def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path
config.fill_defaults
return config
end
puts "The config file #{cfg_path} does not exist. " \
"Dumping the default config there."
default = self.allocate
default.path = path
default.fill_defaults
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir
end
File.write cfg_path, default.to_yaml
puts "The config file has been created at #{cfg_path}."
default
end
def fill_defaults
{% for hash_name in ["mangadex"] %}
@{{hash_name.id}}_defaults.map do |k, v|
if @{{hash_name.id}}[k]?.nil?
@{{hash_name.id}}[k] = v
end
end
{% end %}
end
def preprocess
unless base_url.starts_with? "/"
raise "base url (#{base_url}) should start with `/`"
end
unless base_url.ends_with? "/"
@base_url += "/"
end
if disable_login && default_username.empty?
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
end
end end
-21
View File
@@ -1,21 +0,0 @@
require "./config"
require "./library"
require "./storage"
require "./logger"
class Context
property config : Config
property library : Library
property storage : Storage
property logger : MLogger
property queue : MangaDex::Queue
def initialize(@config, @logger, @library, @storage, @queue)
end
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
@logger.{{lvl.id}} msg
end
{% end %}
end
+98
View File
@@ -0,0 +1,98 @@
require "kemal"
require "../storage"
require "../util/*"
class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def require_basic_auth(env)
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
call_next env
end
def validate_token(env)
token = env.session.string? "token"
!token.nil? && Storage.default.verify_token token
end
def validate_token_admin(env)
token = env.session.string? "token"
!token.nil? && Storage.default.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.default.verify_user username, password
end
def handle_opds_auth(env)
if validate_token(env) || validate_auth_header(env)
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
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) || Config.current.disable_login
env.session.string "callback", env.request.path
return redirect env, "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
env.response.status_code = 403 if should_reject
end
call_next env
end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end
+23
View File
@@ -0,0 +1,23 @@
require "kemal"
require "../logger"
class LogHandler < Kemal::BaseLogHandler
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
Logger.debug msg
env
end
def write(msg)
Logger.debug msg
end
private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1
"#{(millis * 1000).round(2)}µs"
end
end
+30
View File
@@ -0,0 +1,30 @@
require "baked_file_system"
require "kemal"
require "../util/*"
class FS
extend BakedFileSystem
{% if flag?(:release) %}
{% if read_file? "#{__DIR__}/../../dist/favicon.ico" %}
{% puts "baking ../../dist" %}
bake_folder "../../dist"
{% else %}
{% puts "baking ../../public" %}
bake_folder "../../public"
{% end %}
{% end %}
end
class StaticHandler < Kemal::Handler
def call(env)
if requesting_static_file env
file = FS.get? env.request.path
return call_next env if file.nil?
slice = Bytes.new file.size
file.read slice
return send_file env, slice, MIME.from_filename file.path
end
call_next env
end
end
+26
View File
@@ -0,0 +1,26 @@
require "kemal"
require "../util/*"
class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String)
end
def call(env)
unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) &&
env.request.method == "GET"
return call_next env
end
ary = env.request.path.split(File::SEPARATOR).select do |part|
!part.empty?
end
ary[0] = @upload_dir
path = File.join ary
if File.exists? path
send_file env, path
else
env.response.status_code = 404
end
end
end
-313
View File
@@ -1,313 +0,0 @@
require "zip"
require "mime"
require "json"
require "uri"
require "./util"
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 : String, title : String,
size : String, pages : Int32, cover_url : String, id : String,
title_id : String, encoded_path : String, encoded_title : String,
mtime : Time
def initialize(path, @book_title, @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 = Zip::File.new path
@pages = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.size
file.close
@id = storage.get_id @zip_path, false
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "book_title", "title", "size",
"cover_url", "id", "title_id", "encoded_path",
"encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "pages" {json.number @pages}
json.field "mtime" {json.number @mtime.to_unix}
end
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_alphanumerically a.filename, b.filename
}
.[page_num - 1]
page.open do |io|
slice = Bytes.new page.uncompressed_size
bytes_read = io.read_fully? slice
unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),\
page.filename, bytes_read
end
end
end
end
class Title
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,
@logger : MLogger, @library : Library)
@dir = dir
@id = storage.get_id @dir, true
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@entries = [] of Entry
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, @logger, 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"].includes? File.extname path
next if !valid_zip path
entry = Entry.new path, @title, @id, storage
@entries << entry if entry.pages > 0
end
end
@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
mtimes = [File.info(dir).modification_time]
mtimes += @title_ids.map{|e| @library.title_hash[e].mtime}
mtimes += @entries.map{|e| e.mtime}
@mtime = mtimes.max
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 "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
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
private def valid_zip(path : String)
begin
file = Zip::File.new path
file.close
return true
rescue
@logger.warn "File #{path} is corrupted or is not a valid zip "\
"archive. Ignoring it."
return false
end
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
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)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
info.save @dir
return
end
info.progress[username][entry] = page
info.save @dir
end
def load_progress(username, entry)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
return 0
end
if info.progress[username][entry]?.nil?
return 0
end
info.progress[username][entry]
end
def load_percetage(username, entry)
info = TitleInfo.new @dir
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
# { user1: { entry1: 10, entry2: 0 } }
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
property progress : Hash(String, Hash(String, Int32))
def initialize(title_dir)
info = nil
json_path = File.join title_dir, "info.json"
if File.exists? json_path
info = TitleInfo.from_json File.read json_path
else
info = TitleInfo.from_json "{\"progress\": {}}"
end
@progress = info.progress.clone
end
def save(title_dir)
json_path = File.join title_dir, "info.json"
File.write json_path, self.to_pretty_json
end
end
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
logger : MLogger, storage : Storage, title_hash : Hash(String, Title)
def initialize(@dir, @scan_interval, @logger, @storage)
# 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, @logger, 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
+239
View File
@@ -0,0 +1,239 @@
require "image_size"
class Entry
getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_id @zip_path, false
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"] %}
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/cover/#{@book.id}/#{@id}"
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
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
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