Compare commits

..

162 Commits

Author SHA1 Message Date
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
73 changed files with 2196 additions and 997 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
# These are supported funding model platforms
patreon: hkalexling
ko_fi: hkalexling

View File

@@ -8,10 +8,13 @@ assignees: ''
---
**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**
A clear and concise description of what you want to happen.
**Describe a small use-case for this feature request**
How would you imagine this to be used? What would be the advantage of this for the users of the application?
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -8,19 +8,24 @@ on:
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest
container:
image: crystallang/crystal:0.34.0-alpine
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
- name: Build
run: make
run: make static
- name: Linter
run: make check
- name: Run tests
run: make test
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: mango
path: mango

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ yarn.lock
dist
mango
.env
*.md
public/css/uikit.css

View File

@@ -4,7 +4,7 @@ WORKDIR /Mango
COPY . .
COPY package*.json .
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 \
&& make static
FROM library/alpine

View File

@@ -14,7 +14,7 @@ static: uglify | libs
crystal build src/mango.cr --release --progress --static
libs:
shards install
shards install --production
run:
crystal run src/mango.cr --error-trace
@@ -25,6 +25,7 @@ test:
check:
crystal tool format --check
./bin/ameba
./dev/linewidth.sh
install:
cp mango $(INSTALL_DIR)/mango

View File

