Compare commits

..

130 Commits

Author SHA1 Message Date
Alex Ling 952aa0c6ca Fix linter 2021-01-17 15:59:42 +00:00
Alex Ling bd81c2e005 Fix incorrect migration SQL 2021-01-17 15:58:13 +00:00
Alex Ling b471ed2fa0 Upgrade MG 2021-01-17 15:49:10 +00:00
Alex Ling 7507ab64ad Bump version to v0.19.0 2021-01-17 08:34:35 +00:00
Alex Ling e4587d36bc Fix linter 2021-01-17 08:25:01 +00:00
Alex Ling 7d6d3640ad Disable the tagging UI for non-admin users 2021-01-17 08:16:40 +00:00
Alex Ling 3071d44e32 Fix admin API bypassing 2021-01-17 08:10:43 +00:00
Alex Ling 7a09c9006a Set up foreign keys 2021-01-17 04:47:06 +00:00
Alex Ling 959560c7a7 Add titles and move insert_ids to class variable
This fixes the bug where the new ids are not saved
2021-01-17 04:45:55 +00:00
Alex Ling ff679b30d8 Capitalize the UNIQUE keyword 2021-01-17 04:41:05 +00:00
Alex Ling f7a360c2d8 Proper DB migration 2021-01-16 17:11:57 +00:00
Alex Ling 1065b430e3 Rewrite tagging UI with suggestions (#146) 2021-01-14 13:08:50 +00:00
Alex Ling 5abf7032a5 Use less 2021-01-14 13:04:57 +00:00
Alex Ling 44336c546a Bump version to v0.18.3 2021-01-12 10:14:12 +00:00
Alex Ling a4c6e6611c Try WSS first, and fallback to WS (#144) 2021-01-12 10:13:06 +00:00
Alex Ling 0b457a2797 Merge branch 'master' of https://github.com/hkalexling/Mango 2021-01-11 15:37:34 +00:00
Alex Ling 653751bede Merge branch 'dev' 2021-01-11 15:37:06 +00:00
Alex Ling a02bf4a81e Bump version to v0.18.2 2021-01-11 15:22:51 +00:00
Alex Ling 5271d12f4c Respect base URL in WS connections 2021-01-11 15:05:58 +00:00
Alex Ling c2e2f0b9b3 Merge pull request #143 from hkalexling/all-contributors/add-h45h74x
docs: add h45h74x as a contributor
2021-01-11 19:32:47 +08:00
allcontributors[bot] 72d319902e docs: update .all-contributorsrc [skip ci] 2021-01-11 11:31:21 +00:00
allcontributors[bot] bbd0fd68cb docs: update README.md [skip ci] 2021-01-11 11:31:20 +00:00
Alex Ling 0fb1e1598d Remove sourcerer.io HoF and use all-contributors
[skip ci]
RIP sourcerer.io https://github.com/sourcerer-io/sourcerer-app/issues/632
2021-01-11 11:28:30 +00:00
Alex Ling 4645582f5d Bump version to v0.18.1 2021-01-11 05:29:28 +00:00
Alex Ling ac9c51dd33 Remove non-existing #root from css selectors (#142) 2021-01-11 05:28:44 +00:00
Alex Ling f51d27860a Validate input index before flipping page 2021-01-09 15:49:34 +00:00
Alex Ling 4a7439a1ea Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-09 06:40:49 +00:00
Alex Ling 00e19399d7 Check login is disabled before accessing default username 2021-01-09 06:35:26 +00:00
Alex Ling cb723acef7 Update config in README 2021-01-09 06:35:11 +00:00
Alex Ling 794bed12bd Merge pull request #139 from h45h74x/feature/plugin-helper-function-post
Added post helper function
2021-01-09 14:30:52 +08:00
Simon bae8220e75 Added post helper function 2021-01-08 21:17:58 +01:00
Alex Ling 0cc5e1626b Fix broken buttons on download manager page 2021-01-08 11:38:51 +00:00
Alex Ling da0ca665a6 Mark entry as read when exiting reader at the end 2021-01-08 11:38:25 +00:00
Alex Ling a91cf21aa9 Bump version to v0.18.0 2021-01-07 16:27:22 +00:00
Alex Ling 39b2636711 Sort tags in title 2021-01-07 16:21:23 +00:00
Alex Ling 2618d8412b Update the API doc to include margin in dimensions 2021-01-07 16:06:43 +00:00
Alex Ling 445ebdf357 Merge pull request #136 from h45h74x/feature/adjustable-page-gaps
Feature/adjustable page gaps
2021-01-07 01:11:34 +08:00
Simon 60134dc364 Formatting 2021-01-06 17:44:02 +01:00
Simon aa70752244 Moved margin value to the dimensions API 2021-01-06 17:30:55 +01:00
Simon 0f39535097 Added new entry in example config 2021-01-06 15:28:09 +01:00
Simon e086bec9da Added adjustable page gaps via config 2021-01-06 15:27:48 +01:00
Alex Ling dcdcf29114 Sort tags on the tags page 2021-01-05 07:34:31 +00:00
Alex Ling c5c73ddff3 Rewrite download-manager.js 2021-01-01 09:19:16 +00:00
Alex Ling f18ee4284f Rewrite admin.js with Alpine component 2021-01-01 09:04:53 +00:00
Alex Ling 0fbc11386e Fix broken "Exit Reader" button 2021-01-01 09:04:18 +00:00
Alex Ling a68282b4bf Rewrite reader.js with a reusable alpine function 2020-12-31 16:21:00 +00:00
Alex Ling e64908ad06 Remove the outdated styleModal call 2020-12-31 14:08:14 +00:00
Alex Ling af0913df64 Dynamic HTML title 2020-12-31 14:08:14 +00:00
Alex Ling 5685dd1cc5 Use tallboy to draw CLI table 2020-12-30 16:44:23 +00:00
Alex Ling af2fd2a66a Remove the Context and Router classes 2020-12-30 15:58:51 +00:00
Alex Ling db2a51a26b Clean up library classes 2020-12-30 15:23:38 +00:00
Alex Ling cf930418cb Update rename spec 2020-12-30 12:53:48 +00:00
Alex Ling 911848ad11 Merge branch 'feature/tagging' into dev 2020-12-30 11:15:44 +00:00
Alex Ling 93f745aecb Only admins can add or delete tags 2020-12-30 11:13:43 +00:00
Alex Ling 981a1f0226 Add /tags to nav bar 2020-12-30 11:13:43 +00:00
Alex Ling 8188456788 Finish tagging 2020-12-30 11:13:43 +00:00
Alex Ling 1eace2c64c Add the /tags/:tag page 2020-12-30 11:13:43 +00:00
Alex Ling c6ee5409f8 Trim input tag 2020-12-30 11:13:43 +00:00
Alex Ling b05ed57762 Add API endpoints for tags 2020-12-30 11:13:43 +00:00
Alex Ling 0f1d1099f6 Add unique constraint to tags and error handling 2020-12-30 11:13:43 +00:00
Alex Ling 40a24f4247 Add tags to the web UI 2020-12-30 11:13:43 +00:00
Alex Ling a6862e86d4 Update alpine 2020-12-30 11:13:43 +00:00
Alex Ling bfc1b697bd Add tag related methods for Title 2020-12-30 11:13:43 +00:00
Alex Ling 276f62cb76 Update DB for tags 2020-12-30 11:13:43 +00:00
Alex Ling 45a81ad5f6 Display the entries and sub-titles count 2020-12-30 11:13:43 +00:00
Alex Ling ce88acb9e5 Simplify the request_path_startswith helper method 2020-12-30 11:13:43 +00:00
Alex Ling bd34b803f1 Tokens take precedence over default user setting 2020-12-30 11:13:43 +00:00
Alex Ling 2559f65f35 Display the entries and sub-titles count 2020-12-29 04:33:55 +00:00
Alex Ling 93c21ea659 Simplify the request_path_startswith helper method 2020-12-28 16:29:29 +00:00
Alex Ling 85ad38c321 Allow disable login 2020-12-28 16:13:51 +00:00
Alex Ling b6a204f5bd Escape illegal filename characters in Windows 2020-12-28 15:20:09 +00:00
Alex Ling f7b8e2d852 Bump version to v0.17.1 2020-12-27 09:46:14 +00:00
Alex Ling 946017c8bd Fix function redeclaration 2020-12-27 09:42:06 +00:00
Alex Ling ec5256dabd Improve batch mark UX (#97) 2020-12-27 09:42:06 +00:00
Alex Ling 4e707076a1 By default use the system theme setting (#111) 2020-12-27 09:42:06 +00:00
Alex Ling 66a3cc268b Merge branch 'master' into dev 2020-12-26 09:34:23 +00:00
Alex Ling 96949905b9 Cache entry display names
This improves the title page load time (#116)
2020-12-26 09:32:03 +00:00
Alex Ling 30c0199039 Merge branch 'dev' 2020-12-26 07:33:04 +00:00
Alex Ling 7a7cb78f82 Check bool environment variables are "1" or "true" 2020-12-26 07:11:10 +00:00
Alex Ling 8931ba8c43 Bump version to 0.17.0 2020-12-26 04:02:58 +00:00
Alex Ling d50981c151 Fix typos 2020-12-26 04:01:57 +00:00
Alex Ling df4deb1415 Allow proxy with authentication 2020-12-22 02:32:07 +00:00
Alex Ling aa5e999ed4 Allow users to disable SSL verification 2020-12-21 06:04:26 +00:00
Alex Ling 84d4b0c529 Switch to redoc and update API description 2020-12-21 06:04:26 +00:00
Alex Ling d3e5691478 Add overall description of the API 2020-12-14 15:20:50 +00:00
Alex Ling 1000b02ae0 Exclude /src/routes/api.cr from line width check 2020-12-14 14:59:18 +00:00
Alex Ling 1f795889a9 Move the entry download route to API 2020-12-14 13:03:23 +00:00
Alex Ling d33b45233a Use the correct verbs in the API 2020-12-14 12:49:56 +00:00
Alex Ling 4f6df5b9a3 Rename bulk-progress to bulk_progress 2020-12-14 11:54:25 +00:00
Alex Ling 341b586cb3 Add API documentation 2020-12-14 11:09:38 +00:00
Alex Ling 9dcc9665ce Cancel a download job when deleted from web UI 2020-12-12 16:15:16 +00:00
Alex Ling 1cd90926df Bind boolean attributes 2020-12-11 10:22:08 +00:00
Alex Ling ac1ff61e6d Move theme.js to common.js
This reduces the number of JS files to include when loading
2020-12-11 10:11:39 +00:00
Alex Ling 6ea41f79e9 Simplify the showControl calls on reader page 2020-12-11 09:47:32 +00:00
Alex Ling dad02a2a30 Move getProp and setProp to common.js 2020-12-11 09:46:56 +00:00
Alex Ling 280490fb36 Rewrite the download manager page 2020-12-11 07:46:47 +00:00
Alex Ling 455315a362 Upgrade to Crystal 0.35.1 2020-12-11 07:46:47 +00:00
Alex Ling df51406638 Use $GITHUB_ENV [skip ci] 2020-11-24 13:43:54 +08:00
Alex Ling 531d42ef18 [skip ci] enable set-env
https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/
2020-11-24 13:38:15 +08:00
Alex Ling 2645e8cd05 Merge branch 'dev' 2020-11-24 05:31:06 +00:00
Alex Ling b2dc44a919 Reverse J and K for page navigation 2020-11-24 05:09:06 +00:00
Alex Ling c8db397a3b Bump version to v0.16.0 2020-11-24 04:30:47 +00:00
Alex Ling 6384d4b77a Log "DB optimization finished" in the info level 2020-11-24 04:05:07 +00:00
Alex Ling 1039732d87 Log the full file path in error messages (#123) 2020-11-24 04:03:53 +00:00
Alex Ling 011123f690 Allow keyboard navigation on reader page (#124) 2020-11-24 03:57:38 +00:00
Alex Ling e602a35b0c Merge branch 'dev' 2020-11-02 16:32:08 +00:00
Alex Ling 7792d3426e Bump version to v0.15.1 2020-11-01 09:22:05 +00:00
Alex Ling b59c8f85ad Fix scroller issues in continuous reader (#121) 2020-10-31 04:29:46 +00:00
Alex Ling 18834ac28e Set thumbnail size and mimetype 2020-10-29 04:06:44 +00:00
Alex Ling bf68e32ac8 Merge branch 'dev' 2020-10-25 07:57:26 +00:00
Alex Ling 54eb041fe4 Update README 2020-10-25 07:29:19 +00:00
Alex Ling 57d8c100f9 Bump version to v0.15.0 2020-10-25 07:22:38 +00:00
Alex Ling 56d973b99d Get progress when page loads and when post 2020-10-25 07:21:08 +00:00
Alex Ling 670e5cdf6a Better logging when optimizing DB 2020-10-25 07:09:37 +00:00
Alex Ling 1b35392f9c Remove unnecessary properties 2020-10-25 07:09:21 +00:00
Alex Ling c4e1ffe023 Trigger thumbnail generation from the admin page 2020-10-25 05:41:27 +00:00
Alex Ling 44f4959477 Finish thumbnail generation and DB optimization
(#93)
2020-10-24 04:13:11 +00:00
Alex Ling 0582b57d60 Add config options for optimization tasks 2020-10-24 03:50:26 +00:00
Alex Ling 83d96fd2a1 Add the route to serve thumbnails 2020-10-23 12:30:47 +00:00
Alex Ling 8ac89c420c Add helper methods for thumbnail generation 2020-10-23 12:30:29 +00:00
Alex Ling 968c2f4ad5 Update DB to save thumbnails 2020-10-23 12:29:20 +00:00
Alex Ling ad940f30d5 Update image_size.cr to 0.4.0 for better err msg 2020-10-23 12:21:05 +00:00
Alex Ling 308ad4e063 Only truncate visible titles to improve load time 2020-10-20 14:36:56 +00:00
Alex Ling 4d709b7eb5 Update default config in README 2020-10-18 12:53:43 +00:00
Alex Ling 5760ad924e Bump version to v0.14.0 2020-10-18 12:22:26 +00:00
Alex Ling fff171c8c9 Bump version to v0.13.0 2020-10-18 11:39:24 +00:00
Alex Ling 44ff566a1d Merge branch 'feature/paged-reader' into dev 2020-10-15 11:52:15 +00:00
Alex Ling 853f422964 Configurable read timeout (#108) 2020-10-15 11:51:04 +00:00
Alex Ling 3bb0917374 Allow /manga/<id> URL for MangaDex 2020-10-15 11:38:22 +00:00
Alex Ling a86f0d0f34 Add paged reading mode 2020-10-09 10:09:42 +00:00
74 changed files with 2731 additions and 1059 deletions
+102
View File
@@ -0,0 +1,102 @@
{
"projectName": "Mango",
"projectOwner": "hkalexling",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"commitConvention": "none",
"contributors": [
{
"login": "hkalexling",
"name": "Alex Ling",
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
"profile": "https://github.com/hkalexling/",
"contributions": [
"code",
"doc",
"infra"
]
},
{
"login": "jaredlt",
"name": "jaredlt",
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
"profile": "https://github.com/jaredlt",
"contributions": [
"code",
"ideas",
"design"
]
},
{
"login": "shincurry",
"name": "ココロ",
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
"profile": "https://windisco.com/",
"contributions": [
"infra"
]
},
{
"login": "noirscape",
"name": "Valentijn",
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
"profile": "https://catgirlsin.space/",
"contributions": [
"infra"
]
},
{
"login": "flying-sausages",
"name": "flying-sausages",
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
"profile": "https://github.com/flying-sausages",
"contributions": [
"doc",
"ideas"
]
},
{
"login": "XavierSchiller",
"name": "Xavier",
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
"profile": "https://github.com/XavierSchiller",
"contributions": [
"infra"
]
},
{
"login": "WROIATE",
"name": "Jarao",
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
"profile": "https://github.com/WROIATE",
"contributions": [
"infra"
]
},
{
"login": "Leeingnyo",
"name": "이인용",
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
"profile": "https://github.com/Leeingnyo",
"contributions": [
"code"
]
},
{
"login": "h45h74x",
"name": "Simon",
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
"profile": "http://h45h74x.eu.org",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.34.0-alpine image: crystallang/crystal:0.35.1-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@master - uses: actions/checkout@master
- name: Get release version - name: Get release version
id: get_version id: get_version
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10}) run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
- name: Publish to Dockerhub - name: Publish to Dockerhub
uses: elgohr/Publish-Docker-Github-Action@master uses: elgohr/Publish-Docker-Github-Action@master
with: with:
+1
View File
@@ -12,3 +12,4 @@ mango
public/css/uikit.css public/css/uikit.css
public/img/*.svg public/img/*.svg
public/js/*.min.js public/js/*.min.js
public/css/*.css
+1 -1
View File
@@ -1,4 +1,4 @@
FROM crystallang/crystal:0.34.0-alpine AS builder FROM crystallang/crystal:0.35.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
+1 -1
View File
@@ -2,7 +2,7 @@ FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
+1 -1
View File
@@ -2,7 +2,7 @@ FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
+34 -3
View File
@@ -12,6 +12,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Thumbnail generation
- Built-in [MangaDex](https://mangadex.org/) downloader - Built-in [MangaDex](https://mangadex.org/) downloader
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites - Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app - The web reader is responsive and works well on mobile, so there is no need for a mobile app
@@ -51,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.12.3 Mango - Manga Server and Web Reader. Version 0.19.0
Usage: Usage:
@@ -76,11 +77,19 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
--- ---
port: 9000 port: 9000
base_url: / base_url: /
session_secret: mango-session-secret
library_path: ~/mango/library library_path: ~/mango/library
db_path: ~/mango/mango.db db_path: ~/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
mangadex: mangadex:
base_url: https://mangadex.org base_url: https://mangadex.org
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
@@ -91,8 +100,9 @@ mangadex:
manga_rename_rule: '{title}' manga_rename_rule: '{title}'
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
### Library Structure ### Library Structure
@@ -147,5 +157,26 @@ Mobile UI:
## Contributors ## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions. Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
</tr>
</table>
[![](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) <!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \ [ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \
&& echo "The above lines exceed the 80 characters limit" \ && echo "The above lines exceed the 80 characters limit" \
|| exit 0 || exit 0
+85
View File
@@ -0,0 +1,85 @@
class ForeignKeys < MG::Base
def up : String
<<-SQL
-- add foreign key to tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag),
FOREIGN KEY (id) REFERENCES titles (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
-- add foreign key to thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
FOREIGN KEY (id) REFERENCES ids (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
-- remove foreign key from thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
-- remove foreign key from tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
SQL
end
end
+19
View File
@@ -0,0 +1,19 @@
class CreateIds < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
is_title INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
DROP TABLE ids;
SQL
end
end
+19
View File
@@ -0,0 +1,19 @@
class CreateTags < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
SQL
end
def down : String
<<-SQL
DROP TABLE tags;
SQL
end
end
+20
View File
@@ -0,0 +1,20 @@
class CreateThumbnails < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
DROP TABLE thumbnails;
SQL
end
end
+56
View File
@@ -0,0 +1,56 @@
class CreateTitles < MG::Base
def up : String
<<-SQL
-- create titles
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT
);
CREATE UNIQUE INDEX titles_id_idx on titles (id);
CREATE UNIQUE INDEX titles_path_idx on titles (path);
-- migrate data from ids to titles
INSERT INTO titles
SELECT id, path, null
FROM ids
WHERE is_title = 1;
DELETE FROM ids
WHERE is_title = 1;
-- remove the is_title column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL
);
INSERT INTO ids
SELECT path, id
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
-- insert the is_title column
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
-- migrate data from titles to ids
INSERT INTO ids
SELECT path, id, 1
FROM titles;
-- remove titles
DROP TABLE titles;
SQL
end
end
+20
View File
@@ -0,0 +1,20 @@
class CreateUsers < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL,
password TEXT NOT NULL,
token TEXT,
admin INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
SQL
end
def down : String
<<-SQL
DROP TABLE users;
SQL
end
end
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0", "gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
-154
View File
@@ -1,154 +0,0 @@
.uk-alert-close {
color: black !important;
}
.uk-card-body {
padding: 20px;
}
.uk-card-media-top {
width: 100%;
height: 250px;
}
@media (min-width: 600px) {
.uk-card-media-top {
height: 300px;
}
}
.uk-card-media-top>img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title {
max-height: 3em;
}
.acard:hover {
cursor: pointer;
}
.uk-list li:not(.nopointer) {
cursor: pointer;
}
#scan-status {
cursor: auto;
}
.reader-bg {
background-color: black;
}
.break-word {
word-wrap: break-word;
}
.uk-logo>img {
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;
}
#selectable .ui-selecting {
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
}
.uk-light #selectable .ui-selecting {
background: #5E5731;
}
.uk-light #selectable .ui-selected {
background: #9D9252;
}
td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
+124
View File
@@ -0,0 +1,124 @@
// Item cards
.item .uk-card {
cursor: pointer;
.uk-card-media-top {
width: 100%;
height: 250px;
@media (min-width: 600px) {
height: 300px;
}
img {
height: 100%;
width: 100%;
object-fit: cover;
&.grayscale {
filter: grayscale(100%);
}
}
}
.uk-card-body {
padding: 20px;
.uk-card-title {
max-height: 3em;
font-size: 1rem;
}
}
}
// jQuery selectable
#selectable {
.ui-selecting {
background: #EEE6B9;
}
.ui-selected {
background: #F4E487;
}
.uk-light & {
.ui-selecting {
background: #5E5731;
}
.ui-selected {
background: #9D9252;
}
}
}
// Edit modal
#edit-modal {
.uk-grid > div {
height: 300px;
}
#cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#cover-upload {
height: 100%;
box-sizing: border-box;
}
.uk-modal-body .uk-inline {
width: 100%;
}
}
// Dark theme
.uk-light {
.uk-navbar-dropdown,
.uk-modal-header,
.uk-modal-body,
.uk-modal-footer {
background: #222;
}
.uk-navbar-dropdown,
.uk-dropdown {
color: #ccc;
}
.uk-nav-header,
.uk-description-list > dt {
color: #555;
}
}
// Alpine magic
[x-cloak] {
display: none;
}
// Batch select bar on title page
#select-bar-controls {
a {
transform: scale(1.5, 1.5);
&:hover {
color: orange;
}
}
}
// Totop button
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
}
// Misc
.uk-alert-close {
color: black !important;
}
.break-word {
word-wrap: break-word;
}
.uk-search {
width: 100%;
}
+58
View File
@@ -0,0 +1,58 @@
@light-gray: #e5e5e5;
@gray: #666666;
@black: #141414;
@blue: rgb(30, 135, 240);
@white1: rgba(255, 255, 255, .1);
@white2: rgba(255, 255, 255, .2);
@white7: rgba(255, 255, 255, .7);
.select2-container--default {
.select2-selection--multiple {
border: 1px solid @light-gray;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: @blue;
color: white;
border: none;
border-radius: 2px;
}
}
.select2-dropdown {
.select2-results__option--highlighted.select2-results__option--selectable {
background-color: @blue;
}
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @light-gray
}
}
}
.uk-light {
.select2-container--default {
.select2-selection {
background-color: @white1;
}
.select2-selection--multiple {
border: 1px solid @white2;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: white;
color: @gray;
border: none;
}
.select2-search__field {
color: @white7;
}
}
}
.select2-dropdown {
background-color: @black;
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @white2;
}
}
}
+48 -33
View File
@@ -1,40 +1,55 @@
let scanning = false; const component = () => {
return {
progress: 1.0,
generating: false,
scanning: false,
scanTitles: 0,
scanMs: -1,
themeSetting: '',
const scan = () => { init() {
scanning = true; this.getProgress();
$('#scan-status > div').removeAttr('hidden'); setInterval(() => {
$('#scan-status > span').attr('hidden', ''); this.getProgress();
const color = $('#scan').css('color'); }, 5000);
$('#scan').css('color', 'gray');
$.post(base_url + 'api/admin/scan', (data) => {
const ms = data.milliseconds;
const titles = data.titles;
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
$('#scan-status > span').removeAttr('hidden');
$('#scan').css('color', color);
$('#scan-status > div').attr('hidden', '');
scanning = false;
});
}
String.prototype.capitalize = function() {
return this.charAt(0).toUpperCase() + this.slice(1);
}
$(() => {
$('li').click((e) => {
const url = $(e.currentTarget).attr('data-url');
if (url) {
$(location).attr('href', url);
}
});
const setting = loadThemeSetting(); const setting = loadThemeSetting();
$('#theme-select').val(setting.capitalize()); this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
},
$('#theme-select').change((e) => { themeChanged(event) {
const newSetting = $(e.currentTarget).val().toLowerCase(); const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
},
scan() {
if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.always(() => {
this.scanning = false;
}); });
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
}); });
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
};
+147
View File
@@ -0,0 +1,147 @@
/**
* --- Alpine helper functions
*/
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
* @param {string} selector - The jQuery selector to the root element
*/
const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop;
};
/**
* Get an alpine.js property
*
* @function getProp
* @param {string} key - Key of the data property
* @param {string} selector - The jQuery selector to the root element
* @return {*} The data property
*/
const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key];
};
/**
* --- Theme related functions
* Note: In the comments below we treat "theme" and "theme setting"
* differently. A theme can have only two values, either "dark" or
* "light", while a theme setting can have the third value "system".
*/
/**
* Check if the system setting prefers dark theme.
* from https://flaviocopes.com/javascript-detect-dark-mode/
*
* @function preferDarkMode
* @return {bool}
*/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
* Check whether a given string represents a valid theme setting
*
* @function validThemeSetting
* @param {string} theme - The string representing the theme setting
* @return {bool}
*/
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
/**
* Load theme setting from local storage, or use 'light'
*
* @function loadThemeSetting
* @return {string} A theme setting ('dark', 'light', or 'system')
*/
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system';
return str;
};
/**
* Load the current theme (not theme setting)
*
* @function loadTheme
* @return {string} The current theme to use ('dark' or 'light')
*/
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
/**
* Save a theme setting
*
* @function saveThemeSetting
* @param {string} setting - A theme setting
*/
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting);
};
/**
* Toggle the current theme. When the current theme setting is 'system', it
* will be changed to either 'light' or 'dark'
*
* @function toggleTheme
*/
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
/**
* Apply a theme, or load a theme and then apply it
*
* @function setTheme
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
* `loadTheme` to get a theme and apply it.
*/
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+13 -4
View File
@@ -1,5 +1,10 @@
const truncate = () => { /**
$('.uk-card-title').each((i, e) => { * Truncate a .uk-card-title element
*
* @function truncate
* @param {object} e - The title element to truncate
*/
const truncate = (e) => {
$(e).dotdotdot({ $(e).dotdotdot({
truncate: 'letter', truncate: 'letter',
watch: true, watch: true,
@@ -11,7 +16,11 @@ const truncate = () => {
} }
} }
}); });
});
}; };
truncate(); $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});
+95 -124
View File
@@ -1,94 +1,37 @@
$(() => { const component = () => {
$('input.uk-checkbox').each((i, e) => { return {
$(e).change(() => { jobs: [],
loadConfig(); paused: undefined,
}); loading: false,
}); toggling: false,
loadConfig(); ws: undefined,
load();
const intervalMS = 5000; wsConnect(secure = true) {
setTimeout(() => { const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
setInterval(() => { console.log(`Connecting to ${url}`);
if (globalConfig.autoRefresh !== true) return; this.ws = new WebSocket(url);
load(); this.ws.onmessage = event => {
}, intervalMS); const data = JSON.parse(event.data);
}, intervalMS); this.jobs = data.jobs;
}); this.paused = data.paused;
var globalConfig = {};
var loading = false;
const loadConfig = () => {
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
}; };
const remove = (id) => { this.ws.onclose = () => {
var url = base_url + 'api/admin/mangadex/queue/delete'; if (this.ws.failed)
if (id !== undefined) return this.wsConnect(false);
url += '?' + $.param({ alert('danger', 'Socket connection closed');
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}; };
const refresh = (id) => { this.ws.onerror = () => {
var url = base_url + 'api/admin/mangadex/queue/retry'; if (secure)
if (id !== undefined) return this.ws.failed = true;
url += '?' + $.param({ alert('danger', 'Socket connection failed');
id: id
});
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to restart download job. Error: ${data.error}`);
return;
}
load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}; };
const toggle = () => { },
$('#pause-resume-btn').attr('disabled', ''); init() {
const paused = $('#pause-resume-btn').text() === 'Resume download'; this.wsConnect();
const action = paused ? 'resume' : 'pause'; this.load();
const url = `${base_url}api/admin/mangadex/queue/${action}`; },
$.ajax({ load() {
type: 'POST', this.loading = true;
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({ $.ajax({
type: 'GET', type: 'GET',
url: base_url + 'api/admin/mangadex/queue', url: base_url + 'api/admin/mangadex/queue',
@@ -99,47 +42,75 @@ const load = () => {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`); alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return; return;
} }
console.log(data); this.jobs = data.jobs;
const btnText = data.paused ? "Resume download" : "Pause download"; this.paused = data.paused;
$('#pause-resume-btn').text(btnText);
$('#pause-resume-btn').removeAttr('hidden');
const rows = data.jobs.map(obj => {
var cls = 'label ';
if (obj.status === 'Pending')
cls += 'label-pending';
if (obj.status === 'Completed')
cls += 'label-success';
if (obj.status === 'Error')
cls += 'label-danger';
if (obj.status === 'MissingPages')
cls += 'label-warning';
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
return `<tr id="chapter-${obj.id}">
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
<td>${obj.success_count}/${obj.pages}</td>
<td>${moment(obj.time).fromNow()}</td>
<td>${statusSpan} ${dropdown}</td>
<td>${obj.plugin_id || ""}</td>
<td>
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
${retryBtn}
</td>
</tr>`;
});
const tbody = `<tbody>${rows.join('')}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.always(() => { .always(() => {
loading = false; this.loading = false;
}); });
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-')[1];
url = `${url}?${$.param({
id: id
})}`;
}
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
}
this.load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
toggle() {
this.toggling = true;
const action = this.paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.load();
this.toggling = false;
});
},
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
}
};
}; };
+1 -1
View File
@@ -95,7 +95,7 @@ const search = () => {
try { try {
const path = new URL(input).pathname; const path = new URL(input).pathname;
const match = /\/title\/([0-9]+)/.exec(path); const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]); int_id = parseInt(match[1]);
} catch (e) { } catch (e) {
int_id = parseInt(input); int_id = parseInt(input);
+6 -7
View File
@@ -33,14 +33,13 @@ const search = () => {
if (searching) if (searching)
return; return;
const query = $('#search-input').val(); const query = $.param({
$.ajax({ query: $('#search-input').val(),
type: 'POST',
url: base_url + 'api/admin/plugin/list',
data: JSON.stringify({
query: query,
plugin: pid plugin: pid
}), });
$.ajax({
type: 'GET',
url: `${base_url}api/admin/plugin/list?${query}`,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
}) })
+215 -129
View File
@@ -1,195 +1,281 @@
let lastSavedPage = page; const readerComponent = () => {
let items = []; return {
let longPages = false; loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
$(() => { msg: 'Loading the web reader. Please wait...',
getPages(); alertClass: 'uk-alert-primary',
items: [],
$('#page-select').change(() => { curItem: {},
const p = parseInt($('#page-select').val()); flipAnimation: null,
toPage(p); longPages: false,
}); lastSavedPage: page,
});
/** /**
* Set an alpine.js property * Initialize the component by fetching the page dimensions
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
*/ */
const setProp = (key, prop) => { init(nextTick) {
$('#root').get(0).__x.$data[key] = prop;
};
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) $.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => { .then(data => {
if (!data.success && data.error) if (!data.success && data.error)
throw new Error(resp.error); throw new Error(resp.error);
const dimensions = data.dimensions; const dimensions = data.dimensions;
items = dimensions.map((d, i) => { this.items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width, width: d.width,
height: d.height height: d.height,
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
}; };
}); });
const avgRatio = items.reduce((acc, cur) => { const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width return acc + cur.height / cur.width
}, 0) / items.length; }, 0) / this.items.length;
console.log(avgRatio); console.log(avgRatio);
longPages = avgRatio > 2; this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
setProp('items', items); // Here we save a copy of this.mode, and use the copy as
setProp('loading', false); // the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
waitForPage(items.length, () => { // which are not available in mode-select
toPage(page); const mode = this.mode;
setupScroller(); this.updateMode(this.mode, page, nextTick);
}); $('#mode-select').val(mode);
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e); console.error(e);
setProp('alertClass', 'uk-alert-danger'); this.alertClass = 'uk-alert-danger';
setProp('msg', errMsg); this.msg = errMsg;
}) })
}; },
/** /**
* Jump to a specific page * Handles the `change` event for the page selector
*
* @function toPage
* @param {number} idx - One-based index of the page
*/ */
const toPage = (idx) => { pageChanged() {
$(`#${idx}`).get(0).scrollIntoView(true); const p = parseInt($('#page-select').val());
UIkit.modal($('#modal-sections')).hide(); this.toPage(p);
}; },
/** /**
* Check if a page exists every 100ms. If so, invoke the callback function. * Handles the `change` event for the mode selector
* *
* @function waitForPage * @param {function} nextTick - Alpine $nextTick magic property
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/ */
const waitForPage = (idx, cb) => { modeChanged(nextTick) {
if ($(`#${idx}`).length > 0) return cb(); const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0 || newIdx > this.items.length) return;
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
setTimeout(() => { setTimeout(() => {
waitForPage(idx, cb) this.flipAnimation = null;
}, 100); }, 500);
};
this.replaceHistory(newIdx);
},
/** /**
* Show the control modal * Jumps to a specific page
* *
* @function showControl * @param {number} idx - One-based index of the page
* @param {object} event - The onclick event that triggers the function
*/ */
const showControl = (event) => { toPage(idx) {
const idx = parseInt($(event.currentTarget).attr('id')); if (this.mode === 'continuous') {
const pageCount = $('#page-select > option').length; $(`#${idx}`).get(0).scrollIntoView(true);
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; } else {
$('#progress-label').text(progressText); if (idx >= 1 && idx <= this.items.length) {
$('#page-select').val(idx); this.curItem = this.items[idx - 1];
UIkit.modal($('#modal-sections')).show();
} }
/**
* Redirect to a URL
*
* @function redirect
* @param {string} url - The target URL
*/
const redirect = (url) => {
window.location.replace(url);
} }
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/** /**
* Replace the address bar history and save the reading progress if necessary * Replace the address bar history and save the reading progress if necessary
* *
* @function replaceHistory * @param {number} idx - One-based index of the page
* @param {number} idx - One-based index of the current page
*/ */
const replaceHistory = (idx) => { replaceHistory(idx) {
const ary = window.location.pathname.split('/'); const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx; ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/` ary.shift(); // remove leading `/`
ary.unshift(window.location.origin); ary.unshift(window.location.origin);
const url = ary.join('/'); const url = ary.join('/');
saveProgress(idx); this.saveProgress(idx);
history.replaceState(null, "", url); history.replaceState(null, "", url);
} },
/** /**
* Set up the scroll handler that calls `replaceHistory` when an image * Updates the backend reading progress if:
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
replaceHistory(current);
}
});
});
};
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last * 1) the current page is more than five pages away from the last
* saved page, or * saved page, or
* 2) the average height/width ratio of the pages is over 2, or * 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or * 3) the current page is the first page, or
* 4) the current page is the last page * 4) the current page is the last page
* *
* @function saveProgress
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback * @param {function} cb - Callback
*/ */
const saveProgress = (idx, cb) => { saveProgress(idx, cb) {
idx = parseInt(idx); idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 || if (Math.abs(idx - this.lastSavedPage) >= 5 ||
longPages || this.longPages ||
idx === 1 || idx === items.length idx === 1 || idx === this.items.length
) { ) {
lastSavedPage = idx; this.lastSavedPage = idx;
console.log('saving progress', idx); console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`; const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.post(url) $.ajax({
.then(data => { method: 'PUT',
if (data.error) throw new Error(data.error); url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb(); if (cb) cb();
}) })
.catch(e => { .fail((jqXHR, status) => {
console.error(e); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
alert('danger', e); });
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
const pageCount = this.items.length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and optionally sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
* @param {boolean} [markCompleted] - Whether we should mark the
* reading progress to 100%
*/
exitReader(exitUrl, markCompleted = false) {
if (!markCompleted) {
return this.redirect(exitUrl);
}
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
}); });
} }
}; };
}
/**
* Mark progress to 100% and redirect to the next entry
* Used as the onclick handler for the "Next Entry" button
*
* @function nextEntry
* @param {string} nextUrl - URL of the next entry
*/
const nextEntry = (nextUrl) => {
saveProgress(items.length, () => {
redirect(nextUrl);
});
};
-72
View File
@@ -1,72 +0,0 @@
// https://flaviocopes.com/javascript-detect-dark-mode/
const preferDarkMode = () => {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
// dark / light / system
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'light';
return str;
};
// dark / light
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'light';
localStorage.setItem('theme', setting);
};
// when toggled, Auto will be changed to light or dark
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.uk-card').addClass('uk-card-secondary');
$('.uk-card').removeClass('uk-card-default');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.uk-card').removeClass('uk-card-secondary');
$('.uk-card').addClass('uk-card-default');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});
+101 -9
View File
@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `${base_url}opds/download/${titleID}/${entryID}`); $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
} }
@@ -63,17 +63,26 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}` let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({ const query = $.param({
entry: eid eid: eid
}); });
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.post(url, (data) => {
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
error = data.error; error = data.error;
alert('danger', error); alert('danger', error);
} }
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
@@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => {
} }
const query = $.param({ const query = $.param({
entry: eid eid: eid
}); });
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) if (eid)
url += `?${query}`; url += `?${query}`;
$.ajax({ $.ajax({
type: 'POST', type: 'PUT',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json' dataType: 'json'
@@ -131,6 +140,7 @@ const edit = (eid) => {
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName); displayNameField.attr('value', displayName);
console.log(displayNameField);
displayNameField.keyup(event => { displayNameField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid); renameSubmit(displayNameField.val(), eid);
@@ -150,10 +160,10 @@ const setupUpload = (eid) => {
const bar = $('#upload-progress').get(0); const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = { const queryObj = {
title: titleId tid: titleId
}; };
if (eid) if (eid)
queryObj['entry'] = eid; queryObj['eid'] = eid;
const query = $.param(queryObj); const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`; const url = `${base_url}api/admin/upload/cover?${query}`;
console.log(url); console.log(url);
@@ -218,9 +228,9 @@ const selectedIDs = () => {
const bulkProgress = (action, el) => { const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id'); const tid = $(el).attr('data-id');
const ids = selectedIDs(); const ids = selectedIDs();
const url = `${base_url}api/bulk-progress/${action}/${tid}`; const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({ $.ajax({
type: 'POST', type: 'PUT',
url: url, url: url,
contentType: "application/json", contentType: "application/json",
dataType: 'json', dataType: 'json',
@@ -242,3 +252,85 @@ const bulkProgress = (action, el) => {
deselectAll(); deselectAll();
}); });
}; };
const tagsComponent = () => {
return {
isAdmin: false,
tags: [],
tid: $('.upload-field').attr('data-title-id'),
loading: true,
load(admin) {
this.isAdmin = admin;
$('.tag-select').select2({
tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin,
templateSelection(state) {
const a = document.createElement('a');
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
a.setAttribute('class', 'uk-link-reset');
a.onclick = event => {
event.stopPropagation();
};
a.innerText = state.text;
return a;
}
});
this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', data => {
this.tags = data.tags;
allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op);
});
$('.tag-select').on('select2:select', e => {
this.onAdd(e);
});
$('.tag-select').on('select2:unselect', e => {
this.onDelete(e);
});
$('.tag-select').on('change', () => {
this.onChange();
});
$('.tag-select').trigger('change');
this.loading = false;
});
});
},
onChange() {
this.tags = $('.tag-select').select2('data').map(o => o.text);
},
onAdd(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT');
},
onDelete(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE');
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success) {
if (cb) cb(data);
} else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};
+14 -9
View File
@@ -1,11 +1,16 @@
function remove(username) { const remove = (username) => {
$.post(base_url + 'api/admin/user/delete/' + username, function(data) { $.ajax({
if (data.success) { url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE',
dataType: 'json'
})
.done(data => {
if (data.success)
location.reload(); location.reload();
} else
else { alert('danger', data.error);
error = data.error; })
alert('danger', error); .fail((jqXHR, status) => {
} alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
} };
+35 -19
View File
@@ -1,62 +1,78 @@
version: 1.0 version: 2.0
shards: shards:
ameba: ameba:
github: crystal-ameba/ameba git: https://github.com/crystal-ameba/ameba.git
version: 0.12.1 version: 0.12.1
archive: archive:
github: hkalexling/archive.cr git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0 version: 0.4.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system git: https://github.com/schovi/baked_file_system.git
version: 0.9.8 version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim: clim:
github: at-grandpa/clim git: https://github.com/at-grandpa/clim.git
version: 0.12.0 version: 0.12.0
db: db:
github: crystal-lang/crystal-db git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0 version: 0.9.0
duktape: duktape:
github: jessedoyle/duktape.cr git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0 version: 0.20.0
exception_page: exception_page:
github: crystal-loot/exception_page git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4 version: 0.1.4
http_proxy: http_proxy:
github: mamantoha/http_proxy git: https://github.com/mamantoha/http_proxy.git
version: 0.7.1 version: 0.7.1
image_size: image_size:
github: hkalexling/image_size.cr git: https://github.com/hkalexling/image_size.cr.git
version: 0.2.0 version: 0.4.0
kemal: kemal:
github: kemalcr/kemal git: https://github.com/kemalcr/kemal.git
version: 0.26.1 version: 0.27.0
kemal-session: kemal-session:
github: kemalcr/kemal-session git: https://github.com/kemalcr/kemal-session.git
version: 0.12.1 version: 0.12.1
kilt: kilt:
github: jeromegn/kilt git: https://github.com/jeromegn/kilt.git
version: 0.4.0 version: 0.4.0
koa:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
mg:
git: https://github.com/hkalexling/mg.git
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
myhtml: myhtml:
github: kostya/myhtml git: https://github.com/kostya/myhtml.git
version: 1.5.1 version: 1.5.1
open_api:
git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
radix: radix:
github: luislavena/radix git: https://github.com/luislavena/radix.git
version: 0.3.9 version: 0.3.9
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.16.0
tallboy:
git: https://github.com/epoch/tallboy.git
version: 0.9.3
+9 -2
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.12.3 version: 0.19.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -8,7 +8,7 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.34.0 crystal: 0.35.1
license: MIT license: MIT
@@ -21,6 +21,7 @@ dependencies:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive: archive:
github: hkalexling/archive.cr github: hkalexling/archive.cr
ameba: ameba:
@@ -36,3 +37,9 @@ dependencies:
github: mamantoha/http_proxy github: mamantoha/http_proxy
image_size: image_size:
github: hkalexling/image_size.cr github: hkalexling/image_size.cr
koa:
github: hkalexling/koa
tallboy:
github: epoch/tallboy
mg:
github: hkalexling/mg
+8 -8
View File
@@ -40,11 +40,6 @@ describe Rule do
rule.render({"a" => "a", "b" => "b"}).should eq "a" rule.render({"a" => "a", "b" => "b"}).should eq "a"
end 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 it "raises on escaped characters" do
expect_raises Exception do expect_raises Exception do
Rule.new "hello/world" Rule.new "hello/world"
@@ -69,8 +64,13 @@ describe Rule do
rule.render({} of String => String).should eq "testing" rule.render({} of String => String).should eq "testing"
end end
it "escapes slash" do it "escapes illegal characters" do
rule = Rule.new "{id}" rule = Rule.new "{a}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world" rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
end
it "strips trailing spaces and dots" do
rule = Rule.new "hello. world. .."
rule.render({} of String => String).should eq "hello. world"
end end
end end
+11 -11
View File
@@ -1,13 +1,13 @@
require "zip" require "compress/zip"
require "archive" require "archive"
# A unified class to handle all supported archive formats. It uses the ::Zip # A unified class to handle all supported archive formats. It uses the
# module in crystal standard library if the target file is a zip archive. # Compress::Zip module in crystal standard library if the target file is
# Otherwise it uses `archive.cr`. # a zip archive. Otherwise it uses `archive.cr`.
class ArchiveFile class ArchiveFile
def initialize(@filename : String) def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Zip::File.new filename @archive_file = Compress::Zip::File.new filename
else else
@archive_file = Archive::File.new filename @archive_file = Archive::File.new filename
end end
@@ -20,16 +20,16 @@ class ArchiveFile
end end
def close def close
if @archive_file.is_a? Zip::File if @archive_file.is_a? Compress::Zip::File
@archive_file.as(Zip::File).close @archive_file.as(Compress::Zip::File).close
end end
end end
# Lists all file entries # Lists all file entries
def entries def entries
ary = [] of Zip::File::Entry | Archive::Entry ary = [] of Compress::Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e| @archive_file.entries.map do |e|
if (e.is_a? Zip::File::Entry && e.file?) || if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?) (e.is_a? Archive::Entry && e.info.file?)
ary.push e ary.push e
end end
@@ -37,8 +37,8 @@ class ArchiveFile
ary ary
end end
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes? def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Zip::File::Entry if e.is_a? Compress::Zip::File::Entry
data = nil data = nil
e.open do |io| e.open do |io|
slice = Bytes.new e.uncompressed_size slice = Bytes.new e.uncompressed_size
+11 -2
View File
@@ -11,13 +11,18 @@ class Config
property library_path : String = File.expand_path "~/mango/library", property library_path : String = File.expand_path "~/mango/library",
home: true home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true
@[YAML::Field(key: "scan_interval_minutes")] property scan_interval_minutes : Int32 = 5
property scan_interval : Int32 = 5 property thumbnail_generation_interval_hours : Int32 = 24
property db_optimization_interval_hours : Int32 = 24
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads", property upload_path : String = File.expand_path "~/mango/uploads",
home: true home: true
property plugin_path : String = File.expand_path "~/mango/plugins", property plugin_path : String = File.expand_path "~/mango/plugins",
home: true home: true
property download_timeout_seconds : Int32 = 30
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -83,5 +88,9 @@ class Config
unless base_url.ends_with? "/" unless base_url.ends_with? "/"
@base_url += "/" @base_url += "/"
end end
if disable_login && default_username.empty?
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
end end
end end
+19 -8
View File
@@ -11,9 +11,6 @@ class AuthHandler < Kemal::Handler
"You have to login with proper credentials" "You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\"" HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def require_basic_auth(env) def require_basic_auth(env)
env.response.status_code = 401 env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
@@ -23,12 +20,12 @@ class AuthHandler < Kemal::Handler
def validate_token(env) def validate_token(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_token token !token.nil? && Storage.default.verify_token token
end end
def validate_token_admin(env) def validate_token_admin(env)
token = env.session.string? "token" token = env.session.string? "token"
!token.nil? && @storage.verify_admin token !token.nil? && Storage.default.verify_admin token
end end
def validate_auth_header(env) def validate_auth_header(env)
@@ -49,7 +46,7 @@ class AuthHandler < Kemal::Handler
def verify_user(value) def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1]) username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":") .split(":")
@storage.verify_user username, password Storage.default.verify_user username, password
end end
def handle_opds_auth(env) def handle_opds_auth(env)
@@ -68,14 +65,28 @@ class AuthHandler < Kemal::Handler
return call_next(env) return call_next(env)
end end
unless validate_token env unless validate_token(env) || Config.current.disable_login
env.session.string "callback", env.request.path env.session.string "callback", env.request.path
return redirect env, "/login" return redirect env, "/login"
end end
if request_path_startswith env, ["/admin", "/api/admin", "/download"] if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless validate_token_admin env # The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
if should_reject
env.response.status_code = 403 env.response.status_code = 403
send_error_page "HTTP 403: You are not authorized to visit " \
"#{env.request.path}"
return
end end
end end
+1 -1
View File
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
slice = Bytes.new file.size slice = Bytes.new file.size
file.read slice file.read slice
return send_file env, slice, file.mime_type return send_file env, slice, MIME.from_filename file.path
end end
call_next env call_next env
end end
+36 -7
View File
@@ -1,11 +1,12 @@
require "image_size" require "image_size"
class Entry class Entry
property zip_path : String, book : Title, title : String, getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String, size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String? encoded_title : String, mtime : Time, err_msg : String?
def initialize(@zip_path, @book, storage) def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path @encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path @title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@@ -47,8 +48,7 @@ class Entry
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["zip_path", "title", "size", "id", {% for str in ["zip_path", "title", "size", "id"] %}
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "title_id", @book.id json.field "title_id", @book.id
@@ -69,7 +69,7 @@ class Entry
def cover_url def cover_url
return "#{Config.current.base_url}img/icon.png" if @err_msg return "#{Config.current.base_url}img/icon.png" if @err_msg
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1" url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
TitleInfo.new @book.dir do |info| TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]? info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
@@ -118,8 +118,8 @@ class Entry
"width" => size.width, "width" => size.width,
"height" => size.height, "height" => size.height,
} }
rescue rescue e
Logger.warn "Failed to read page #{i} of entry #{@id}" Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32} sizes << {"width" => 1000_i32, "height" => 1000_i32}
end end
end end
@@ -207,4 +207,33 @@ class Entry
def started?(username) def started?(username)
load_progress(username) > 0 load_progress(username) > 0
end end
def generate_thumbnail : Image?
return if @err_msg
img = read_page(1).not_nil!
begin
size = ImageSize.get img.data
if size.height > size.width
thumbnail = ImageSize.resize img.data, width: 200
else
thumbnail = ImageSize.resize img.data, height: 300
end
img.data = thumbnail
img.size = thumbnail.size
unless img.mime == "image/webp"
# image_size.cr resizes non-webp images to jpg
img.mime = "image/jpeg"
end
Storage.default.save_thumbnail @id, img
rescue e
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end
img
end
def get_thumbnail : Image?
Storage.default.get_thumbnail @id
end
end end
+81 -28
View File
@@ -1,5 +1,5 @@
class Library class Library
property dir : String, title_ids : Array(String), scan_interval : Int32, getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
use_default use_default
@@ -8,20 +8,48 @@ class Library
register_mime_types register_mime_types
@dir = Config.current.library_path @dir = Config.current.library_path
@scan_interval = Config.current.scan_interval
# explicitly initialize @titles to bypass the compiler check. it will # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@title_hash = {} of String => Title @title_hash = {} of String => Title
return scan if @scan_interval < 1 @entries_count = 0
@thumbnails_count = 0
scan_interval = Config.current.scan_interval_minutes
if scan_interval < 1
scan
else
spawn do spawn do
loop do loop do
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds 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 sleep scan_interval.minutes
end
end
end
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
unless thumbnail_interval < 1
spawn do
loop do
# Wait for scan to complete (in most cases)
sleep 1.minutes
generate_thumbnails
sleep thumbnail_interval.hours
end
end
end
db_interval = Config.current.db_optimization_interval_hours
unless db_interval < 1
spawn do
loop do
Storage.default.optimize
sleep db_interval.hours
end
end end
end end
end end
@@ -40,29 +68,8 @@ class Library
end end
end end
# This is a hack to bypass a compiler bug # Helper function from src/util/util.cr
ary = titles sort_titles titles, opt.not_nil!, username
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end end
def deep_titles def deep_titles
@@ -99,7 +106,7 @@ class Library
.select { |fn| !fn.starts_with? "." } .select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn } .map { |fn| File.join @dir, fn }
.select { |path| File.directory? path } .select { |path| File.directory? path }
.map { |path| Title.new path, "", storage, self } .map { |path| Title.new path, "" }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear } .tap { |_| @title_ids.clear }
@@ -194,4 +201,50 @@ class Library
.sample(ENTRIES_IN_HOME_SECTIONS) .sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle .shuffle
end end
def thumbnail_generation_progress
return 0 if @entries_count == 0
@thumbnails_count / @entries_count
end
def generate_thumbnails
if @thumbnails_count > 0
Logger.debug "Thumbnail generation in progress"
return
end
Logger.info "Starting thumbnail generation"
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
@entries_count = entries.size
@thumbnails_count = 0
# Report generation progress regularly
spawn do
loop do
unless @thumbnails_count == 0
Logger.debug "Thumbnail generation progress: " \
"#{(thumbnail_generation_progress * 100).round 1}%"
end
# Generation is completed. We reset the count to 0 to allow subsequent
# calls to the function, and break from the loop to stop the progress
# report fiber
if thumbnail_generation_progress.to_i == 1
@thumbnails_count = 0
break
end
sleep 10.seconds
end
end
entries.each do |e|
unless e.get_thumbnail
e.generate_thumbnail
# Sleep after each generation to minimize the impact on disk IO
# and CPU
sleep 0.5.seconds
end
@thumbnails_count += 1
end
Logger.info "Thumbnail generation finished"
end
end end
+45 -17
View File
@@ -1,12 +1,14 @@
require "../archive" require "../archive"
class Title class Title
property dir : String, parent_id : String, title_ids : Array(String), getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time encoded_title : String, mtime : Time
def initialize(@dir : String, @parent_id, storage, @entry_display_name_cache : Hash(String, String)?
@library : Library)
def initialize(@dir : String, @parent_id)
storage = Storage.default
id = storage.get_id @dir, true id = storage.get_id @dir, true
if id.nil? if id.nil?
id = random_str id = random_str
@@ -27,26 +29,26 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, storage, library title = Title.new path, @id
next if title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
next next
end end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
entry = Entry.new path, self, storage entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
mtimes = [@mtime] mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime } mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime } mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max @mtime = mtimes.max
@title_ids.sort! do |a, b| @title_ids.sort! do |a, b|
compare_numerically @library.title_hash[a].title, compare_numerically Library.default.title_hash[a].title,
@library.title_hash[b].title Library.default.title_hash[b].title
end end
sorter = ChapterSorter.new @entries.map { |e| e.title } sorter = ChapterSorter.new @entries.map { |e| e.title }
@entries.sort! do |a, b| @entries.sort! do |a, b|
@@ -56,7 +58,7 @@ class Title
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %} {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "display_name", display_name json.field "display_name", display_name
@@ -82,7 +84,7 @@ class Title
end end
def titles def titles
@title_ids.map { |tid| @library.get_title! tid } @title_ids.map { |tid| Library.default.get_title! tid }
end end
# Get all entries, including entries in nested titles # Get all entries, including entries in nested titles
@@ -100,15 +102,37 @@ class Title
ary = [] of Title ary = [] of Title
tid = @parent_id tid = @parent_id
while !tid.empty? while !tid.empty?
title = @library.get_title! tid title = Library.default.get_title! tid
ary << title ary << title
tid = title.parent_id tid = title.parent_id
end end
ary.reverse ary.reverse
end end
def size # Returns a string the describes the content of the title
@entries.size + @title_ids.size # e.g., - 3 titles and 1 entry
# - 4 entries
# - 1 title
def content_label
ary = [] of String
tsize = titles.size
esize = entries.size
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
ary.join " and "
end
def tags
Storage.default.get_title_tags @id
end
def add_tag(tag)
Storage.default.add_tag @id, tag
end
def delete_tag(tag)
Storage.default.delete_tag @id, tag
end end
def get_entry(eid) def get_entry(eid)
@@ -129,13 +153,17 @@ class Title
end end
def display_name(entry_name) def display_name(entry_name)
dn = entry_name unless @entry_display_name_cache
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]? @entry_display_name_cache = info.entry_display_name
end
end
dn = entry_name
info_dn = @entry_display_name_cache.not_nil![entry_name]?
unless info_dn.nil? || info_dn.empty? unless info_dn.nil? || info_dn.empty?
dn = info_dn dn = info_dn
end end
end
dn dn
end end
+10
View File
@@ -57,6 +57,16 @@ struct Image
def initialize(@data, @mime, @filename, @size) def initialize(@data, @mime, @filename, @size)
end end
def self.from_db(res : DB::ResultSet)
img = Image.allocate
res.read String
img.data = res.read Bytes
img.filename = res.read String
img.mime = res.read String
img.size = res.read Int32
img
end
end end
class TitleInfo class TitleInfo
+28 -20
View File
@@ -6,29 +6,17 @@ class Logger
SEVERITY_IDS = [0, 4, 5, 2, 3] SEVERITY_IDS = [0, 4, 5, 2, 3]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta] COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
getter raw_log = Log.for ""
@@severity : Log::Severity = :info @@severity : Log::Severity = :info
use_default use_default
def initialize def initialize
level = Config.current.log_level @@severity = Logger.get_severity
{% begin %}
case level.downcase
when "off"
@@severity = :none
{% for lvl, i in LEVELS %}
when {{lvl}}
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
@log = Log.for("")
@backend = Log::IOBackend.new @backend = Log::IOBackend.new
@backend.formatter = ->(entry : Log::Entry, io : IO) do
format_proc = ->(entry : Log::Entry, io : IO) do
color = :default color = :default
{% begin %} {% begin %}
case entry.severity.label.to_s().downcase case entry.severity.label.to_s().downcase
@@ -45,12 +33,32 @@ class Logger
io << entry.message io << entry.message
end end
Log.builder.bind "*", @@severity, @backend @backend.formatter = Log::Formatter.new &format_proc
Log.setup @@severity, @backend
end
def self.get_severity(level = "") : Log::Severity
if level.empty?
level = Config.current.log_level
end
{% begin %}
case level.downcase
when "off"
return Log::Severity::None
{% for lvl, i in LEVELS %}
when {{lvl}}
return Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
end end
# Ignores @@severity and always log msg # Ignores @@severity and always log msg
def log(msg) def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil @backend.write Log::Entry.new "", Log::Severity::None, msg,
Log::Metadata.empty, nil
end end
def self.log(msg) def self.log(msg)
@@ -59,7 +67,7 @@ class Logger
{% for lvl in LEVELS %} {% for lvl in LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg } raw_log.{{lvl.id}} { msg }
end end
def self.{{lvl.id}}(msg) def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg default.not_nil!.{{lvl.id}} msg
+14 -3
View File
@@ -1,12 +1,12 @@
require "./api" require "./api"
require "zip" require "compress/zip"
module MangaDex module MangaDex
class PageJob class PageJob
property success = false property success = false
property url : String property url : String
property filename : String property filename : String
property writer : Zip::Writer property writer : Compress::Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning) def initialize(@url, @filename, @writer, @tries_remaning)
@@ -69,7 +69,7 @@ module MangaDex
# Find the number of digits needed to store the number of pages # Find the number of digits needed to store the number of pages
len = Math.log10(chapter.pages.size).to_i + 1 len = Math.log10(chapter.pages.size).to_i + 1
writer = Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue # Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size channel = Channel(PageJob).new chapter.pages.size
spawn do spawn do
@@ -91,6 +91,7 @@ module MangaDex
end end
channel.send page_job channel.send page_job
break unless @queue.exists? job
end end
end end
@@ -98,6 +99,9 @@ module MangaDex
page_jobs = [] of PageJob page_jobs = [] of PageJob
chapter.pages.size.times do chapter.pages.size.times do
page_job = channel.receive page_job = channel.receive
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \ Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}" "#{page_job.url}"
page_jobs << page_job page_jobs << page_job
@@ -110,6 +114,13 @@ module MangaDex
Logger.error msg Logger.error msg
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success } fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \ Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed" "#{fail_count}/#{page_jobs.size} failed"
+13 -12
View File
@@ -3,11 +3,12 @@ require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./main_fiber"
require "./mangadex/*" require "./mangadex/*"
require "./plugin/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "./plugin/*" require "tallboy"
MANGO_VERSION = "0.12.3" MANGO_VERSION = "0.19.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -53,6 +54,11 @@ class CLI < Clim
ARGV.clear ARGV.clear
Config.load(opts.config).set_current Config.load(opts.config).set_current
# Initialize main components
Storage.default
Queue.default
Library.default
MangaDex::Downloader.default MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
@@ -105,18 +111,13 @@ class CLI < Clim
password.not_nil!, opts.admin password.not_nil!, opts.admin
when "list" when "list"
users = storage.list_users users = storage.list_users
name_length = users.map(&.[0].size).max? || 0 table = Tallboy.table do
l_cell_width = ["username".size, name_length].max header ["username", "admin access"]
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| users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \ row [name, admin]
"#{admin.to_s.ljust r_cell_width} "
end end
puts "-" * header.size end
puts table
when nil when nil
puts opts.help_string puts opts.help_string
else else
+9 -1
View File
@@ -53,7 +53,7 @@ class Plugin
end end
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
writer = Zip::Writer.new zip_path writer = Compress::Zip::Writer.new zip_path
rescue e rescue e
@queue.set_status Queue::JobStatus::Error, job @queue.set_status Queue::JobStatus::Error, job
unless e.message.nil? unless e.message.nil?
@@ -66,6 +66,8 @@ class Plugin
fail_count = 0 fail_count = 0
while page = plugin.next_page while page = plugin.next_page
break unless @queue.exists? job
fn = process_filename page["filename"].as_s fn = process_filename page["filename"].as_s
url = page["url"].as_s url = page["url"].as_s
headers = HTTP::Headers.new headers = HTTP::Headers.new
@@ -109,6 +111,12 @@ class Plugin
end end
end end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
return
end
Logger.debug "Download completed. #{fail_count}/#{pages} failed" Logger.debug "Download completed. #{fail_count}/#{pages} failed"
writer.close writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path, filename = File.join File.dirname(zip_path), File.basename(zip_path,
+42
View File
@@ -257,6 +257,48 @@ class Plugin
end end
sbx.put_prop_string -2, "get" sbx.put_prop_string -2, "get"
sbx.push_proc LibDUK::VARARGS do |ptr|
env = Duktape::Sandbox.new ptr
url = env.require_string 0
body = env.require_string 1
headers = HTTP::Headers.new
if env.get_top == 3
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
while env.next -1, true
key = env.require_string -2
val = env.require_string -1
headers.add key, val
env.pop_2
end
end
res = HTTP::Client.post url, headers, body
env.push_object
env.push_int res.status_code
env.put_prop_string -2, "status_code"
env.push_string res.body
env.put_prop_string -2, "body"
env.push_object
res.headers.each do |k, v|
if v.size == 1
env.push_string v[0]
else
env.push_string v.join ","
end
env.put_prop_string -2, k
end
env.put_prop_string -2, "headers"
env.call_success
end
sbx.put_prop_string -2, "post"
sbx.push_proc 2 do |ptr| sbx.push_proc 2 do |ptr|
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
+15
View File
@@ -196,6 +196,21 @@ class Queue
self.delete job.id self.delete job.id
end end
def exists?(id : String)
res = false
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
res = db.query_one "select count(*) from queue where id = (?)", id,
as: Bool
end
end
res
end
def exists?(job : Job)
self.exists? job.id
end
def delete_status(status : JobStatus) def delete_status(status : JobStatus)
MainFiber.run do MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
+5 -1
View File
@@ -139,9 +139,13 @@ module Rename
post_process str post_process str
end end
# Post-processes the generated file/folder name
# - Handles the rare case where the string is `..`
# - Removes trailing spaces and periods
# - Replace illegal characters with `_`
private def post_process(str) private def post_process(str)
return "_" if str == ".." return "_" if str == ".."
str.gsub "/", "_" str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
end end
end end
end end
+6 -8
View File
@@ -1,13 +1,11 @@
require "./router" struct AdminRouter
class AdminRouter < Router
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
layout "admin" layout "admin"
end end
get "/admin/user" do |env| get "/admin/user" do |env|
users = @context.storage.list_users users = Storage.default.list_users
username = get_username env username = get_username env
layout "user" layout "user"
end end
@@ -32,11 +30,11 @@ class AdminRouter < Router
# would not contain `admin` # would not contain `admin`
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
@context.storage.new_user username, password, admin Storage.default.new_user username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"error" => e.message}) query: hash_to_query({"error" => e.message})
@@ -51,12 +49,12 @@ class AdminRouter < Router
admin = !env.params.body["admin"]?.nil? admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"] original_username = env.params.url["original_username"]
@context.storage.update_user \ Storage.default.update_user \
original_username, username, password, admin original_username, username, password, admin
redirect env, "/admin/user" redirect env, "/admin/user"
rescue e rescue e
@context.error e Logger.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit", path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \ query: hash_to_query({"username" => original_username, \
+477 -45
View File
@@ -1,16 +1,184 @@
require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload" require "../upload"
require "koa"
struct APIRouter
@@api_json : String?
API_VERSION = "0.1.0"
macro s(fields)
{
{% for field in fields %}
{{field}} => "string",
{% end %}
}
end
class APIRouter < Router
def initialize def initialize
Koa.init "Mango API", version: API_VERSION, desc: <<-MD
# A Word of Caution
This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk!
# Authentication
All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
# Terminologies
- Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga.
- Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume.
- Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library.
MD
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
Koa.global_tag "admin", desc: <<-MD
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
MD
Koa.binary "binary", desc: "A binary file"
Koa.array "entryAry", "$entry", desc: "An array of entries"
Koa.array "titleAry", "$title", desc: "An array of titles"
Koa.array "strAry", "string", desc: "An array of strings"
entry_schema = {
"pages" => "integer",
"mtime" => "integer",
}.merge s %w(zip_path title size id title_id display_name cover_url)
Koa.object "entry", entry_schema, desc: "An entry in a book"
title_schema = {
"mtime" => "integer",
"entries" => "$entryAry",
"titles" => "$titleAry",
"parents" => "$strAry",
}.merge s %w(dir title id display_name cover_url)
Koa.object "title", title_schema,
desc: "A manga title (a collection of entries and sub-titles)"
Koa.object "library", {
"dir" => "string",
"titles" => "$titleAry",
}, desc: "A library containing a list of top-level titles"
Koa.object "scanResult", {
"milliseconds" => "integer",
"titles" => "integer",
}
Koa.object "progressResult", {
"progress" => "number",
}
Koa.object "result", {
"success" => "boolean",
"error" => "string?",
}
mc_schema = {
"groups" => "object",
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
Koa.array "chapterAry", "$mangadexChapter"
mm_schema = {
"chapers" => "$chapterAry",
}.merge s %w(id title description author artist cover_url)
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
Koa.object "chaptersObj", {
"chapters" => "$chapterAry",
}
Koa.object "successFailCount", {
"success" => "integer",
"fail" => "integer",
}
job_schema = {
"pages" => "integer",
"success_count" => "integer",
"fail_count" => "integer",
"time" => "integer",
}.merge s %w(id manga_id title manga_title status_message status)
Koa.object "job", job_schema, desc: "A download job in the queue"
Koa.array "jobAry", "$job"
Koa.object "jobs", {
"success" => "boolean",
"paused" => "boolean",
"jobs" => "$jobAry",
}
Koa.object "binaryUpload", {
"file" => "$binary",
}
Koa.object "pluginListBody", {
"plugin" => "string",
"query" => "string",
}
Koa.object "pluginChapter", {
"id" => "string",
"title" => "string",
}
Koa.array "pluginChapterAry", "$pluginChapter"
Koa.object "pluginList", {
"success" => "boolean",
"chapters" => "$pluginChapterAry?",
"title" => "string?",
"error" => "string?",
}
Koa.object "pluginDownload", {
"plugin" => "string",
"title" => "string",
"chapters" => "$pluginChapterAry",
}
Koa.object "dimension", {
"width" => "integer",
"height" => "integer",
}
Koa.array "dimensionAry", "$dimension"
Koa.object "dimensionResult", {
"success" => "boolean",
"dimensions" => "$dimensionAry?",
"margin" => "number",
"error" => "string?",
}
Koa.object "ids", {
"ids" => "$strAry",
}
Koa.object "tagsResult", {
"success" => "boolean",
"tags" => "$strAry?",
"error" => "string?",
}
Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)"
Koa.response 200, ref: "$binary", media_type: "image/*"
Koa.response 500, "Page not found or not readable"
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -20,46 +188,102 @@ class APIRouter < Router
send_img env, img send_img env, img
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
end end
Koa.describe "Returns the cover image of a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
Koa.response 200, ref: "$binary", media_type: "image/*"
Koa.response 500, "Page not found or not readable"
get "/api/cover/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
img = entry.get_thumbnail || entry.read_page 1
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
if img.nil?
send_img env, img
rescue e
Logger.error e
env.response.status_code = 500
e.message
end
end
Koa.describe "Returns the book with title `tid`"
Koa.path "tid", desc: "Title ID"
Koa.response 200, ref: "$title"
Koa.response 404, "Title not found"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 404
e.message e.message
end end
end end
get "/api/book" do |env| Koa.describe "Returns the entire library with all titles and entries"
send_json env, @context.library.to_json Koa.response 200, ref: "$library"
get "/api/library" do |env|
send_json env, Library.default.to_json
end end
Koa.describe "Triggers a library scan"
Koa.tag "admin"
Koa.response 200, ref: "$scanResult"
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
@context.library.scan Library.default.scan
ms = (Time.utc - start).total_milliseconds ms = (Time.utc - start).total_milliseconds
send_json env, { send_json env, {
"milliseconds" => ms, "milliseconds" => ms,
"titles" => @context.library.titles.size, "titles" => Library.default.titles.size,
}.to_json }.to_json
end end
post "/api/admin/user/delete/:username" do |env| Koa.describe "Returns the thumbnail generation progress between 0 and 1"
Koa.tag "admin"
Koa.response 200, ref: "$progressResult"
get "/api/admin/thumbnail_progress" do |env|
send_json env, {
"progress" => Library.default.thumbnail_generation_progress,
}.to_json
end
Koa.describe "Triggers a thumbnail generation"
Koa.tag "admin"
post "/api/admin/generate_thumbnails" do |env|
spawn do
Library.default.generate_thumbnails
end
end
Koa.describe "Deletes a user with `username`"
Koa.tag "admin"
Koa.response 200, ref: "$result"
delete "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username Storage.default.delete_user username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -69,13 +293,24 @@ class APIRouter < Router
end end
end end
post "/api/progress/:title/:page" do |env| Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD
When `eid` is provided, sets the reading progress of the entry to `page`.
When `eid` is omitted, updates the progress of the entire title. Specifically:
- if `page` is 0, marks the entire title as unread
- otherwise, marks the entire title as read
MD
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "page", desc: "The new page number indicating the progress"
Koa.response 200, ref: "$result"
put "/api/progress/:tid/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]) title = (Library.default.get_title env.params.url["tid"]).not_nil!
.not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
if !entry_id.nil? if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil! entry = title.get_entry(entry_id).not_nil!
@@ -87,7 +322,7 @@ class APIRouter < Router
title.read_all username title.read_all username
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -97,10 +332,15 @@ class APIRouter < Router
end end
end end
post "/api/bulk-progress/:action/:title" do |env| Koa.describe "Updates the reading progress of multiple entries in a title"
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
Koa.path "tid", desc: "Title ID"
Koa.body ref: "$ids", desc: "An array of entry IDs"
Koa.response 200, ref: "$result"
put "/api/bulk_progress/:action/:tid" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
action = env.params.url["action"] action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s ids = env.params.json["ids"].as(Array).map &.as_s
@@ -109,7 +349,7 @@ class APIRouter < Router
end end
title.bulk_progress action, ids, username title.bulk_progress action, ids, username
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -119,12 +359,20 @@ class APIRouter < Router
end end
end end
post "/api/admin/display_name/:title/:name" do |env| Koa.describe "Sets the display name of a title or an entry", <<-MD
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
MD
Koa.tag "admin"
Koa.path "tid", desc: "Title ID"
Koa.query "eid", desc: "Entry ID", required: false
Koa.path "name", desc: "The new display name"
Koa.response 200, ref: "$result"
put "/api/admin/display_name/:tid/:name" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (Library.default.get_title env.params.url["tid"])
.not_nil! .not_nil!
name = env.params.url["name"] name = env.params.url["name"]
entry = env.params.query["entry"]? entry = env.params.query["eid"]?
if entry.nil? if entry.nil?
title.set_display_name name title.set_display_name name
else else
@@ -132,7 +380,7 @@ class APIRouter < Router
title.set_display_name eobj.not_nil!.title, name title.set_display_name eobj.not_nil!.title, name
end end
rescue e rescue e
@context.error e Logger.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message, "error" => e.message,
@@ -142,6 +390,12 @@ class APIRouter < Router
end end
end end
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tag "admin"
Koa.path "id", desc: "A MangaDex manga ID"
Koa.response 200, ref: "$mangadexManga"
get "/api/admin/mangadex/manga/:id" do |env| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
id = env.params.url["id"] id = env.params.url["id"]
@@ -149,11 +403,17 @@ class APIRouter < Router
manga = api.get_manga id manga = api.get_manga id
send_json env, manga.to_info_json send_json env, manga.to_info_json
rescue e rescue e
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tag "admin"
Koa.body ref: "$chaptersObj"
Koa.response 200, ref: "$successFailCount"
post "/api/admin/mangadex/download" do |env| post "/api/admin/mangadex/download" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
@@ -167,23 +427,40 @@ class APIRouter < Router
Time.unix chapter["time"].as_s.to_i Time.unix chapter["time"].as_s.to_i
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
}.to_json }.to_json
rescue e rescue e
@context.error e Logger.error e
send_json env, {"error" => e.message}.to_json send_json env, {"error" => e.message}.to_json
end end
end end
ws "/api/admin/mangadex/queue" do |socket, env|
interval_raw = env.params.query["interval"]?
interval = (interval_raw.to_i? if interval_raw) || 5
loop do
socket.send({
"jobs" => Queue.default.get_all,
"paused" => Queue.default.paused?,
}.to_json)
sleep interval.seconds
end
end
Koa.describe "Returns the current download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tag "admin"
Koa.response 200, ref: "$jobs"
get "/api/admin/mangadex/queue" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
jobs = @context.queue.get_all jobs = Queue.default.get_all
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => jobs,
"paused" => @context.queue.paused?, "paused" => Queue.default.paused?,
"success" => true, "success" => true,
}.to_json }.to_json
rescue e rescue e
@@ -194,6 +471,19 @@ class APIRouter < Router
end end
end end
Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD
The `action` parameter can be `delete`, `retry`, `pause` or `resume`.
When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively.
When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs in the queue.
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
MD
Koa.tag "admin"
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
Koa.query "id", required: false, desc: "A job ID"
Koa.response 200, ref: "$result"
post "/api/admin/mangadex/queue/:action" do |env| post "/api/admin/mangadex/queue/:action" do |env|
begin begin
action = env.params.url["action"] action = env.params.url["action"]
@@ -201,20 +491,20 @@ class APIRouter < Router
case action case action
when "delete" when "delete"
if id.nil? if id.nil?
@context.queue.delete_status Queue::JobStatus::Completed Queue.default.delete_status Queue::JobStatus::Completed
else else
@context.queue.delete id Queue.default.delete id
end end
when "retry" when "retry"
if id.nil? if id.nil?
@context.queue.reset Queue.default.reset
else else
@context.queue.reset id Queue.default.reset id
end end
when "pause" when "pause"
@context.queue.pause Queue.default.pause
when "resume" when "resume"
@context.queue.resume Queue.default.resume
else else
raise "Unknown queue action #{action}" raise "Unknown queue action #{action}"
end end
@@ -228,6 +518,22 @@ class APIRouter < Router
end end
end end
Koa.describe "Uploads a file to the server", <<-MD
Currently the only supported value for the `target` parameter is `cover`.
### Cover
Uploads a cover image for a title or an entry.
Query parameters:
- `tid`: A title ID
- `eid`: (Optional) An entry ID
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
MD
Koa.tag "admin"
Koa.body type: "multipart/form-data", ref: "$binaryUpload"
Koa.response 200, ref: "$result"
post "/api/admin/upload/:target" do |env| post "/api/admin/upload/:target" do |env|
begin begin
target = env.params.url["target"] target = env.params.url["target"]
@@ -242,9 +548,9 @@ class APIRouter < Router
case target case target
when "cover" when "cover"
title_id = env.params.query["title"] title_id = env.params.query["tid"]
entry_id = env.params.query["entry"]? entry_id = env.params.query["eid"]?
title = @context.library.get_title(title_id).not_nil! title = Library.default.get_title(title_id).not_nil!
unless SUPPORTED_IMG_TYPES.includes? \ unless SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? filename MIME.from_filename? filename
@@ -282,10 +588,14 @@ class APIRouter < Router
end end
end end
post "/api/admin/plugin/list" do |env| Koa.describe "Lists the chapters in a title from a plugin"
Koa.tag "admin"
Koa.body ref: "$pluginListBody"
Koa.response 200, ref: "$pluginList"
get "/api/admin/plugin/list" do |env|
begin begin
query = env.params.json["query"].as String query = env.params.query["query"].as String
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query json = plugin.list_chapters query
chapters = json["chapters"] chapters = json["chapters"]
@@ -304,6 +614,10 @@ class APIRouter < Router
end end
end end
Koa.describe "Adds a list of chapters from a plugin to the download queue"
Koa.tag "admin"
Koa.body ref: "$pluginDownload"
Koa.response 200, ref: "$successFailCount"
post "/api/admin/plugin/download" do |env| post "/api/admin/plugin/download" do |env|
begin begin
plugin = Plugin.new env.params.json["plugin"].as String plugin = Plugin.new env.params.json["plugin"].as String
@@ -320,7 +634,7 @@ class APIRouter < Router
Time.utc Time.utc
) )
} }
inserted_count = @context.queue.push jobs inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success": inserted_count, "success": inserted_count,
"fail": jobs.size - inserted_count, "fail": jobs.size - inserted_count,
@@ -333,12 +647,16 @@ class APIRouter < Router
end end
end end
Koa.describe "Returns the image dimensions of all pages in an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$dimensionResult"
get "/api/dimensions/:tid/:eid" do |env| get "/api/dimensions/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
title = @context.library.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
@@ -347,6 +665,7 @@ class APIRouter < Router
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
"margin" => Config.current.page_margin,
}.to_json }.to_json
rescue e rescue e
send_json env, { send_json env, {
@@ -355,5 +674,118 @@ class APIRouter < Router
}.to_json }.to_json
end end
end end
Koa.describe "Downloads an entry"
Koa.path "tid", desc: "A title ID"
Koa.path "eid", desc: "An entry ID"
Koa.response 200, ref: "$binary"
Koa.response 404, "Entry not found"
get "/api/download/:tid/:eid" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path
rescue e
Logger.error e
env.response.status_code = 404
end
end
Koa.describe "Gets the tags of a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$tagsResult"
get "/api/tags/:tid" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tags = title.tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns all tags"
Koa.response 200, ref: "$tagsResult"
get "/api/tags" do |env|
begin
tags = Storage.default.list_tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
put "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.add_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a tag from a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/tags/:tid/:tag" do |env|
begin
title = (Library.default.get_title env.params.url["tid"]).not_nil!
tag = env.params.url["tag"]
title.delete_tag tag
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate
@@api_json = doc.to_json if doc
get "/openapi.json" do |env|
if @@api_json
send_json env, @@api_json
else
env.response.status_code = 404
end
end
end end
end end
+60 -17
View File
@@ -1,6 +1,4 @@
require "./router" struct MainRouter
class MainRouter < Router
def initialize def initialize
get "/login" do |env| get "/login" do |env|
base_url = Config.current.base_url base_url = Config.current.base_url
@@ -11,7 +9,7 @@ class MainRouter < Router
begin begin
env.session.delete_string "token" env.session.delete_string "token"
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" Logger.error "Error when attempting to log out: #{e}"
ensure ensure
redirect env, "/login" redirect env, "/login"
end end
@@ -21,7 +19,7 @@ class MainRouter < Router
begin begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil! token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token env.session.string "token", token
@@ -41,22 +39,22 @@ class MainRouter < Router
begin begin
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json @context.library.dir, username sort_opt = SortOptions.from_info_json Library.default.dir, username
get_sort_opt get_sort_opt
titles = @context.library.sorted_titles username, sort_opt titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username percentage = titles.map &.load_percentage username
layout "library" layout "library"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/book/:title" do |env| get "/book/:title" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username sort_opt = SortOptions.from_info_json title.dir, username
@@ -68,7 +66,7 @@ class MainRouter < Router
title_percentage = title.titles.map &.load_percentage username title_percentage = title.titles.map &.load_percentage username
layout "title" layout "title"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -92,7 +90,7 @@ class MainRouter < Router
layout "plugin-download" layout "plugin-download"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
@@ -100,18 +98,63 @@ class MainRouter < Router
get "/" do |env| get "/" do |env|
begin begin
username = get_username env username = get_username env
continue_reading = @context continue_reading = Library.default
.library.get_continue_reading_entries username .get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username recently_added = Library.default.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username start_reading = Library.default.get_start_reading_titles username
titles = @context.library.titles titles = Library.default.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 } new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0 empty_library = titles.size == 0
layout "home" layout "home"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
end end
end end
get "/tags/:tag" do |env|
begin
username = get_username env
tag = env.params.url["tag"]
sort_opt = SortOptions.new
get_sort_opt
title_ids = Storage.default.get_tag_titles tag
raise "Tag #{tag} not found" if title_ids.empty?
titles = title_ids.map { |id| Library.default.get_title id }
.select Title
titles = sort_titles titles, sort_opt, username
percentage = titles.map &.load_percentage username
layout "tag"
rescue e
Logger.error e
env.response.status_code = 404
end
end
get "/tags" do |env|
tags = Storage.default.list_tags.map do |tag|
{
tag: tag,
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
count: Storage.default.get_tag_titles(tag).size,
}
end
# Sort by :count reversly, and then sort by :tag
tags.sort! do |a, b|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
end
layout "tags"
end
get "/api" do |env|
render "src/views/api.html.ecr"
end
end end
end end
+4 -18
View File
@@ -1,30 +1,16 @@
require "./router" struct OPDSRouter
class OPDSRouter < Router
def initialize def initialize
get "/opds" do |env| get "/opds" do |env|
titles = @context.library.titles titles = Library.default.titles
render_xml "src/views/opds/index.xml.ecr" render_xml "src/views/opds/index.xml.ecr"
end end
get "/opds/book/:title_id" do |env| get "/opds/book/:title_id" do |env|
begin begin
title = @context.library.get_title(env.params.url["title_id"]).not_nil! title = Library.default.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.xml.ecr" render_xml "src/views/opds/title.xml.ecr"
rescue e rescue e
@context.error e Logger.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 env.response.status_code = 404
end end
end end
+12 -12
View File
@@ -1,25 +1,23 @@
require "./router" struct ReaderRouter
class ReaderRouter < Router
def initialize def initialize
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
next layout "reader-error" if entry.err_msg next layout "reader-error" if entry.err_msg
# load progress # load progress
page = [1, entry.load_progress username].max page_idx = [1, entry.load_progress username].max
# start from page 1 if the user has finished reading the entry # start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username page_idx = 1 if entry.finished? username
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}" redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
@@ -30,10 +28,12 @@ class ReaderRouter < Router
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil! title = (Library.default.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i page_idx = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 if page_idx > entry.pages || page_idx <= 0
raise "Page #{page_idx} not found."
end
exit_url = "#{base_url}book/#{title.id}" exit_url = "#{base_url}book/#{title.id}"
@@ -45,7 +45,7 @@ class ReaderRouter < Router
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
@context.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
-3
View File
@@ -1,3 +0,0 @@
class Router
@context : Context = Context.default
end
+3 -29
View File
@@ -5,34 +5,8 @@ require "./handlers/*"
require "./util/*" require "./util/*"
require "./routes/*" require "./routes/*"
class Context
property library : Library
property storage : Storage
property queue : Queue
use_default
def initialize
@storage = Storage.default
@library = Library.default
@queue = Queue.default
end
{% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg)
Logger.{{lvl.id}} msg
end
{% end %}
end
class Server class Server
@context : Context = Context.default
def initialize def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
end
error 404 do |env| error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}" message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message" layout "message"
@@ -53,11 +27,11 @@ class Server
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
add_handler AuthHandler.new @context.storage add_handler AuthHandler.new
add_handler UploadHandler.new Config.current.upload_path add_handler UploadHandler.new Config.current.upload_path
{% if flag?(:release) %} {% if flag?(:release) %}
# when building for relase, embed the static files in binary # when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files." Logger.debug "We are in release mode. Using embedded static files."
serve_static false serve_static false
add_handler StaticHandler.new add_handler StaticHandler.new
{% end %} {% end %}
@@ -71,7 +45,7 @@ class Server
end end
def start def start
@context.debug "Starting Kemal server" Logger.debug "Starting Kemal server"
{% if flag?(:release) %} {% if flag?(:release) %}
Kemal.config.env = "production" Kemal.config.env = "production"
{% end %} {% end %}
+174 -27
View File
@@ -3,6 +3,8 @@ require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util/*" require "./util/*"
require "mg"
require "../migration/*"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -13,9 +15,10 @@ def verify_password(hash, pw)
end end
class Storage class Storage
@@insert_ids = [] of IDTuple
@path : String @path : String
@db : DB::Database? @db : DB::Database?
@insert_ids = [] of IDTuple
alias IDTuple = NamedTuple(path: String, alias IDTuple = NamedTuple(path: String,
id: String, id: String,
@@ -35,32 +38,21 @@ class Storage
MainFiber.run do MainFiber.run do
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
# We create the `ids` table first. even if the uses has an MG::Migration.new(db, log: Logger.default.raw_log).migrate
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e rescue e
unless e.message.not_nil!.ends_with? "already exists" Logger.fatal "DB migration failed. #{e}"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e raise e
end end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32 user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0 init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
init_admin if init_user # Verifies that the default username in config is valid
if Config.current.disable_login
username = Config.current.default_username
unless username_exists username
raise "Default username #{username} does not exist"
end
end end
end end
unless @auto_close unless @auto_close
@@ -81,13 +73,37 @@ class Storage
private def get_db(&block : DB::Database ->) private def get_db(&block : DB::Database ->)
if @db.nil? if @db.nil?
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "PRAGMA foreign_keys = 1"
yield db yield db
end end
else else
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
yield @db.not_nil! yield @db.not_nil!
end end
end end
def username_exists(username)
exists = false
MainFiber.run do
get_db do |db|
exists = db.query_one("select count(*) from users where " \
"username = (?)", username, as: Int32) > 0
end
end
exists
end
def username_is_admin(username)
is_admin = false
MainFiber.run do
get_db do |db|
is_admin = db.query_one("select admin from users where " \
"username = (?)", username, as: Int32) > 0
end
end
is_admin
end
def verify_user(username, password) def verify_user(username, password)
out_token = nil out_token = nil
MainFiber.run do MainFiber.run do
@@ -218,28 +234,159 @@ class Storage
id = nil id = nil
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
if is_title
id = db.query_one? "select id from titles where path = (?)", path,
as: String
else
id = db.query_one? "select id from ids where path = (?)", path, id = db.query_one? "select id from ids where path = (?)", path,
as: {String} as: String
end
end end
end end
id id
end end
def insert_id(tp : IDTuple) def insert_id(tp : IDTuple)
@insert_ids << tp @@insert_ids << tp
end end
def bulk_insert_ids def bulk_insert_ids
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
db.transaction do |tx| db.transaction do |tran|
@insert_ids.each do |tp| conn = tran.connection
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path], @@insert_ids.each do |tp|
tp[:id], tp[:is_title] ? 1 : 0 if tp[:is_title]
conn.exec "insert into titles values (?, ?, null)", tp[:id],
tp[:path]
else
conn.exec "insert into ids values (?, ?)", tp[:path], tp[:id]
end end
end end
end end
@insert_ids.clear end
@@insert_ids.clear
end
end
def save_thumbnail(id : String, img : Image)
MainFiber.run do
get_db do |db|
db.exec "insert into thumbnails values (?, ?, ?, ?, ?)", id, img.data,
img.filename, img.mime, img.size
end
end
end
def get_thumbnail(id : String) : Image?
img = nil
MainFiber.run do
get_db do |db|
db.query_one? "select * from thumbnails where id = (?)", id do |res|
img = Image.from_db res
end
end
end
img
end
def get_title_tags(id : String) : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def get_tag_titles(tag : String) : Array(String)
tids = [] of String
MainFiber.run do
get_db do |db|
db.query "select id from tags where tag = (?)", tag do |rs|
rs.each do
tids << rs.read String
end
end
end
end
tids
end
def list_tags : Array(String)
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select distinct tag from tags" do |rs|
rs.each do
tags << rs.read String
end
end
end
end
tags
end
def add_tag(id : String, tag : String)
err = nil
MainFiber.run do
begin
get_db do |db|
db.exec "insert into tags values (?, ?)", id, tag
end
rescue e
err = e
end
end
raise err.not_nil! if err
end
def delete_tag(id : String, tag : String)
MainFiber.run do
get_db do |db|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
end
end
end
def optimize
MainFiber.run do
Logger.info "Starting DB optimization"
get_db do |db|
# Delete dangling entry IDs
trash_ids = [] of String
db.query "select path, id from ids" do |rs|
rs.each do
path = rs.read String
trash_ids << rs.read String unless File.exists? path
end
end
db.exec "delete from ids where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_ids.size} dangling entry IDs deleted" \
if trash_ids.size > 0
# Delete dangling title IDs
trash_titles = [] of String
db.query "select path, id from titles" do |rs|
rs.each do
path = rs.read String
trash_titles << rs.read String unless Dir.exists? path
end
end
db.exec "delete from titles where id in " \
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_titles.size} dangling title IDs deleted" \
if trash_titles.size > 0
end
Logger.info "DB optimization finished"
end end
end end
+3 -2
View File
@@ -5,7 +5,7 @@ require "http_proxy"
module HTTP module HTTP
class Client class Client
private def self.exec(uri : URI, tls : TLSContext = nil) private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Using monkey-patched HTTP::Client" Logger.debug "Setting proxy"
previous_def uri, tls do |client, path| previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri client.set_proxy get_proxy uri
yield client, path yield client, path
@@ -35,7 +35,8 @@ private def env_to_proxy(key : String) : HTTP::Proxy::Client?
begin begin
uri = URI.parse val uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil! HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
username: uri.user, password: uri.password
rescue rescue
nil nil
end end
+31
View File
@@ -61,3 +61,34 @@ class String
self.chars.all? { |c| c.alphanumeric? || c == '_' } self.chars.all? { |c| c.alphanumeric? || c == '_' }
end end
end end
def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]?
return false unless val
val.downcase.in? "1", "true"
end
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary = titles
case opt.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
+54 -14
View File
@@ -1,30 +1,58 @@
# Web related helper functions/macros # Web related helper functions/macros
# This macro defines `is_admin` when used
macro check_admin_access
is_admin = false
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
if Config.current.disable_login
is_admin = Storage.default.
username_is_admin Config.current.default_username
end
if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token
end
end
macro layout(name) macro layout(name)
base_url = Config.current.base_url base_url = Config.current.base_url
check_admin_access
begin begin
is_admin = false
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}} page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e rescue e
message = e.to_s message = e.to_s
@context.error message Logger.error message
page = "Error"
render "src/views/message.html.ecr", "src/views/layout.html.ecr" render "src/views/message.html.ecr", "src/views/layout.html.ecr"
end end
end end
macro send_error_page(msg)
message = {{msg}}
base_url = Config.current.base_url
check_admin_access
page = "Error"
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
send_file env, html.to_slice, "text/html"
end
macro send_img(env, img) macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime send_file {{env}}, {{img}}.data, {{img}}.mime
end end
macro get_username(env) macro get_username(env)
# if the request gets here, it has gone through the auth handler, and begin
# we can be sure that a valid token exists, so we can use not_nil! here
token = env.session.string "token" token = env.session.string "token"
(@context.storage.verify_token token).not_nil! (Storage.default.verify_token token).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
else
raise e
end
end
end end
def send_json(env, json) def send_json(env, json)
@@ -46,12 +74,7 @@ def hash_to_query(hash)
end end
def request_path_startswith(env, ary) def request_path_startswith(env, ary)
ary.each do |prefix| ary.any? { |prefix| env.request.path.starts_with? prefix }
if env.request.path.starts_with? prefix
return true
end
end
false
end end
def requesting_static_file(env) def requesting_static_file(env)
@@ -81,3 +104,20 @@ macro get_sort_opt
sort_opt = SortOptions.new sort_method, is_ascending sort_opt = SortOptions.new sort_method, is_ascending
end end
end end
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
previous_def uri, tls do |client, path|
if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
Logger.debug "Disabling SSL verification"
client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
end
Logger.debug "Setting read timeout"
client.read_timeout = Config.current.download_timeout_seconds.seconds
Logger.debug "Requesting #{uri}"
yield client, path
end
end
end
end
+20 -10
View File
@@ -1,15 +1,25 @@
<ul class="uk-list uk-list-large uk-list-divider"> <ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li data-url="<%= base_url %>admin/user">User Managerment</li> <li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li onclick="if(!scanning){scan()}"> <li>
<span id="scan">Scan Library Files</span> <a class="uk-link-reset" @click="scan()">
<span id="scan-status" class="uk-align-right"> <span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
<div uk-spinner hidden></div> <div class="uk-align-right">
<span hidden></span> <div uk-spinner x-show="scanning"></div>
</span> <span x-show="!scanning && scanMs > 0" x-text="`Scan ${scanTitles} titles in ${scanMs}ms`"></span>
</div>
</a>
</li> </li>
<li class="nopointer"> <li>
<a class="uk-link-reset" @click="generateThumbnails()">
<span :style="`${generating ? 'color:grey' : ''}`">Generate Thumbnails</span>
<div class="uk-align-right">
<span x-show="generating && progress > 0" x-text="`${(progress * 100).toFixed(2)}%`"></span>
</div>
</a>
</li>
<li>
<span>Theme</span> <span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2"> <select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
<option>Dark</option> <option>Dark</option>
<option>Light</option> <option>Light</option>
<option>System</option> <option>System</option>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango API Documentation</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<redoc spec-url="/openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>
+3 -2
View File
@@ -35,7 +35,7 @@
onclick="location='<%= base_url %>book/<%= item.id %>'" onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>> <% end %>>
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}" <div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true, selecting: false}" :class="{selected: selected}" @count.window="selecting = $event.detail.count > 0"
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %> <% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
x-init="disabled = false" x-init="disabled = false"
<% end %>> <% end %>>
@@ -45,6 +45,7 @@
class="grayscale" class="grayscale"
<% end %>> <% end %>>
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)"> <div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
<div class="uk-height-1-1 uk-width-1-1" x-show="selecting" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')"></div>
<div class="uk-position-center"> <div class="uk-position-center">
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span> <span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
</div> </div>
@@ -75,7 +76,7 @@
<% end %> <% end %>
<% if item.is_a? Title %> <% if item.is_a? Title %>
<% if grouped_count == 1 %> <% if grouped_count == 1 %>
<p class="uk-text-meta"><%= item.size %> entries</p> <p class="uk-text-meta"><%= item.content_label %></p>
<% else %> <% else %>
<p class="uk-text-meta"><%= grouped_count %> new entries</p> <p class="uk-text-meta"><%= grouped_count %> new entries</p>
<% end %> <% end %>
@@ -0,0 +1,3 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
+4 -4
View File
@@ -1,7 +1,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge"> <meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title> <title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader"> <meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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/uikit.css" />
@@ -12,7 +12,7 @@
<script defer src="<%= base_url %>js/fontawesome.min.js"></script> <script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script> <script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script> <script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script> <script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/theme.js"></script> <script src="<%= base_url %>js/common.js"></script>
</head> </head>
+48 -12
View File
@@ -1,13 +1,9 @@
<div x-data="component()" x-init="init()">
<div class="uk-margin"> <div class="uk-margin">
<div id="actions" class="uk-margin"> <button class="uk-button uk-button-default" @click="jobAction('delete')">Delete Completed Tasks</button>
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button> <button class="uk-button uk-button-default" @click="jobAction('retry')">Retry Failed Tasks</button>
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></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> </div>
<table class="uk-table uk-table-striped uk-overflow-auto"> <table class="uk-table uk-table-striped uk-overflow-auto">
<thead> <thead>
@@ -21,12 +17,52 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table> </table>
</div>
<% content_for "script" do %> <% 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/moment.js/2.24.0/moment.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download-manager.js"></script> <script src="<%= base_url %>js/download-manager.js"></script>
+2 -3
View File
@@ -11,7 +11,7 @@
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd> <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> <dt style="font-weight: 500;">Can't see your files yet?</dt>
<dd> <dd>
You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete You must wait <%= Config.current.scan_interval_minutes %> minutes for the library scan to complete
<% if is_admin %> <% if is_admin %>
, or manually re-scan from <a href="<%= base_url %>admin">Admin</a> , or manually re-scan from <a href="<%= base_url %>admin">Admin</a>
<% end %>. <% end %>.
@@ -77,8 +77,7 @@
<%- end -%> <%- end -%>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<% end %> <% end %>
+4 -2
View File
@@ -11,6 +11,7 @@
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav> <ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent"> <li class="uk-parent">
@@ -36,10 +37,11 @@
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div> <div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div> </div>
<div class="uk-navbar-left uk-visible@s"> <div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a> <a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
<ul class="uk-navbar-nav"> <ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li> <li>
@@ -67,7 +69,7 @@
</div> </div>
<div class="uk-section uk-section-small"> <div class="uk-section uk-section-small">
</div> </div>
<div class="uk-section uk-section-small" id="main-section"> <div class="uk-section uk-section-small" style="position:relative;">
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<div id="alert"></div> <div id="alert"></div>
<%= content %> <%= content %>
+1 -2
View File
@@ -24,8 +24,7 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script> <script src="<%= base_url %>js/sort-items.js"></script>
<% end %> <% end %>
+1
View File
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<% page = "Login" %>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
+1 -1
View File
@@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" /> <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/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
+1 -3
View File
@@ -21,9 +21,7 @@
<% content_for "script" do %> <% content_for "script" do %>
<script> <script>
UIkit.modal('#modal').show().then(function() { UIkit.modal('#modal').show();
styleModal();
});
UIkit.util.on('#modal', 'hide', function() { UIkit.util.on('#modal', 'hide', function() {
location.href = "<%= base_url %>book/<%= entry.book.id %>"; location.href = "<%= base_url %>book/<%= entry.book.id %>";
}); });
+44 -16
View File
@@ -1,28 +1,31 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="reader-bg"> <html style="background-color: black;">
<% page = "Reader" %>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg"> <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div>
<div class="uk-container uk-container-small"> <div class="uk-container uk-container-small">
<div id="alert"></div> <div id="alert"></div>
<div id="root" x-data="{
loading: true,
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: []
}">
<div x-show="loading"> <div x-show="loading">
<div :class="alertClass" x-show="msg" uk-alert> <div :class="alertClass" x-show="msg" uk-alert>
<p x-text="msg"></p> <p x-text="msg"></p>
</div> </div>
</div> </div>
<div x-show="!loading" x-cloak> </div>
<div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items"> <template x-for="item in items">
<img <img
uk-img uk-img
class="uk-align-center" class="uk-align-center"
:style="item.style"
:data-src="item.url" :data-src="item.url"
:width="item.width" :width="item.width"
:height="item.height" :height="item.height"
@@ -33,10 +36,26 @@
<%- if next_entry_url -%> <%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%> <%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button> <button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
<%- end -%> <%- end -%>
</div> </div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
`" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -52,25 +71,34 @@
<p id="progress-label"></p> <p id="progress-label"></p>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to page</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="page-select" class="uk-select"> <select id="page-select" class="uk-select" @change="pageChanged()">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option> <option value="<%= p %>"><%= p %></option>
<%- end -%> <%- end -%>
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls">
<select id="mode-select" class="uk-select" @change="modeChanged($nextTick)">
<option value="continuous">Continuous</option>
<option value="paged">Paged</option>
</select>
</div>
</div>
</div> </div>
<div class="uk-modal-footer uk-text-right"> <div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button> <button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const base_url = "<%= base_url %>"; const base_url = "<%= base_url %>";
const page = <%= page %>; const page = <%= page_idx %>;
const tid = "<%= title.id %>"; const tid = "<%= title.id %>";
const eid = "<%= entry.id %>"; const eid = "<%= entry.id %>";
</script> </script>
@@ -83,7 +111,7 @@
<style> <style>
img[data-src][src*='data:image'] { background: white; } img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; } img { width: 100%; }
</style> </style>
</html> </html>
+30
View File
@@ -0,0 +1,30 @@
<h2 class=uk-title>Tag: <%= tag %></h2>
<p class="uk-text-meta"><%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> tagged</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"auto" => "Auto",
"time_modified" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>
+8
View File
@@ -0,0 +1,8 @@
<h2 class=uk-title>Tags</h2>
<p class="uk-text-meta"><%= tags.size %> <%= tags.size > 1 ? "tags" : "tag" %> found</p>
<% tags.each do |tag| %>
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" href="<%= base_url %>tags/<%= tag[:encoded_tag] %>"><%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>)</a>
</span>
<% end %>
+12 -4
View File
@@ -1,5 +1,5 @@
<div> <div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>"> <div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++; $dispatch('count', {count: count})" @remove.window="count--; $dispatch('count', {count: count})" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid> <div class="uk-child-width-1-3" uk-grid>
<div> <div>
<p x-text="count + ' items selected'" style="color:orange"></p> <p x-text="count + ' items selected'" style="color:orange"></p>
@@ -32,7 +32,13 @@
<%- end -%> <%- end -%>
<li class="uk-disabled"><a><%= title.display_name %></a></li> <li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul> </ul>
<p class="uk-text-meta"><%= title.size %> entries found</p> <p class="uk-text-meta"><%= title.content_label %> found</p>
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)" x-show="!loading">
<select class="tag-select" multiple="multiple" style="width:100%">
</select>
</div>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s"> <div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default"> <form class="uk-search uk-search-default">
@@ -117,8 +123,10 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <%= render_component "dots-scripts" %>
<script src="<%= base_url %>js/dots.js"></script> <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
<link href="/css/tags.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script> <script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script> <script src="<%= base_url %>js/search.js"></script>