@@ -1,6 +1,3 @@
![banner](./public/img/banner-paddings.png)
# Mango
@@ -10,14 +7,17 @@
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
- OPDS support
- Dark/light mode switch
- Supports both `.zip` and `.cbz` formats
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library
- Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
## Installation
### Pre-built Binary
@@ -35,11 +35,11 @@ Simply download the pre-built binary file `mango` for the latest [release](https
### Docker (via Dockerhub)
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
### Build from source
1. Make sure you have `crystal`, `shards` 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
3. `make && sudo make install`
4. Start Mango by running the command `mango`
@@ -50,11 +50,21 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango e-manga server/reader. Version 0.3.0
Mango - Manga Server and Web Reader. Version 0.7.2
-v, --version Show version
-h, --help Show help
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
Usage:
mango [sub_command] [options]
Options:
-c PATH, --config=PATH Path to the config file [type:String]
-h, --help Show this help.
-v, --version Show version.
Sub Commands:
admin Run admin tools
```
### Config
@@ -64,17 +74,20 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml
---
port: 9000
library_path: /home/alex_ling/mango/library
upload_path: /home/alex_ling/mango/uploads
db_path: /home/alex_ling/mango/mango.db
base_url: /
library_path: ~/mango/library
db_path: ~/mango/mango.db
scan_interval_minutes: 5
log_level: info
upload_path: ~/mango/uploads
mangadex:
base_url: https://mangadex.org
api_url: https://mangadex.org/api
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: /home/alex_ling/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
@@ -82,7 +95,7 @@ mangadex:
### 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:
```
.
@@ -94,8 +107,8 @@ You can organize your `.cbz/.zip` files in nested folders in the library directo
└── Manga 2
   └── Vol. 1
   └── Ch.1 - Ch.3
   ├── 1.zip
   ├── 2.zip
   ├── 1.zip
   ├── 2.zip
   └── 3.zip
```
@@ -127,4 +140,6 @@ Mobile UI:
## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)

5
dev/linewidth.sh Executable file
View File

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

View File

@@ -1,6 +1,7 @@
const gulp = require('gulp');
const minify = require("gulp-babel-minify");
const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
gulp.task('minify-js', () => {
return gulp.src('public/js/*.js')
@@ -10,6 +11,12 @@ gulp.task('minify-js', () => {
.pipe(gulp.dest('dist/js'));
});
gulp.task('less', () => {
return gulp.src('src/assets/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
gulp.task('minify-css', () => {
return gulp.src('public/css/*.css')
.pipe(minifyCss())
@@ -21,9 +28,9 @@ gulp.task('img', () => {
.pipe(gulp.dest('dist/img'));
});
gulp.task('favicon', () => {
return gulp.src('public/favicon.ico')
gulp.task('copy-files', () => {
return gulp.src('public/*.*')
.pipe(gulp.dest('dist'));
});
gulp.task('default', gulp.parallel('minify-js', 'minify-css', 'img', 'favicon'));
gulp.task('default', gulp.parallel('minify-js', gulp.series('less', 'minify-css'), 'img', 'copy-files'));

View File

@@ -8,9 +8,14 @@
"devDependencies": {
"gulp": "^4.0.2",
"gulp-babel-minify": "^0.5.1",
"gulp-minify-css": "^1.2.4"
"gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4",
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"uikit": "^3.5.4"
}
}

View File

@@ -36,7 +36,8 @@
word-wrap: break-word;
}
.uk-logo > img {
max-height: 90px;
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;

View File

@@ -5,7 +5,7 @@ function scan() {
$('#scan-status > span').attr('hidden', '');
var color = $('#scan').css('color');
$('#scan').css('color', 'gray');
$.post('/api/admin/scan', function (data) {
$.post(base_url + 'api/admin/scan', function (data) {
var ms = data.milliseconds;
var titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');

View File

@@ -22,97 +22,103 @@ const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
};
const remove = (id) => {
var url = '/api/admin/mangadex/queue/delete';
var url = base_url + 'api/admin/mangadex/queue/delete';
if (id !== undefined)
url += '?' + $.param({id: id});
url += '?' + $.param({
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const refresh = (id) => {
var url = '/api/admin/mangadex/queue/retry';
var url = base_url + 'api/admin/mangadex/queue/retry';
if (id !== undefined)
url += '?' + $.param({id: id});
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}`);
});
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}`;
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
load();
$('#pause-resume-btn').removeAttr('disabled');
});
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';
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
console.log(data);
const btnText = data.paused ? "Resume download" : "Pause download";
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending';
if (obj.status === 'Completed')
cls += 'label-success';
if (obj.status === 'Error')
cls += 'label-danger';
if (obj.status === 'MissingPages')
cls += 'label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}">
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>
@@ -123,16 +129,16 @@ const load = () => {
${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;
});
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;
});
};

View File

@@ -33,7 +33,7 @@ const download = () => {
console.log(ids);
$.ajax({
type: 'POST',
url: '/api/admin/mangadex/download',
url: base_url + 'api/admin/mangadex/download',
data: JSON.stringify({chapters: chapters}),
contentType: "application/json",
dataType: 'json'
@@ -47,7 +47,7 @@ const download = () => {
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 = '/admin/downloads';
window.location.href = base_url + 'admin/downloads';
});
styleModal();
})
@@ -109,7 +109,7 @@ const search = () => {
return;
}
$.getJSON("/api/admin/mangadex/manga/" + int_id)
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
@@ -242,7 +242,10 @@ const buildTable = () => {
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
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;
}
if (k === 'lang') {
@@ -297,3 +300,9 @@ const buildTable = () => {
});
$('#selection-controls').removeAttr('hidden');
};
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};

View File

@@ -22,8 +22,7 @@ const setTheme = themeStr => {
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
}
else {
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
@@ -39,5 +38,11 @@ const styleModal = () => {
$('.uk-modal-footer').css('background', color);
};
// do it before document is ready to prevent the initial flash of white
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme(getTheme());
$(() => {
// hack for the reader page
setTheme(getTheme());
});

View File

@@ -2,52 +2,58 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function(){
$('#modal button, #modal a').each(function() {
$(this).removeAttr('hidden');
});
if (percentage === 0) {
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
}
else {
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
if (percentage === 100) {
$('#read-btn').attr('hidden', '');
}
$('#modal-title').find('span').text(entry);
$('#modal-title').next().attr('data-id', titleID);
$('#modal-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().find('.title-rename-field').val(entry);
$('#modal-title-link').text(title);
$('#modal-title-link').attr('href', `${base_url}book/${titleID}`);
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function(){
$('#read-btn').click(function() {
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function(){
$('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0);
});
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show();
styleModal();
}
const updateProgress = (tid, eid, page) => {
let url = `/api/progress/${tid}/${page}`
const query = $.param({entry: eid});
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
entry: eid
});
if (eid)
url += `?${query}`;
$.post(url, (data) => {
if (data.success) {
location.reload();
}
else {
} else {
error = data.error;
alert('danger', error);
}
@@ -65,27 +71,29 @@ const renameSubmit = (name, eid) => {
return;
}
const query = $.param({ entry: eid });
let url = `/api/admin/display_name/${titleId}/${name}`;
const query = $.param({
entry: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const edit = (eid) => {
@@ -98,8 +106,7 @@ const edit = (eid) => {
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
}
else {
} else {
$('#title-progress-control').removeAttr('hidden');
}
@@ -126,11 +133,13 @@ const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId};
const queryObj = {
title: titleId
};
if (eid)
queryObj['entry'] = eid;
const query = $.param(queryObj);
const url = `/api/admin/upload/cover?${query}`;
const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,

View File

@@ -1,5 +1,5 @@
$(() => {
var target = '/admin/user/edit';
var target = base_url + 'admin/user/edit';
if (username) target += username;
$('form').attr('action', target);
if (error) alert('danger', error);

View File

@@ -1,5 +1,5 @@
function remove(username) {
$.post('/api/admin/user/delete/' + username, function(data) {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) {
if (data.success) {
location.reload();
}

2
public/robots.txt Normal file
View File

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

View File

@@ -2,12 +2,20 @@ version: 1.0
shards:
ameba:
github: crystal-ameba/ameba
version: 0.12.0
version: 0.12.1
archive:
github: hkalexling/archive.cr
version: 0.2.0
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8
clim:
github: at-grandpa/clim
version: 0.12.0
db:
github: crystal-lang/crystal-db
version: 0.9.0
@@ -20,6 +28,10 @@ shards:
github: kemalcr/kemal
version: 0.26.1
kemal-session:
github: kemalcr/kemal-session
version: 0.12.1
kilt:
github: jeromegn/kilt
version: 0.4.0

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.3.0
version: 0.7.2
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -15,11 +15,15 @@ license: MIT
dependencies:
kemal:
github: kemalcr/kemal
kemal-session:
github: kemalcr/kemal-session
sqlite3:
github: crystal-lang/crystal-sqlite3
baked_file_system:
github: schovi/baked_file_system
development_dependencies:
archive:
github: hkalexling/archive.cr
ameba:
github: crystal-ameba/ameba
clim:
github: at-grandpa/clim

View File

@@ -2,7 +2,7 @@ require "./spec_helper"
describe Config do
it "creates config if it does not exist" do
with_default_config do |_, _, path|
with_default_config do |_, path|
File.exists?(path).should be_true
end
end

76
spec/rename_spec.cr Normal file
View File

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

View File

@@ -1,6 +1,6 @@
require "spec"
require "../src/context"
require "../src/server"
require "../src/config"
class State
@@hash = {} of String => String
@@ -37,15 +37,15 @@ end
def with_default_config
temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path
logger = Logger.new config.log_level
yield config, logger, temp_config.path
config.set_current
yield config, temp_config.path
temp_config.delete
end
def with_storage
with_default_config do |_, logger|
with_default_config do
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
if clear == true
temp_db.delete
@@ -54,9 +54,9 @@ def with_storage
end
def with_queue
with_default_config do |_, logger|
with_default_config do
temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path, logger
queue = MangaDex::Queue.new temp_queue_db.path
clear = yield queue, temp_queue_db.path
if clear == true
temp_queue_db.delete

59
src/archive.cr Normal file
View File

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

33
src/assets/uikit.less Normal file
View File

@@ -0,0 +1,33 @@
@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;
}

View File

@@ -3,7 +3,11 @@ require "yaml"
class Config
include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
property port : Int32 = 9000
property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library",
home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true
@@ -22,13 +26,27 @@ class Config
"download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true),
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
"manga_rename_rule" => "{title}",
}
@@singlet : Config?
def self.current
@@singlet.not_nil!
end
def set_current
@@singlet = self
end
def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path
config.fill_defaults
return config
end
@@ -39,6 +57,7 @@ class Config
abort "Aborting..."
end
default = self.allocate
default.path = path
default.fill_defaults
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir
@@ -58,4 +77,13 @@ class Config
end
{% end %}
end
def preprocess
unless base_url.starts_with? "/"
raise "base url (#{base_url}) should start with `/`"
end
unless base_url.ends_with? "/"
@base_url += "/"
end
end
end

View File

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

View File

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

View File

@@ -2,20 +2,17 @@ require "kemal"
require "../logger"
class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : Logger)
end
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
@logger.debug msg
Logger.debug msg
env
end
def write(msg)
@logger.debug msg
Logger.debug msg
end
private def elapsed_text(elapsed)

View File

@@ -16,10 +16,8 @@ class FS
end
class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env)
if request_path_startswith env, @dirs
if requesting_static_file env
file = FS.get? env.request.path
return call_next env if file.nil?

View File

@@ -11,7 +11,9 @@ class UploadHandler < Kemal::Handler
return call_next env
end
ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
ary = env.request.path.split(File::SEPARATOR).select do |part|
!part.empty?
end
ary[0] = @upload_dir
path = File.join ary

View File

@@ -1,8 +1,10 @@
require "zip"
require "mime"
require "json"
require "uri"
require "./util"
require "./archive"
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
struct Image
property data : Bytes
@@ -25,13 +27,22 @@ class Entry
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = Zip::File.new path
file = ArchiveFile.new path
@pages = file.entries.count do |e|
["image/jpeg", "image/png"].includes? \
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
@id = storage.get_id @zip_path, false
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
end
@@ -57,37 +68,115 @@ class Entry
end
def cover_url
url = "/api/page/#{@title_id}/#{@id}/1"
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = info_url
url = File.join Config.current.base_url, info_url
end
end
url
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
SUPPORTED_IMG_TYPES.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
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
img
end
def next_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == @book.entries.size - 1
@book.entries[idx + 1]
end
def previous_entry
idx = @book.entries.index self
return nil if idx.nil? || idx == 0
@book.entries[idx - 1]
end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, page)
TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {@title => page}
else
info.progress[username][@title] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {@title => Time.utc}
else
info.last_read[username][@title] = Time.utc
end
info.save
end
end
def load_progress(username)
progress = 0
TitleInfo.new @book.dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][@title]?.nil?
progress = info.progress[username][@title]
end
end
[progress, @pages].min
end
def load_percentage(username)
page = load_progress username
page / @pages
end
def load_last_read(username)
last_read = nil
TitleInfo.new @book.dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][@title]?.nil?
last_read = info.last_read[username][@title]
end
end
last_read
end
def finished?(username)
load_progress(username) == @pages
end
def started?(username)
load_progress(username) > 0
end
end
@@ -97,8 +186,17 @@ class Title
encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage,
@logger : Logger, @library : Library)
@id = storage.get_id @dir, true
@library : Library)
id = storage.get_id @dir, true
if id.nil?
id = random_str
storage.insert_id({
path: @dir,
id: id,
is_title: true,
})
end
@id = id
@title = File.basename dir
@encoded_title = URI.encode @title
@title_ids = [] of String
@@ -109,18 +207,22 @@ class Title
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, storage, @logger, library
title = Title.new path, @id, storage, library
next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title
@title_ids << title.id
next
end
if [".zip", ".cbz"].includes? File.extname path
zip_exception = validate_zip path
unless zip_exception.nil?
@logger.warn "File #{path} is corrupted or is not a valid zip " \
"archive. Ignoring it."
@logger.debug "Zip error: #{zip_exception}"
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
unless File.readable? path
Logger.warn "File #{path} is not readable. Please make sure the " \
"file permission is configured correctly."
next
end
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next
end
entry = Entry.new path, self, @id, storage
@@ -173,6 +275,17 @@ class Title
@title_ids.map { |tid| @library.get_title! tid }
end
# Get all entries, including entries in nested titles
def deep_entries
return @entries if title_ids.empty?
@entries + titles.map { |t| t.deep_entries }.flatten
end
def deep_titles
return [] of Title if titles.empty?
titles + titles.map { |t| t.deep_titles }.flatten
end
def parents
ary = [] of Title
tid = @parent_id
@@ -181,7 +294,7 @@ class Title
ary << title
tid = title.parent_id
end
ary
ary.reverse
end
def size
@@ -231,14 +344,14 @@ class Title
end
def cover_url
url = "img/icon.png"
url = "#{Config.current.base_url}img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = info_url
url = File.join Config.current.base_url, info_url
end
end
url
@@ -261,7 +374,7 @@ class Title
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
save_progress username, e.title, e.pages
e.save_progress username, e.pages
end
titles.each do |t|
t.read_all username
@@ -271,58 +384,119 @@ class Title
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
save_progress username, e.title, 0
e.save_progress username, 0
end
titles.each do |t|
t.unread_all username
end
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
def deep_read_page_count(username) : Int32
load_progress_for_all_entries(username).sum +
titles.map { |t| t.deep_read_page_count username }.flatten.sum
end
def deep_total_page_count : Int32
entries.map { |e| e.pages }.sum +
titles.map { |t| t.deep_total_page_count }.flatten.sum
end
def load_percentage(username)
deep_read_page_count(username) / deep_total_page_count
end
def get_continue_reading_entry(username)
in_progress_entries = @entries.select do |e|
load_progress(username, e.title) > 0
end
return nil if in_progress_entries.empty?
latest_read_entry = in_progress_entries[-1]
if load_progress(username, latest_read_entry.title) ==
latest_read_entry.pages
next_entry latest_read_entry
else
latest_read_entry
end
end
def load_progress_for_all_entries(username)
progress = {} of String => Int32
TitleInfo.new @dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
else
info.progress[username][entry] = page
progress = info.progress[username]?
end
@entries.map do |e|
info_progress = 0
if progress && progress.has_key? e.title
info_progress = [progress[e.title], e.pages].min
end
info_progress
end
end
def load_percentage_for_all_entries(username)
progress = load_progress_for_all_entries username
@entries.map_with_index do |e, i|
progress[i] / e.pages
end
end
# === helper methods ===
# Gets the last read entry in the title. If the entry has been completed,
# returns the next entry. Returns nil when no entry has been read yet,
# or when all entries are completed
def get_last_read_entry(username) : Entry?
progress = {} of String => Int32
TitleInfo.new @dir do |info|
progress = info.progress[username]?
end
return if progress.nil?
last_read_entry = nil
@entries.reverse_each do |e|
if progress.has_key? e.title
last_read_entry = e
break
end
end
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry
end
last_read_entry
end
# Equivalent to `@entries.map &. date_added`, but much more efficient
def get_date_added_for_all_entries
da = {} of String => Time
TitleInfo.new @dir do |info|
da = info.date_added
end
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.zip_path
end
TitleInfo.new @dir do |info|
info.date_added = da
info.save
end
@entries.map { |e| da[e.title] }
end
def load_progress(username, entry)
progress = 0
TitleInfo.new @dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end
def deep_entries_with_date_added
da_ary = get_date_added_for_all_entries
zip = @entries.map_with_index do |e, i|
{entry: e, date_added: da_ary[i]}
end
progress
end
def load_percetage(username, entry)
page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry }
return 0.0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
return 0.0 if @entries.empty?
read_pages = total_pages = 0
@entries.each do |e|
read_pages += load_progress username, e.title
total_pages += e.pages
end
read_pages / total_pages
end
def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1
@entries[idx + 1]
return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
end
end
@@ -335,6 +509,8 @@ class TitleInfo
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
@[JSON::Field(ignore: true)]
property dir : String = ""
@@ -367,9 +543,20 @@ end
class Library
property dir : String, title_ids : Array(String), scan_interval : Int32,
logger : Logger, storage : Storage, title_hash : Hash(String, Title)
title_hash : Hash(String, Title)
def initialize(@dir, @scan_interval, @logger, @storage)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
register_mime_types
@dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@title_ids = [] of String
@@ -381,7 +568,7 @@ class Library
start = Time.local
scan
ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60
end
end
@@ -391,6 +578,10 @@ class Library
@title_ids.map { |tid| self.get_title!(tid) }
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
@@ -410,22 +601,96 @@ class Library
def scan
unless Dir.exists? @dir
@logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
storage = Storage.new auto_close: false
(Dir.entries @dir)
.select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, @logger, self }
.map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
@logger.debug "Scan completed"
storage.bulk_insert_ids
storage.close
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
cr_entries = deep_titles
.map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0..11]
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
last_read = e.load_last_read username
pe = e.previous_entry
if last_read.nil? && pe
last_read = pe.load_last_read username
end
{
entry: e,
percentage: e.load_percentage(username),
last_read: last_read,
}
}
# Sort by by last_read, most recent first (nils at the end)
cr_entries.sort { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}
end
alias RA = NamedTuple(
entry: Entry,
percentage: Float64,
grouped_count: Int32)
def get_recently_added_entries(username)
recently_added = [] of RA
last_date_added = nil
titles.map { |t| t.deep_entries_with_date_added }.flatten
.select { |e| e[:date_added] > 1.month.ago }
.sort { |a, b| b[:date_added] <=> a[:date_added] }
.each do |e|
break if recently_added.size > 12
last = recently_added.last?
if last && e[:entry].title_id == last[:entry].title_id &&
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
# A NamedTuple is immutable, so we have to cast it to a Hash first
last_hash = last.to_h
count = last_hash[:grouped_count].as(Int32)
last_hash[:grouped_count] = count + 1
# Setting the percentage to a negative value will hide the
# percentage badge on the card
last_hash[:percentage] = -1.0
recently_added[recently_added.size - 1] = RA.from last_hash
else
last_date_added = e[:date_added]
recently_added << {
entry: e[:entry],
percentage: e[:entry].load_percentage(username),
grouped_count: 1,
}
end
end
recently_added[0..11]
end
end

View File

@@ -8,7 +8,15 @@ class Logger
@@severity : Log::Severity = :info
def initialize(level : String)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
level = Config.current.log_level
{% begin %}
case level.downcase
when "off"
@@ -50,9 +58,16 @@ class Logger
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
end
def self.log(msg)
default.log msg
end
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg }
end
def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg
end
{% end %}
end

View File

@@ -1,6 +1,7 @@
require "http/client"
require "json"
require "csv"
require "../rename"
macro string_properties(names)
{% for name in names %}
@@ -14,6 +15,14 @@ macro parse_strings_from_json(names)
{% end %}
end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex
class Chapter
string_properties ["lang_code", "title", "volume", "chapter"]
@@ -64,16 +73,20 @@ module MangaDex
gname = obj["group_name#{s}"].as_s
@groups << {gid, gname}
end
@full_title = @title
unless @chapter.empty?
@full_title = "Ch.#{@chapter} " + @full_title
end
unless @volume.empty?
@full_title = "Vol.#{@volume} " + @full_title
end
rename_rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
@full_title = rename rename_rule
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
hash = properties_to_hash ["id", "title", "volume", "chapter",
"lang_code", "language", "pages"]
hash["groups"] = @groups.map { |g| g[1] }.join ","
rule.render hash
end
end
class Manga
@@ -111,10 +124,23 @@ module MangaDex
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end
class API
def initialize(@base_url = "https://mangadex.org/api/")
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@base_url = Config.current.mangadex["api_url"].to_s ||
"https://mangadex.org/api/"
@lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0]

View File

@@ -1,5 +1,6 @@
require "./api"
require "sqlite3"
require "zip"
module MangaDex
class PageJob
@@ -79,12 +80,21 @@ module MangaDex
class Queue
property downloader : Downloader?
@path : String
def initialize(@path : String, @logger : Logger)
dir = File.dirname path
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize(db_path : String? = nil)
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
dir = File.dirname @path
unless Dir.exists? dir
@logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it"
Logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
@@ -101,7 +111,7 @@ module MangaDex
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
@logger.error "Error when checking tables in DB: #{e}"
Logger.error "Error when checking tables in DB: #{e}"
raise e
end
end
@@ -254,11 +264,22 @@ module MangaDex
class Downloader
property stopped = false
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
@library_path : String = Config.current.library_path
@downloading = false
def initialize(@queue : Queue, @api : API, @library_path : String,
@wait_seconds : Int32, @retries : Int32,
@logger : Logger)
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@queue = Queue.default
@api = API.default
@queue.downloader = self
spawn do
@@ -270,7 +291,7 @@ module MangaDex
next if job.nil?
download job
rescue e
@logger.error e
Logger.error e
end
end
end
@@ -282,7 +303,7 @@ module MangaDex
begin
chapter = @api.get_chapter(job.id)
rescue e
@logger.error e
Logger.error e
@queue.set_status JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
@@ -292,7 +313,9 @@ module MangaDex
end
@queue.set_pages chapter.pages.size, job
lib_dir = @library_path
manga_dir = File.join lib_dir, chapter.manga.title
rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
@@ -310,16 +333,16 @@ module MangaDex
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
@logger.debug "Downloading #{url}"
Logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
@logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
Logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job
@@ -330,8 +353,8 @@ module MangaDex
page_jobs = [] of PageJob
chapter.pages.size.times do
page_job = channel.receive
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job
if page_job.success
@queue.add_success job
@@ -339,16 +362,16 @@ module MangaDex
@queue.add_fail job
msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job
@logger.error msg
Logger.error msg
end
end
fail_count = page_jobs.count { |j| !j.success }
@logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
writer.close
@logger.debug "cbz File created at #{zip_path}"
Logger.debug "cbz File created at #{zip_path}"
zip_exception = validate_zip zip_path
zip_exception = validate_archive zip_path
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@@ -363,7 +386,7 @@ module MangaDex
end
private def download_page(job : PageJob)
@logger.debug "downloading #{job.url}"
Logger.debug "downloading #{job.url}"
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
@@ -377,7 +400,7 @@ module MangaDex
end
job.success = true
rescue e
@logger.error e
Logger.error e
job.success = false
end
end

View File

@@ -1,41 +1,103 @@
require "./config"
require "./server"
require "./context"
require "./mangadex/*"
require "option_parser"
require "clim"
VERSION = "0.3.0"
MANGO_VERSION = "0.7.2"
config_path = nil
macro common_option
option "-c PATH", "--config=PATH", type: String,
desc: "Path to the config file"
end
OptionParser.parse do |parser|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
macro throw(msg)
puts "ERROR: #{{{msg}}}"
puts
puts "Please see the `--help`."
exit 1
end
parser.on "-v", "--version", "Show version" do
puts "Version #{VERSION}"
exit
end
parser.on "-h", "--help", "Show help" do
puts parser
exit
end
parser.on "-c PATH", "--config=PATH",
"Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
config_path = path
class CLI < Clim
main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
usage "mango [sub_command] [options]"
help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v"
common_option
run do |opts|
Config.load(opts.config).set_current
MangaDex::Downloader.default
# empty ARGV so it won't be passed to Kemal
ARGV.clear
server = Server.new
server.start
end
sub "admin" do
desc "Run admin tools"
usage "mango admin [tool]"
help short: "-h"
run do |opts|
puts opts.help_string
end
sub "user" do
desc "User management tool"
usage "mango admin user [arguments] [options]"
help short: "-h"
argument "action", type: String,
desc: "Action to perform. Can be add/delete/update/list"
argument "username", type: String,
desc: "Username to update or delete"
option "-u USERNAME", "--username=USERNAME", type: String,
desc: "Username"
option "-p PASSWORD", "--password=PASSWORD", type: String,
desc: "Password"
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
common_option
run do |opts, args|
Config.load(opts.config).set_current
storage = Storage.new nil, false
case args.action
when "add"
throw "Options `-u` and `-p` required." if opts.username.nil? ||
opts.password.nil?
storage.new_user opts.username.not_nil!,
opts.password.not_nil!, opts.admin
when "delete"
throw "Argument `username` required." if args.username.nil?
storage.delete_user args.username
when "update"
throw "Argument `username` required." if args.username.nil?
username = opts.username || args.username
password = opts.password || ""
storage.update_user args.username, username.not_nil!,
password.not_nil!, opts.admin
when "list"
users = storage.list_users
name_length = users.map(&.[0].size).max? || 0
l_cell_width = ["username".size, name_length].max
r_cell_width = "admin access".size
header = " #{"username".ljust l_cell_width} | admin access "
puts "-" * header.size
puts header
puts "-" * header.size
users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \
"#{admin.to_s.ljust r_cell_width} "
end
puts "-" * header.size
when nil
puts opts.help_string
else
throw "Unknown action \"#{args.action}\"."
end
end
end
end
end
end
config = Config.load config_path
logger = Logger.new config.log_level
storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
logger
api = MangaDex::API.new config.mangadex["api_url"].to_s
MangaDex::Downloader.new queue, api, config.library_path,
config.mangadex["download_wait_seconds"].to_i,
config.mangadex["download_retries"].to_i, logger
context = Context.new config, logger, library, storage, queue
server = Server.new context
server.start
CLI.start(ARGV)

147
src/rename.cr Normal file
View File

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

View File

@@ -1,7 +1,7 @@
require "./router"
class AdminRouter < Router
def setup
def initialize
get "/admin" do |env|
layout "admin"
end
@@ -32,29 +32,15 @@ class AdminRouter < Router
# would not contain `admin`
admin = !env.params.body["admin"]?.nil?
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
@context.storage.new_user username, password, admin
env.redirect "/admin/user"
redirect env, "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"error" => e.message})
env.redirect redirect_url.to_s
redirect env, redirect_url.to_s
end
post "/admin/user/edit/:original_username" do |env|
@@ -65,38 +51,21 @@ class AdminRouter < Router
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
if password.size != 0
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
@context.storage.update_user \
original_username, username, password, admin
env.redirect "/admin/user"
redirect env, "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message})
env.redirect redirect_url.to_s
redirect env, redirect_url.to_s
end
get "/admin/downloads" do |env|
base_url = @context.config.mangadex["base_url"]
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager"
end
end

View File

@@ -3,7 +3,7 @@ require "../mangadex/*"
require "../upload"
class APIRouter < Router
def setup
def initialize
get "/api/page/:tid/:eid/:page" do |env|
begin
tid = env.params.url["tid"]
@@ -80,7 +80,7 @@ class APIRouter < Router
if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil!
raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page
entry.save_progress username, page
elsif page == 0
title.unread_all username
else
@@ -123,7 +123,7 @@ class APIRouter < Router
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
api = MangaDex::API.default
manga = api.get_manga id
send_json env, manga.to_info_json
rescue e
@@ -224,13 +224,13 @@ class APIRouter < Router
entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil!
unless ["image/jpeg", "image/png"].includes? \
unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG"
end
ext = File.extname filename
upload = Upload.new @context.config.upload_path, @context.logger
upload = Upload.new Config.current.upload_path
url = upload.path_to_url upload.save "img", ext, part.body
if url.nil?

View File

@@ -1,19 +1,19 @@
require "./router"
class MainRouter < Router
def setup
def initialize
get "/login" do |env|
render "src/views/login.ecr"
base_url = Config.current.base_url
render "src/views/login.html.ecr"
end
get "/logout" do |env|
begin
cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value
env.session.delete_string "token"
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
env.redirect "/login"
redirect env, "/login"
end
end
@@ -23,21 +23,26 @@ class MainRouter < Router
password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil!
cookie = HTTP::Cookie.new "token", token
cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie
env.redirect "/"
env.session.string "token", token
callback = env.session.string? "callback"
if callback
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/"
end
rescue
env.redirect "/login"
redirect env, "/login"
end
end
get "/" do |env|
get "/library" do |env|
begin
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
env.response.status_code = 500
@@ -48,9 +53,8 @@ class MainRouter < Router
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
percentage = title.entries.map { |e|
title.load_percetage username, e.title
}
percentage = title.load_percentage_for_all_entries username
title_percentage = title.titles.map &.load_percentage username
layout "title"
rescue e
@context.error e
@@ -59,8 +63,24 @@ class MainRouter < Router
end
get "/download" do |env|
base_url = @context.config.mangadex["base_url"]
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/" do |env|
begin
username = get_username env
continue_reading = @context
.library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username
titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0
layout "home"
rescue e
@context.error e
env.response.status_code = 500
end
end
end
end

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

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

View File

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

View File

@@ -1,6 +1,3 @@
require "../context"
class Router
def initialize(@context : Context)
end
@context : Context = Context.default
end

View File

@@ -1,11 +1,39 @@
require "kemal"
require "./context"
require "kemal-session"
require "./library"
require "./handlers/*"
require "./util"
require "./routes/*"
class Context
property library : Library
property storage : Storage
property queue : MangaDex::Queue
def self.default : self
unless @@default
@@default = new
end
@@default.not_nil!
end
def initialize
@storage = Storage.default
@library = Library.default
@queue = MangaDex::Queue.default
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
Logger.{{lvl.id}} msg
end
{% end %}
end
class Server
def initialize(@context : Context)
@context : Context = Context.default
def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
@@ -14,26 +42,37 @@ class Server
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message"
end
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
MainRouter.new(@context).setup
AdminRouter.new(@context).setup
ReaderRouter.new(@context).setup
APIRouter.new(@context).setup
{% if flag?(:release) %}
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
{% end %}
MainRouter.new
AdminRouter.new
ReaderRouter.new
APIRouter.new
OPDSRouter.new
Kemal.config.logging = false
add_handler LogHandler.new @context.logger
add_handler LogHandler.new
add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new @context.config.upload_path
add_handler UploadHandler.new Config.current.upload_path
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files."
serve_static false
add_handler StaticHandler.new
{% end %}
Kemal::Session.config do |c|
c.timeout = 365.days
c.secret = Config.current.session_secret
c.cookie_name = "mango-sessid-#{Config.current.port}"
c.path = Config.current.base_url
end
end
def start
@@ -41,7 +80,7 @@ class Server
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}
Kemal.config.port = @context.config.port
Kemal.config.port = Config.current.port
Kemal.run
end
end

View File

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

View File

@@ -1,10 +1,10 @@
require "./util"
class Upload
def initialize(@dir : String, @logger : Logger)
def initialize(@dir : String)
unless Dir.exists? @dir
@logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it"
Logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
end
@@ -19,7 +19,7 @@ class Upload
file_path = File.join full_dir, filename
unless Dir.exists? full_dir
@logger.debug "creating directory #{full_dir}"
Logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir
end
@@ -50,7 +50,7 @@ class Upload
end
if ary.empty?
@logger.warn "File #{path} is not in the upload directory #{@dir}"
Logger.warn "File #{path} is not in the upload directory #{@dir}"
return
end

View File

@@ -2,19 +2,25 @@ require "big"
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def requesting_static_file(env)
request_path_startswith env, STATIC_DIRS
end
macro layout(name)
base_url = Config.current.base_url
begin
cookie = env.request.cookies.find { |c| c.name == "token" }
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.ecr", "src/views/layout.ecr"
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end
end
@@ -25,8 +31,8 @@ end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
end
def send_json(env, json)
@@ -34,6 +40,10 @@ def send_json(env, json)
env.response.print json
end
def send_attachment(env, path)
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
@@ -84,12 +94,9 @@ def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
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
def validate_zip(path : String) : Exception?
file = Zip::File.new path
def validate_archive(path : String) : Exception?
file = ArchiveFile.new path
file.check
file.close
return
rescue e
@@ -99,3 +106,60 @@ end
def random_str
UUID.random.to_s.gsub "-", ""
end
def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def validate_username(username)
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
end
end
def validate_password(password)
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.html.ecr"
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end
def register_mime_types
{
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar",
}.each do |k, v|
MIME.register k, v
end
end

View File

@@ -1,5 +1,5 @@
<ul class="uk-list uk-list-large uk-list-divider">
<li data-url="/admin/user">User Managerment</li>
<li data-url="<%= base_url %>admin/user">User Managerment</li>
<li onclick="if(!scanning){scan()}">
<span id="scan">Scan Library Files</span>
<span id="scan-status" class="uk-align-right">
@@ -7,12 +7,13 @@
<span hidden></span>
</span>
</li>
<li data-url="/admin/downloads">Download Manager</li>
<li data-url="<%= base_url %>admin/downloads">Download Manager</li>
</ul>
<hr class="uk-divider-icon">
<a class="uk-button uk-button-danger" href="/logout">Log Out</a>
<p class="uk-text-meta">Version: v<%= MANGO_VERSION %></p>
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %>
<script src="/js/admin.js"></script>
<script src="<%= base_url %>js/admin.js"></script>
<% end %>

View File

@@ -0,0 +1,50 @@
<% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
<% grouped_count = item[:grouped_count] %>
<% if grouped_count == 1 %>
<% item = item[:entry] %>
<% else %>
<% item = item[:entry].book %>
<% end %>
<% else %>
<% grouped_count = 1 %>
<% end %>
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress %>"
<% if item.is_a? Entry %>
id="<%= item.id %>"
<% end %>>
<a class="acard"
<% unless item.is_a? Entry %>
href="<%= base_url %>book/<%= item.id %>"
<% end %>>
<div class="uk-card uk-card-default"
<% if item.is_a? Entry %>
onclick="showModal(&quot;<%= item.encoded_path %>&quot;, '<%= item.pages %>', <%= (progress * 100).round(1) %>, &quot;<%= item.book.encoded_display_name %>&quot;, &quot;<%= item.encoded_display_name %>&quot;, '<%= item.title_id %>', '<%= item.id %>')"
<% end %>>
<div class="uk-card-media-top">
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<% unless progress < 0 || progress > 100 %>
<div class="uk-card-badge uk-label"><%= (progress * 100).round(1) %>%</div>
<% end %>
<h3 class="uk-card-title break-word" data-title="<%= item.display_name.gsub("\"", "&quot;") %>"><%= item.display_name %></h3>
<% if item.is_a? Entry %>
<p><%= item.pages %> pages</p>
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p><%= item.size %> entries</p>
<% else %>
<p><%= grouped_count %> new entries</p>
<% end %>
<% end %>
</div>
</div>
</a>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<% hash.each do |k, v| %>
<option id="<%= k %>-up">â–˛ <%= v %></option>
<option id="<%= k %>-down">â–Ľ <%= v %></option>
<% end %>
</select>
</div>

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,49 +0,0 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<%- end -%>
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>

View File

@@ -1,18 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="/css/mango.css" />
<script defer src="/js/fontawesome.min.js"></script>
<script defer src="/js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head>
<%= render_component "head" %>
<body>
<div class="uk-offcanvas-content">
@@ -20,14 +9,15 @@
<div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li>
<% end %>
<hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
@@ -39,19 +29,20 @@
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li>
<% end %>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
@@ -60,16 +51,18 @@
</div>
<div class="uk-section uk-section-small">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<div id="alert"></div>
<%= content %>
</div>
</div>
<script>
setTheme(getTheme());
</script>
<script>
setTheme(getTheme());
const base_url = "<%= base_url %>";
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
</html>

View File

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

View File

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

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

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

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:index</id>
<link rel="self" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title>Library</title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
</feed>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:<%= title.id %></id>
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title><%= HTML.escape(title.display_name) %></title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% title.titles.each do |t| %>
<entry>
<title><%= HTML.escape(t.display_name) %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
<% title.entries.each do |e| %>
<entry>
<title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id>
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.title_id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.title_id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.title_id %>" />
</entry>
<% end %>
</feed>

View File

@@ -1,30 +1,22 @@
<!DOCTYPE html>
<html class="reader-bg">
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="/css/mango.css" />
</head>
<%= render_component "head" %>
<body>
<script src="/js/theme.js"></script>
<div class="uk-section uk-section-default uk-section-small reader-bg">
<div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%>
<%- urls.each_with_index do |url, i| -%>
<img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
<%- end -%>
<%- if next_url -%>
<%- end -%>
<%- if next_url -%>
<a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
<%- end -%>
</div>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
@@ -34,18 +26,19 @@
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title">Options</h3>
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<p id="progress-label"></p>
</div>
<div class="uk-margin">
<p id="progress-label"></p>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
<div class="uk-form-controls">
<select id="page-select" class="uk-select">
<%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option>
<option value="<%= p %>"><%= p %></option>
<%- end -%>
</select>
</div>
@@ -56,10 +49,14 @@
</div>
</div>
</div>
<script>
const base_url = "<%= base_url %>"
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="/js/reader.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script>
</body>
</html>

View File

@@ -1,159 +0,0 @@
<div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
</div>
<ul class="uk-breadcrumb">
<li><a href="/">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="/book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="auto-up">â–˛ Auto</option>
<option id="auto-down">â–Ľ Auto</option>
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- title.titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
<a class="acard">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top">
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<div id="modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
&nbsp;
<% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">
<p>Read</p>
<p uk-margin>
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
<a id="continue-btn" class="uk-button uk-button-primary"></a>
</p>
<p>Progress</p>
<p uk-margin>
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
</p>
</div>
</div>
</div>
<div id="edit-modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word" id="modal-title">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-width-1-2@s">
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="image/jpeg, image/png">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical">
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script>
<script src="/js/alert.js"></script>
<script src="/js/title.js"></script>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>

103
src/views/title.html.ecr Normal file
View File

@@ -0,0 +1,103 @@
<div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
</div>
<ul class="uk-breadcrumb">
<li><a href="<%= base_url %>library">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul>
<p class="uk-text-meta"><%= title.size %> entries found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"name" => "Name",
"date" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %>
<%= render_component "card" %>
<% end %>
<% title.entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<%= render_component "entry-modal" %>
<div id="edit-modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-width-1-2@s">
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="<%= SUPPORTED_IMG_TYPES.join ", " %>">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical">
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@@ -1,54 +0,0 @@
<form action="/admin/user/edit" method="post" accept-charset="utf-8">
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Username</label>
<input class="uk-input" type="text" name="username"
<%- if username -%>
value=<%= username %>
<%- end -%>
>
</div>
<%- if new_user -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Password</label>
<input class="uk-input" type="password" name="password">
</div>
<%- end -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
<input class="uk-checkbox" type="checkbox" name="admin"
<%- if admin == true -%>
checked
<%- end -%>
>
</div>
<%- if !new_user -%>
<div>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #change-password">Change Password</button>
<div id="change-password" class="uk-margin" hidden>
<label class="uk-form-label" for="form-stacked-text">New Password</label>
<input class="uk-input" type="password" name="password">
</div>
</div>
<%- end -%>
<hr class="uk-divider-icon">
<input type="submit" value="Save" class="uk-button uk-button-primary">
</form>
<% content_for "script" do %>
<script>
var username;
var error;
<%- if !new_user -%>
username = '/<%= username %>';
<%- end -%>
<%- if error -%>
error = '<%= error %>';
<%- end -%>
</script>
<script src="/js/alert.js"></script>
<script src="/js/user-edit.js"></script>
<% end %>

View File

@@ -0,0 +1,46 @@
<form action="<%= base_url %>admin/user/edit" method="post" accept-charset="utf-8">
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Username</label>
<input class="uk-input" type="text" name="username" <%- if username -%> value=<%= username %> <%- end -%>>
</div>
<%- if new_user -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Password</label>
<input class="uk-input" type="password" name="password">
</div>
<%- end -%>
<div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
<input class="uk-checkbox" type="checkbox" name="admin" <%- if admin == true -%> checked <%- end -%>>
</div>
<%- unless new_user -%>
<div>
<button class="uk-button uk-button-default" type="button" uk-toggle="target: #change-password">Change Password</button>
<div id="change-password" class="uk-margin" hidden>
<label class="uk-form-label" for="form-stacked-text">New Password</label>
<input class="uk-input" type="password" name="password">
</div>
</div>
<%- end -%>
<hr class="uk-divider-icon">
<input type="submit" value="Save" class="uk-button uk-button-primary">
</form>
<% content_for "script" do %>
<script>
var username;
var error;
<%- if !new_user -%>
username = '/<%= username %>';
<%- end -%>
<%- if error -%>
error = '<%= error %>';
<%- end -%>
</script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user-edit.js"></script>
<% end %>

View File

@@ -1,31 +0,0 @@
<table class="uk-table uk-table-divider">
<thead>
<tr>
<th>Username</th>
<th>Admin Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%- users.each do |u| -%>
<tr>
<td><%= u[0] %></td>
<td><%= u[1] %></td>
<td>
<a href="/admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
<%- if u[0] != username %>
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
<%- end %>
</td>
</tr>
<%- end -%>
</tbody>
</table>
<a href="/admin/user/edit" class="uk-button uk-button-primary">New User</a>
<% content_for "script" do %>
<script src="/js/alert.js"></script>
<script src="/js/user.js"></script>
<% end %>

31
src/views/user.html.ecr Normal file
View File

@@ -0,0 +1,31 @@
<table class="uk-table uk-table-divider">
<thead>
<tr>
<th>Username</th>
<th>Admin Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%- users.each do |u| -%>
<tr>
<td><%= u[0] %></td>
<td><%= u[1] %></td>
<td>
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
<%- if u[0] != username %>
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
<%- end %>
</td>
</tr>
<%- end -%>
</tbody>
</table>
<a href="<%= base_url %>admin/user/edit" class="uk-button uk-button-primary">New User</a>
<% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/user.js"></script>
<% end %>