Compare commits

..

83 Commits

Author SHA1 Message Date
Alex Ling
e65cd05ef1 Add the option to disable ellipsis when truncating 2020-05-15 09:50:35 +00:00
Alex Ling
a354d811d9 Merge branch 'dev' 2020-04-22 14:36:36 +00:00
Alex Ling
22d757362a Update README.md 2020-04-22 22:34:02 +08:00
Alex Ling
8afcea7e87 Update README.md 2020-04-22 22:31:40 +08:00
Alex Ling
fb05e913a0 Limit cover image types to png/jpeg in the web UI 2020-04-20 07:36:55 +00:00
Alex Ling
490888ad71 Bump version to 0.3.0 2020-04-19 16:23:00 +00:00
Alex Ling
20d71bfa81 Finish #30 2020-04-19 16:11:23 +00:00
Alex Ling
ec6a7bd3d9 Read/unread a directory with API 2020-04-19 15:47:36 +00:00
Alex Ling
b449d906ec Merge branch 'cover' into dev 2020-04-19 14:39:19 +00:00
Alex Ling
f66bec5545 Update frontend for cover upload 2020-04-19 14:33:24 +00:00
Alex Ling
ce5f444012 Remove debug code in upload handler 2020-04-19 14:32:58 +00:00
Alex Ling
8506044232 Handle errors in the "/" endpoint 2020-04-14 06:08:10 +00:00
Alex Ling
079dd8e280 Fix layout macro message displaying bug 2020-04-14 06:08:10 +00:00
Alex Ling
8262a163db Finish the API endpoint for cover upload 2020-04-14 06:09:23 +00:00
Alex Ling
d6b22ef736 Don't return from DB blocks 2020-04-10 15:24:49 +00:00
Alex Ling
39f4897fc5 Set status as "Error" if downloaded zip is invalid
(#29)
2020-04-08 10:31:30 +00:00
Alex Ling
fc6a33e5fd Update Makefile 2020-04-08 07:18:25 +00:00
Alex Ling
7d97d21d40 Run Ameba and Crystal formatting tool on push 2020-04-08 07:09:54 +00:00
Alex Ling
fcf9d39047 Project-wise refactoring to follow Ameba 2020-04-08 06:45:45 +00:00
Alex Ling
d33cae7618 Use Ameba 2020-04-08 06:45:45 +00:00
Alex Ling
8b184ed48d Project-wise code formatting 2020-04-08 05:25:12 +00:00
Alex Ling
d3309a810b Update bug_report.md 2020-04-07 22:15:07 +08:00
Alex Ling
3866c81588 Use the updated Logger class in spec 2020-04-07 13:26:09 +00:00
Alex Ling
2c31f594a4 Use the new Log module in Crystal 0.34.0 2020-04-07 12:58:42 +00:00
Alex Ling
c572c56a39 Upgrade Crystal version to 0.34.0 2020-04-07 12:57:50 +00:00
Alex Ling
e670a083a3 Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling
9b23e1759d Update shards.lock 2020-04-07 12:57:50 +00:00
Alex Ling
14e3470b12 Hide rename buttons when the login user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling
8ce51a6163 Hide the "Admin" and "Download" buttons when user is not admin 2020-04-07 12:57:50 +00:00
Alex Ling
1d4237d687 Pass in admin information when rendering all pages 2020-04-07 12:57:50 +00:00
Alex Ling
b7c0515af7 Fix dark mode on login page 2020-04-07 12:57:50 +00:00
Alex Ling
75edfcdb5b Set and load display names in frontend 2020-04-07 12:57:50 +00:00
Alex Ling
51d19328be Set up API endpoint for setting display names 2020-04-07 12:57:50 +00:00
Alex Ling
d405498af4 Update shards.lock 2020-04-07 04:01:04 +00:00
Alex Ling
696f79aea1 Merge pull request #28 from noirscape/env-file
Use a .env file for docker-compose configuration.
2020-04-07 11:43:32 +08:00
noirscape
d2da8d0b9a docker: Use a .env file 2020-04-06 21:49:14 +02:00
Alex Ling
4e961192d4 Update README.md 2020-04-06 22:44:45 +08:00
Alex Ling
8b90524a2c Create dockerhub.yml 2020-04-06 21:45:08 +08:00
Alex Ling
c9b8770b9f Bump version to v0.2.5 2020-04-02 09:12:35 +00:00
Alex Ling
e568ec8878 Fix the unexpected sorting behavior on Chrome 2020-04-02 09:06:16 +00:00
Alex Ling
ac3df03d88 Show page counts on chapter cards 2020-04-02 05:44:29 +00:00
Alex Ling
7c9728683c On the title page, hide progress label of nested titles 2020-04-02 00:16:19 +00:00
Alex Ling
d921d04abf Bump version to v0.2.4 2020-04-01 23:32:16 +00:00
Alex Ling
5400c8c8ef Fix a UI bug that shows "resume download" button on download manager even when the downloading process is not paused 2020-04-01 23:21:32 +00:00
Alex Ling
58e96cd4fe Watch the title element size for change 2020-04-01 06:13:03 +00:00
Alex Ling
aa09f3a86f Only show tooltips for truncated titles 2020-04-01 05:59:46 +00:00
Alex Ling
a5daded453 Fix the width and height of cover images (#23) 2020-04-01 04:51:57 +00:00
Alex Ling
4968cb8e18 Add tooltips to show un-truncated titles 2020-04-01 04:49:53 +00:00
Alex Ling
27c6e02da8 Run the truncate function after DOM is ready 2020-04-01 04:48:53 +00:00
Alex Ling
68d1b55aea Limit title text height in CSS 2020-04-01 04:47:55 +00:00
Alex Ling
32dc3e84b9 Lazy load images in library/title page to improve page load time 2020-03-31 08:44:07 +00:00
Alex Ling
460fcdf2f5 Limit the number of lines to display in card titles 2020-03-30 20:36:27 +00:00
Alex Ling
c6369f9f26 Prevent flash of white in cards 2020-03-30 20:35:30 +00:00
Alex Ling
aa147602fc Bump version number 0.2.2 -> 0.2.3 2020-03-27 05:00:14 +00:00
Alex Ling
d58c83fbd8 Use BigInt when sorting filenames (#22) 2020-03-27 04:45:03 +00:00
Alex Ling
1a0c3d81ce Add Patreon 2020-03-21 05:18:53 +00:00
Alex Ling
33c61fd8c1 Add build badge 2020-03-19 16:04:06 -04:00
Alex Ling
6eba3fe351 Create build.yml 2020-03-19 19:58:59 +00:00
Alex Ling
da2708abe5 Put mango binary in / instead of /root/Mango/ 2020-03-19 18:17:26 +00:00
Alex Ling
febf344d33 Remove unnecessary libraries 2020-03-19 18:16:48 +00:00
Alex Ling
ae15398b6c Name the builder stage 2020-03-19 18:14:02 +00:00
Alex Ling
b28f6046dd Merge pull request #17 from WROIATE/master
Update Dockerfile to reduce the image size
2020-03-19 12:29:19 -04:00
Jarao
91b823450c Update Dockerfile 2020-03-19 13:00:11 +08:00
Alex Ling
085fba611c Update README.md 2020-03-17 11:59:32 -04:00
Alex Ling
f8d633c751 Add example library structure to README 2020-03-17 11:45:46 -04:00
Alex Ling
f5e6f42fc2 Update README.md 2020-03-15 13:16:19 -04:00
Alex Ling
3ca6d3d338 Bump version (0.2.0 -> 0.2.1) 2020-03-15 17:09:27 +00:00
Alex Ling
750a28eccb Break words in modal title and path to handle long text 2020-03-15 02:58:27 +00:00
Alex Ling
88b16445e2 Show entry title instead of book title in modal 2020-03-15 02:55:35 +00:00
Alex Ling
7774efa471 When a title has no entry as immediate child, always return 0 as the reading progress 2020-03-15 02:30:18 +00:00
Alex Ling
4aeda53806 Sort title_ids and entries alphanumerically 2020-03-15 02:29:45 +00:00
Alex Ling
5d62a87720 Fix inaccurate sorting when sorting by progress 2020-03-15 02:28:21 +00:00
Alex Ling
e902e1dff0 Merge branch 'nested' into v0.2.1 2020-03-15 02:15:55 +00:00
Alex Ling
9fe32b5011 When a title contains no entry as immediate child, display mango logo and remove progress badge 2020-03-15 02:10:22 +00:00
Alex Ling
e65d701e0a Show sum of entries and titles count when displaying the number of entries 2020-03-15 02:08:20 +00:00
Alex Ling
5a500364fc Show a list of parent directories on the title page 2020-03-15 01:45:10 +00:00
Alex Ling
3e42266955 List the parent title objects in Title.to_json 2020-03-15 01:31:14 +00:00
Alex Ling
6407cea7bf Refactor src/library.cr to reduce memory usage
- Store the `Title` objects in `Library@title_hash`
- The `Title` objects only stores IDs to other titles
2020-03-15 01:05:37 +00:00
Alex Ling
7e22cc5f57 Fix bug in API /api/book/:tid that causes 500 2020-03-15 01:03:49 +00:00
Alex Ling
e68678f2fb Remove unnecessary JSON::Field calls 2020-03-14 23:59:46 +00:00
Alex Ling
82fb45b242 Use json builder in src/library.cr instead of json mapping 2020-03-14 23:58:49 +00:00
Alex Ling
46dfc2f712 Set login cookie expiration date 2020-03-14 22:53:52 +00:00
Alex Ling
8c7ced87f1 Add nested library support (WIP) 2020-03-12 20:37:03 +00:00
50 changed files with 2609 additions and 1867 deletions

9
.ameba.yml Normal file
View File

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

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

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

View File

@@ -26,7 +26,7 @@ A clear and concise description of what you expected to happen.
- Mango Version [e.g. v0.1.0] - Mango Version [e.g. v0.1.0]
**Docker (if you are running Mango in a Docker container)** **Docker (if you are running Mango in a Docker container)**
- The `docker-compose.yml` file you are using - The `docker-compose.yml` file you are using, or your `.env` file.
**Additional context** **Additional context**
Add any other context about the problem here. Add screenshots if applicable. Add any other context about the problem here. Add screenshots if applicable.

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

@@ -0,0 +1,26 @@
name: Build
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master, dev ]
jobs:
build:
runs-on: ubuntu-latest
container:
image: crystallang/crystal:0.34.0-alpine
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static
- name: Build
run: make
- name: Linter
run: make check
- name: Run tests
run: make test

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

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

2
.gitignore vendored
View File

@@ -7,4 +7,4 @@ node_modules
yarn.lock yarn.lock
dist dist
mango mango
docker-compose.yml .env

View File

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

View File

@@ -1,4 +1,4 @@
PREFIX=/usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
all: uglify | build all: uglify | build
@@ -22,6 +22,10 @@ run:
test: test:
crystal spec crystal spec
check:
crystal tool format --check
./bin/ameba
install: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango

View File

@@ -5,13 +5,14 @@
# Mango # Mango
[![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
Mango is a self-hosted manga server and reader. Its features include Mango is a self-hosted manga server and reader. Its features include
- Multi-user support - Multi-user support
- Dark/light mode switch - Dark/light mode switch
- Supports both `.zip` and `.cbz` formats - Supports both `.zip` and `.cbz` formats
- Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader - Built-in [MangaDex](https://mangadex.org/) downloader
- 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
@@ -21,20 +22,24 @@ Mango is a self-hosted manga server and reader. Its features include
### Pre-built Binary ### Pre-built Binary
1. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
### Docker ### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose` 1. Make sure you have docker installed and running. You will also need `docker-compose`
2. Clone the repository 2. Clone the repository
3. Copy `docker-compose.example.yml` to `docker-compose.yml` 3. Copy the `env.example` file to `.env`
4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine 4. Fill out the values in the `.env` file. Note that the main and config directories will be created if they don't already exist. The files in these folders will be owned by the root user
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside 5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
6. Head over to `localhost:9000` to log in 6. Head over to `localhost:9000` (or a different port if you changed it) to log in
### Docker (via Dockerhub)
The official docker images are available on [Dockerhub](https://hub.docker.com/r/hkalexling/mango).
### Build from source ### Build from source
1. Make sure you have Crystal, Node and Yarn installed. You might also need to install the development headers for `libsqlite3` and `libyaml`. 1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
2. Clone the repository 2. Clone the repository
3. `make && sudo make install` 3. `make && sudo make install`
4. Start Mango by running the command `mango` 4. Start Mango by running the command `mango`
@@ -45,7 +50,7 @@ Mango is a self-hosted manga server and reader. Its features include
### CLI ### CLI
``` ```
Mango e-manga server/reader. Version 0.2.0 Mango e-manga server/reader. Version 0.3.0
-v, --version Show version -v, --version Show version
-h, --help Show help -h, --help Show help
@@ -59,8 +64,9 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
port: 9000 port: 9000
library_path: ~/mango/library library_path: /home/alex_ling/mango/library
db_path: ~/mango/mango.db upload_path: /home/alex_ling/mango/uploads
db_path: /home/alex_ling/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
log_level: info log_level: info
mangadex: mangadex:
@@ -68,26 +74,29 @@ mangadex:
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
download_queue_db_path: ~/mango/queue.db download_queue_db_path: /home/alex_ling/mango/queue.db
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
### Required Library Structure ### Library Structure
Please make sure that your library directory has the following structure: You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
``` ```
. .
├── Manga 1 ├── Manga 1
│   └── Manga 1.cbz │   ├── Volume 1.cbz
│   ├── Volume 2.cbz
│   ├── Volume 3.cbz
│   └── Volume 4.zip
└── Manga 2 └── Manga 2
├── Vol 0001.zip    └── Vol. 1
├── Vol 0002.zip    └── Ch.1 - Ch.3
├── Vol 0003.zip    ├── 1.zip
├── Vol 0004.zip    ├── 2.zip
└── Vol 0005.zip    └── 3.zip
``` ```
### Initial Login ### Initial Login

View File

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

10
env.example Normal file
View File

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

View File

@@ -5,7 +5,20 @@
padding: 20px; padding: 20px;
} }
.uk-card-media-top { .uk-card-media-top {
max-height: 350px; 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 {
height: 3em;
overflow: hidden; overflow: hidden;
} }
.acard:hover { .acard:hover {
@@ -20,7 +33,7 @@
#scan-status { #scan-status {
cursor: auto; cursor: auto;
} }
.uk-card-title { .break-word {
word-wrap: break-word; word-wrap: break-word;
} }
.uk-logo > img { .uk-logo > img {
@@ -44,3 +57,18 @@
td > .uk-dropdown { td > .uk-dropdown {
white-space: pre-line; 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%;
}

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

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

View File

@@ -50,10 +50,10 @@ $(() => {
sortedKeys.sort((a, b) => { sortedKeys.sort((a, b) => {
// sort by frequency of the key first // sort by frequency of the key first
if (keyRange[a][2] !== keyRange[b][2]) { if (keyRange[a][2] !== keyRange[b][2]) {
return keyRange[a][2] < keyRange[b][2]; return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
} }
// then sort by range of the key // then sort by range of the key
return (keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0]); return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
}); });
console.log(sortedKeys); console.log(sortedKeys);
@@ -70,7 +70,7 @@ $(() => {
return -1; return -1;
if (a.numbers[key] === b.numbers[key]) if (a.numbers[key] === b.numbers[key])
continue; continue;
return a.numbers[key] > b.numbers[key]; return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
} }
return 0; return 0;
}); });
@@ -93,8 +93,8 @@ $(() => {
else if (by === 'date') else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime'); res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else if (by === 'progress') { else if (by === 'progress') {
const ap = $(a).attr('data-progress'); const ap = parseFloat($(a).attr('data-progress'));
const bp = $(b).attr('data-progress'); const bp = parseFloat($(b).attr('data-progress'));
if (ap === bp) if (ap === bp)
// if progress is the same, we compare by name // if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text(); res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
@@ -102,12 +102,11 @@ $(() => {
res = ap > bp; res = ap > bp;
} }
if (dir === 'up') if (dir === 'up')
return res; return res ? 1 : -1;
else else
return !res; return !res ? 1 : -1;
}); });
} }
var html = '';
$('#item-container').append(items); $('#item-container').append(items);
}; };

View File

@@ -15,48 +15,20 @@ const toggleTheme = () => {
saveTheme(newTheme); saveTheme(newTheme);
}; };
// https://stackoverflow.com/a/28344281
const hasClass = (ele,cls) => {
return !!ele.className.match(new RegExp('(\\s|^)'+cls+'(\\s|$)'));
};
const addClass = (ele,cls) => {
if (!hasClass(ele,cls)) ele.className += " "+cls;
};
const removeClass = (ele,cls) => {
if (hasClass(ele,cls)) {
var reg = new RegExp('(\\s|^)'+cls+'(\\s|$)');
ele.className=ele.className.replace(reg,' ');
}
};
const addClassToClass = (targetCls, newCls) => {
const elements = document.getElementsByClassName(targetCls);
for (let i = 0; i < elements.length; i++) {
addClass(elements[i], newCls);
}
};
const removeClassFromClass = (targetCls, newCls) => {
const elements = document.getElementsByClassName(targetCls);
for (let i = 0; i < elements.length; i++) {
removeClass(elements[i], newCls);
}
};
const setTheme = themeStr => { const setTheme = themeStr => {
if (themeStr === 'dark') { if (themeStr === 'dark') {
document.getElementsByTagName('html')[0].style.background = 'rgb(20, 20, 20)'; $('html').css('background', 'rgb(20, 20, 20)');
addClass(document.getElementsByTagName('body')[0], 'uk-light'); $('body').addClass('uk-light');
addClassToClass('uk-card', 'uk-card-secondary'); $('.uk-card').addClass('uk-card-secondary');
removeClassFromClass('uk-card', 'uk-card-default'); $('.uk-card').removeClass('uk-card-default');
addClassToClass('ui-widget-content', 'dark'); $('.ui-widget-content').addClass('dark');
} }
else { else {
document.getElementsByTagName('html')[0].style.background = ''; $('html').css('background', '');
removeClass(document.getElementsByTagName('body')[0], 'uk-light'); $('body').removeClass('uk-light');
removeClassFromClass('uk-card', 'uk-card-secondary'); $('.uk-card').removeClass('uk-card-secondary');
addClassToClass('uk-card', 'uk-card-default'); $('.uk-card').addClass('uk-card-default');
removeClassFromClass('ui-widget-content', 'dark'); $('.ui-widget-content').removeClass('dark');
} }
}; };
@@ -69,10 +41,3 @@ const styleModal = () => {
// do it before document is ready to prevent the initial flash of white // do it before document is ready to prevent the initial flash of white
setTheme(getTheme()); setTheme(getTheme());
document.addEventListener('DOMContentLoaded', () => {
// because this script is attached at the top of HTML, the style on uk-card
// won't be applied because the elements are not available yet. We have to
// apply the theme again for it to take effect
setTheme(getTheme());
}, false);

View File

@@ -15,7 +15,10 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
if (percentage === 100) { if (percentage === 100) {
$('#read-btn').attr('hidden', ''); $('#read-btn').attr('hidden', '');
} }
$('#modal-title').text(title); $('#modal-title').find('span').text(entry);
$('#modal-title').next().attr('data-id', titleID);
$('#modal-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
@@ -29,11 +32,18 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
styleModal(); styleModal();
} }
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) { const updateProgress = (tid, eid, page) => {
let url = `/api/progress/${tid}/${page}`
const query = $.param({entry: eid});
if (eid)
url += `?${query}`;
$.post(url, (data) => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} }
@@ -42,4 +52,108 @@ function updateProgress(titleID, entryID, page) {
alert('danger', error); alert('danger', error);
} }
}); });
};
const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
console.log(name);
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
} }
const query = $.param({ entry: eid });
let url = `/api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
}
else {
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
});
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
styleModal();
};
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId};
if (eid)
queryObj['entry'] = eid;
const query = $.param(queryObj);
const url = `/api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};

View File

@@ -1,21 +1,25 @@
version: 1.0 version: 1.0
shards: shards:
ameba:
github: crystal-ameba/ameba
version: 0.12.0
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8
db: db:
github: crystal-lang/crystal-db github: crystal-lang/crystal-db
version: 0.8.0 version: 0.9.0
exception_page: exception_page:
github: crystal-loot/exception_page github: crystal-loot/exception_page
version: 0.1.2 version: 0.1.4
kemal: kemal:
github: kemalcr/kemal github: kemalcr/kemal
version: 0.26.1 version: 0.26.1
kemal-basic-auth:
github: kemalcr/kemal-basic-auth
version: 0.2.0
kilt: kilt:
github: jeromegn/kilt github: jeromegn/kilt
version: 0.4.0 version: 0.4.0
@@ -26,5 +30,5 @@ shards:
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
version: 0.15.0 version: 0.16.0

View File

@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.1.0 version: 0.3.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.32.1 crystal: 0.34.0
license: MIT license: MIT
@@ -19,3 +19,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
development_dependencies:
ameba:
github: crystal-ameba/ameba

View File

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

View File

@@ -4,7 +4,7 @@ include MangaDex
describe Queue do describe Queue do
it "creates DB at given path" do it "creates DB at given path" do
with_queue do |queue, path| with_queue do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
@@ -102,4 +102,3 @@ describe Queue do
State.reset State.reset
end end
end end

View File

@@ -28,22 +28,22 @@ def get_tempfile(name)
if path.nil? || !File.exists? path if path.nil? || !File.exists? path
file = File.tempfile name file = File.tempfile name
State.set name, file.path State.set name, file.path
return file file
else else
return File.new path File.new path
end end
end end
def with_default_config def with_default_config
temp_config = get_tempfile "mango-test-config" temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path config = Config.load temp_config.path
logger = MLogger.new config logger = Logger.new config.log_level
yield config, logger, temp_config.path yield config, logger, temp_config.path
temp_config.delete temp_config.delete
end end
def with_storage def with_storage
with_default_config do |config, logger| with_default_config do |_, logger|
temp_db = get_tempfile "mango-test-db" temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, logger storage = Storage.new temp_db.path, logger
clear = yield storage, temp_db.path clear = yield storage, temp_db.path
@@ -54,7 +54,7 @@ def with_storage
end end
def with_queue def with_queue
with_default_config do |config, logger| with_default_config do |_, logger|
temp_queue_db = get_tempfile "mango-test-queue-db" temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path, logger queue = MangaDex::Queue.new temp_queue_db.path, logger
clear = yield queue, temp_queue_db.path clear = yield queue, temp_queue_db.path

View File

@@ -2,7 +2,7 @@ require "./spec_helper"
describe Storage do describe Storage do
it "creates DB at given path" do it "creates DB at given path" do
with_storage do |storage, path| with_storage do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end

View File

@@ -25,4 +25,12 @@ describe "compare_alphanumerically" do
compare_alphanumerically a, b compare_alphanumerically a, b
}.should eq ary }.should eq ary
end end
# https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort { |a, b|
compare_alphanumerically a, b
}.should eq ary
end
end end

View File

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

View File

@@ -4,13 +4,15 @@ class Config
include YAML::Serializable include YAML::Serializable
property port : Int32 = 9000 property port : Int32 = 9000
property library_path : String = \ property library_path : String = File.expand_path "~/mango/library",
File.expand_path "~/mango/library", home: true home: true
property db_path : String = \ property db_path : String = File.expand_path "~/mango/mango.db", home: true
File.expand_path "~/mango/mango.db", home: true
@[YAML::Field(key: "scan_interval_minutes")] @[YAML::Field(key: "scan_interval_minutes")]
property scan_interval : Int32 = 5 property scan_interval : Int32 = 5
property log_level : String = "info" property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
property disable_ellipsis_truncation : Bool = false
property mangadex = Hash(String, String | Int32).new property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@@ -19,8 +21,8 @@ class Config
"api_url" => "https://mangadex.org/api", "api_url" => "https://mangadex.org/api",
"download_wait_seconds" => 5, "download_wait_seconds" => 5,
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path "~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true home: true),
} }
def self.load(path : String?) def self.load(path : String?)

View File

@@ -7,13 +7,13 @@ class Context
property config : Config property config : Config
property library : Library property library : Library
property storage : Storage property storage : Storage
property logger : MLogger property logger : Logger
property queue : MangaDex::Queue property queue : MangaDex::Queue
def initialize(@config, @logger, @library, @storage, @queue) def initialize(@config, @logger, @library, @storage, @queue)
end end
{% for lvl in LEVELS %} {% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@logger.{{lvl.id}} msg @logger.{{lvl.id}} msg
end end

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,29 +15,58 @@ struct Image
end end
class Entry class Entry
JSON.mapping zip_path: String, book_title: String, title: String, property zip_path : String, book : Title, title : String,
size: String, pages: Int32, cover_url: String, id: String, size : String, pages : Int32, id : String, title_id : String,
title_id: String, encoded_path: String, encoded_title: String, encoded_path : String, encoded_title : String, mtime : Time
mtime: Time
def initialize(path, @book_title, @title_id, storage) def initialize(path, @book, @title_id, storage)
@zip_path = path @zip_path = path
@encoded_path = URI.encode path @encoded_path = URI.encode path
@title = File.basename path, File.extname path @title = File.basename path, File.extname path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes @size = (File.size path).humanize_bytes
file = Zip::File.new path file = Zip::File.new path
@pages = file.entries @pages = file.entries.count do |e|
.select { |e|
["image/jpeg", "image/png"].includes? \ ["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename MIME.from_filename? e.filename
} end
.size
file.close file.close
@id = storage.get_id @zip_path, false @id = storage.get_id @zip_path, false
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time @mtime = File.info(@zip_path).modification_time
end end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
end
def display_name
@book.display_name @title
end
def encoded_display_name
URI.encode display_name
end
def cover_url
url = "/api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def read_page(page_num) def read_page(page_num)
Zip::File.open @zip_path do |file| Zip::File.open @zip_path do |file|
page = file.entries page = file.entries
@@ -55,7 +84,7 @@ class Entry
unless bytes_read unless bytes_read
return nil return nil
end end
return Image.new slice, MIME.from_filename(page.filename),\ return Image.new slice, MIME.from_filename(page.filename),
page.filename, bytes_read page.filename, bytes_read
end end
end end
@@ -63,75 +92,225 @@ class Entry
end end
class Title class Title
JSON.mapping dir: String, entries: Array(Entry), title: String, property dir : String, parent_id : String, title_ids : Array(String),
id: String, encoded_title: String, mtime: Time, logger: MLogger entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
def initialize(dir : String, storage, @logger : MLogger) def initialize(@dir : String, @parent_id, storage,
@dir = dir @logger : Logger, @library : Library)
@id = storage.get_id @dir, true @id = storage.get_id @dir, true
@title = File.basename dir @title = File.basename dir
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@entries = (Dir.entries dir) @title_ids = [] of String
.select { |path| [".zip", ".cbz"].includes? File.extname path } @entries = [] of Entry
.map { |path| File.join dir, path } @mtime = File.info(dir).modification_time
.select { |path| valid_zip path }
.map { |path| Dir.entries(dir).each do |fn|
Entry.new path, @title, @id, storage next if fn.starts_with? "."
} path = File.join dir, fn
.select { |e| e.pages > 0 } if File.directory? path
.sort { |a, b| a.title <=> b.title } title = Title.new path, @id, storage, @logger, library
mtimes = [File.info(dir).modification_time] next if title.entries.size == 0 && title.titles.size == 0
mtimes += @entries.map{|e| e.mtime} @library.title_hash[title.id] = title
@mtime = mtimes.max @title_ids << title.id
next
end end
# When downloading from MangaDex, the zip/cbz file would not be valid if [".zip", ".cbz"].includes? File.extname path
# before the download is completed. If we scan the zip file, zip_exception = validate_zip path
# Entry.new would throw, so we use this method to check before unless zip_exception.nil?
# constructing Entry
private def valid_zip(path : String)
begin
file = Zip::File.new path
file.close
return true
rescue
@logger.warn "File #{path} is corrupted or is not a valid zip " \ @logger.warn "File #{path} is corrupted or is not a valid zip " \
"archive. Ignoring it." "archive. Ignoring it."
return false @logger.debug "Zip error: #{zip_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
end end
end end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
mtimes += @entries.map { |e| e.mtime }
@mtime = mtimes.max
@title_ids.sort! do |a, b|
compare_alphanumerically @library.title_hash[a].title,
@library.title_hash[b].title
end
@entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
end
json.field "entries" do
json.raw @entries.to_json
end
json.field "parents" do
json.array do
self.parents.each do |title|
json.object do
json.field "title", title.title
json.field "id", title.id
end
end
end
end
end
end
def titles
@title_ids.map { |tid| @library.get_title! tid }
end
def parents
ary = [] of Title
tid = @parent_id
while !tid.empty?
title = @library.get_title! tid
ary << title
tid = title.parent_id
end
ary
end
def size
@entries.size + @title_ids.size
end
def get_entry(eid) def get_entry(eid)
@entries.find { |e| e.id == eid } @entries.find { |e| e.id == eid }
end end
def display_name
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
URI.encode display_name
end
def display_name(entry_name)
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
dn
end
def set_display_name(dn)
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
save_progress username, e.title, e.pages
end
titles.each do |t|
t.read_all username
end
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
save_progress username, e.title, 0
end
titles.each do |t|
t.unread_all username
end
end
# For backward backward compatibility with v0.1.0, we save entry titles # For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json # instead of IDs in info.json
def save_progress(username, entry, page) def save_progress(username, entry, page)
info = TitleInfo.new @dir TitleInfo.new @dir do |info|
if info.progress[username]?.nil? if info.progress[username]?.nil?
info.progress[username] = {entry => page} info.progress[username] = {entry => page}
info.save @dir else
return
end
info.progress[username][entry] = page info.progress[username][entry] = page
info.save @dir
end end
info.save
end
end
def load_progress(username, entry) def load_progress(username, entry)
info = TitleInfo.new @dir progress = 0
if info.progress[username]?.nil? TitleInfo.new @dir do |info|
return 0 unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end end
if info.progress[username][entry]?.nil?
return 0
end end
info.progress[username][entry] progress
end end
def load_percetage(username, entry) def load_percetage(username, entry)
info = TitleInfo.new @dir
page = load_progress username, entry page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry } entry_obj = @entries.find { |e| e.title == entry }
return 0 if entry_obj.nil? return 0.0 if entry_obj.nil?
page / entry_obj.pages page / entry_obj.pages
end end
def load_percetage(username) def load_percetage(username)
return 0.0 if @entries.empty?
read_pages = total_pages = 0 read_pages = total_pages = 0
@entries.each do |e| @entries.each do |e|
read_pages += load_progress username, e.title read_pages += load_progress username, e.title
@@ -139,6 +318,7 @@ class Title
end end
read_pages / total_pages read_pages / total_pages
end end
def next_entry(current_entry_obj) def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1 return nil if idx.nil? || idx == @entries.size - 1
@@ -147,41 +327,53 @@ class Title
end end
class TitleInfo class TitleInfo
# { user1: { entry1: 10, entry2: 0 } }
include JSON::Serializable include JSON::Serializable
@[JSON::Field(key: "comment")]
property comment = "Generated by Mango. DO NOT EDIT!" property comment = "Generated by Mango. DO NOT EDIT!"
property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
@[JSON::Field(key: "progress")] @[JSON::Field(ignore: true)]
property progress : Hash(String, Hash(String, Int32)) property dir : String = ""
def initialize(title_dir) @@mutex_hash = {} of String => Mutex
info = nil
json_path = File.join title_dir, "info.json" def self.new(dir, &)
if File.exists? json_path if @@mutex_hash[dir]?
info = TitleInfo.from_json File.read json_path mutex = @@mutex_hash[dir]
else else
info = TitleInfo.from_json "{\"progress\": {}}" mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end end
@progress = info.progress.clone def save
end json_path = File.join @dir, "info.json"
def save(title_dir)
json_path = File.join title_dir, "info.json"
File.write json_path, self.to_pretty_json File.write json_path, self.to_pretty_json
end end
end end
class Library class Library
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32, property dir : String, title_ids : Array(String), scan_interval : Int32,
logger: MLogger, storage: Storage logger : Logger, storage : Storage, title_hash : Hash(String, Title)
def initialize(@dir, @scan_interval, @logger, @storage) def initialize(@dir, @scan_interval, @logger, @storage)
# 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
@titles = [] of Title @title_ids = [] of String
@title_hash = {} of String => Title
return scan if @scan_interval < 1 return scan if @scan_interval < 1
spawn do spawn do
@@ -189,25 +381,51 @@ class Library
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@titles.size} titles in #{ms}ms" @logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60 sleep @scan_interval * 60
end end
end end
end end
def get_title(tid)
@titles.find { |t| t.id == tid } def titles
@title_ids.map { |tid| self.get_title!(tid) }
end end
def to_json(json : JSON::Builder)
json.object do
json.field "dir", @dir
json.field "titles" do
json.raw self.titles.to_json
end
end
end
def get_title(tid)
@title_hash[tid]?
end
def get_title!(tid)
@title_hash[tid]
end
def scan def scan
unless Dir.exists? @dir unless Dir.exists? @dir
@logger.info "The library directory #{@dir} does not exist. " \ @logger.info "The library directory #{@dir} does not exist. " \
"Attempting to create it" "Attempting to create it"
Dir.mkdir_p @dir Dir.mkdir_p @dir
end end
@titles = (Dir.entries @dir) @title_ids.clear
.select { |path| File.directory? File.join @dir, path } (Dir.entries @dir)
.map { |path| Title.new File.join(@dir, path), @storage, @logger } .select { |fn| !fn.starts_with? "." }
.select { |title| !title.entries.empty? } .map { |fn| File.join @dir, fn }
.select { |path| File.directory? path }
.map { |path| Title.new path, "", @storage, @logger, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title } .sort { |a, b| a.title <=> b.title }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
@logger.debug "Scan completed" @logger.debug "Scan completed"
end end
end end

View File

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

View File

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

View File

@@ -25,8 +25,8 @@ module MangaDex
property pages = [] of {String, String} # filename, url property pages = [] of {String, String} # filename, url
property groups = [] of {Int32, String} # group_id, group_name property groups = [] of {Int32, String} # group_id, group_name
def initialize(@id, json_obj : JSON::Any, @manga, lang : def initialize(@id, json_obj : JSON::Any, @manga,
Hash(String, String)) lang : Hash(String, String))
self.parse_json json_obj, lang self.parse_json json_obj, lang
end end
@@ -52,7 +52,6 @@ module MangaDex
end end
def parse_json(obj, lang) def parse_json(obj, lang)
begin
parse_strings_from_json ["lang_code", "title", "volume", parse_strings_from_json ["lang_code", "title", "volume",
"chapter"] "chapter"]
language = lang[@lang_code]? language = lang[@lang_code]?
@@ -76,10 +75,9 @@ module MangaDex
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end end
end end
end
class Manga class Manga
string_properties ["cover_url", "description", "title", "author", string_properties ["cover_url", "description", "title", "author", "artist"]
"artist"]
property chapters = [] of Chapter property chapters = [] of Chapter
property id : String property id : String
@@ -90,8 +88,8 @@ module MangaDex
def to_info_json(with_chapters = true) def to_info_json(with_chapters = true)
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for name in ["id", "title", "description", {% for name in ["id", "title", "description", "author", "artist",
"author", "artist", "cover_url"] %} "cover_url"] %}
json.field {{name}}, @{{name.id}} json.field {{name}}, @{{name.id}}
{% end %} {% end %}
if with_chapters if with_chapters
@@ -108,14 +106,13 @@ module MangaDex
end end
def parse_json(obj) def parse_json(obj)
begin parse_strings_from_json ["cover_url", "description", "title", "author",
parse_strings_from_json ["cover_url", "description", "title", "artist"]
"author", "artist"]
rescue e rescue e
raise "failed to parse json: #{e}" raise "failed to parse json: #{e}"
end end
end end
end
class API class API
def initialize(@base_url = "https://mangadex.org/api/") def initialize(@base_url = "https://mangadex.org/api/")
@lang = {} of String => String @lang = {} of String => String
@@ -126,7 +123,7 @@ module MangaDex
def get(url) def get(url)
headers = HTTP::Headers{ headers = HTTP::Headers{
"User-agent" => "Mangadex.cr" "User-agent" => "Mangadex.cr",
} }
res = HTTP::Client.get url, headers res = HTTP::Client.get url, headers
raise "Failed to get #{url}. [#{res.status_code}] " \ raise "Failed to get #{url}. [#{res.status_code}] " \
@@ -137,8 +134,7 @@ module MangaDex
def get_manga(id) def get_manga(id)
obj = self.get File.join @base_url, "manga/#{id}" obj = self.get File.join @base_url, "manga/#{id}"
if obj["status"]? != "OK" if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. " \ raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
"Got `#{obj["status"]?}`"
end end
begin begin
manga = Manga.new id, obj["manga"] manga = Manga.new id, obj["manga"]
@@ -146,7 +142,7 @@ module MangaDex
chapter = Chapter.new k, v, manga, @lang chapter = Chapter.new k, v, manga, @lang
manga.chapters << chapter manga.chapters << chapter
end end
return manga manga
rescue rescue
raise "Failed to parse JSON" raise "Failed to parse JSON"
end end
@@ -160,8 +156,7 @@ module MangaDex
"external chapters." "external chapters."
end end
if obj["status"]? != "OK" if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. " \ raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
"Got `#{obj["status"]?}`"
end end
begin begin
server = obj["server"].as_s server = obj["server"].as_s
@@ -169,7 +164,7 @@ module MangaDex
chapter.pages = obj["page_array"].as_a.map do |fn| chapter.pages = obj["page_array"].as_a.map do |fn|
{ {
fn.as_s, fn.as_s,
"#{server}#{hash}/#{fn.as_s}" "#{server}#{hash}/#{fn.as_s}",
} }
end end
rescue rescue
@@ -185,8 +180,7 @@ module MangaDex
"external chapters." "external chapters."
end end
if obj["status"]? != "OK" if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. " \ raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
"Got `#{obj["status"]?}`"
end end
manga_id = "" manga_id = ""
begin begin
@@ -197,7 +191,7 @@ module MangaDex
manga = self.get_manga manga_id manga = self.get_manga manga_id
chapter = manga.chapters.find { |c| c.id == id }.not_nil! chapter = manga.chapters.find { |c| c.id == id }.not_nil!
self.get_chapter chapter self.get_chapter chapter
return chapter chapter
end end
end end
end end

View File

@@ -8,6 +8,7 @@ module MangaDex
property filename : String property filename : String
property writer : Zip::Writer property writer : Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning) def initialize(@url, @filename, @writer, @tries_remaning)
end end
end end
@@ -51,7 +52,7 @@ module MangaDex
def self.from_query_result(res : DB::ResultSet) def self.from_query_result(res : DB::ResultSet)
job = Job.allocate job = Job.allocate
job.parse_query_result res job.parse_query_result res
return job job
end end
def initialize(@id, @manga_id, @title, @manga_title, @status, @time) def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
@@ -79,7 +80,7 @@ module MangaDex
class Queue class Queue
property downloader : Downloader? property downloader : Downloader?
def initialize(@path : String, @logger : MLogger) def initialize(@path : String, @logger : Logger)
dir = File.dirname path dir = File.dirname path
unless Dir.exists? dir unless Dir.exists? dir
@logger.info "The queue DB directory #{dir} does not exist. " \ @logger.info "The queue DB directory #{dir} does not exist. " \
@@ -119,7 +120,7 @@ module MangaDex
rescue rescue
end end
end end
return job job
end end
# Push an array of jobs into the queue, and return the number of jobs # Push an array of jobs into the queue, and return the number of jobs
@@ -176,16 +177,20 @@ module MangaDex
end end
def count_status(status : JobStatus) def count_status(status : JobStatus)
num = 0
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
return db.query_one "select count(*) from queue where "\ num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32 "status = (?)", status.to_i, as: Int32
end end
num
end end
def count def count
num = 0
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
return db.query_one "select count(*) from queue", as: Int32 num = db.query_one "select count(*) from queue", as: Int32
end end
num
end end
def set_status(status : JobStatus, job : Job) def set_status(status : JobStatus, job : Job)
@@ -198,11 +203,11 @@ module MangaDex
def get_all def get_all
jobs = [] of Job jobs = [] of Job
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
jobs = db.query_all "select * from queue order by time", do |rs| jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs Job.from_query_result rs
end end
end end
return jobs jobs
end end
def add_success(job : Job) def add_success(job : Job)
@@ -249,16 +254,17 @@ module MangaDex
class Downloader class Downloader
property stopped = false property stopped = false
@downloading = false
def initialize(@queue : Queue, @api : API, @library_path : String, def initialize(@queue : Queue, @api : API, @library_path : String,
@wait_seconds : Int32, @retries : Int32, @wait_seconds : Int32, @retries : Int32,
@logger : MLogger) @logger : Logger)
@queue.downloader = self @queue.downloader = self
spawn do spawn do
loop do loop do
sleep 1.second sleep 1.second
next if @stopped next if @stopped || @downloading
begin begin
job = @queue.pop job = @queue.pop
next if job.nil? next if job.nil?
@@ -271,7 +277,7 @@ module MangaDex
end end
private def download(job : Job) private def download(job : Job)
@stopped = true @downloading = true
@queue.set_status JobStatus::Downloading, job @queue.set_status JobStatus::Downloading, job
begin begin
chapter = @api.get_chapter(job.id) chapter = @api.get_chapter(job.id)
@@ -281,7 +287,7 @@ module MangaDex
unless e.message.nil? unless e.message.nil?
@queue.add_message e.message.not_nil!, job @queue.add_message e.message.not_nil!, job
end end
@stopped = false @downloading = false
return return
end end
@queue.set_pages chapter.pages.size, job @queue.set_pages chapter.pages.size, job
@@ -336,24 +342,30 @@ module MangaDex
@logger.error msg @logger.error msg
end end
end end
fail_count = page_jobs.select{|j| !j.success}.size 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"
writer.close writer.close
@logger.debug "cbz File created at #{zip_path}" @logger.debug "cbz File created at #{zip_path}"
if fail_count == 0
@queue.set_status JobStatus::Completed, job zip_exception = validate_zip zip_path
else if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status JobStatus::Error, job
elsif fail_count > 0
@queue.set_status JobStatus::MissingPages, job @queue.set_status JobStatus::MissingPages, job
else
@queue.set_status JobStatus::Completed, job
end end
@stopped = false @downloading = false
end end
end end
private def download_page(job : PageJob) private def download_page(job : PageJob)
@logger.debug "downloading #{job.url}" @logger.debug "downloading #{job.url}"
headers = HTTP::Headers{ headers = HTTP::Headers{
"User-agent" => "Mangadex.cr" "User-agent" => "Mangadex.cr",
} }
begin begin
HTTP::Client.get job.url, headers do |res| HTTP::Client.get job.url, headers do |res|

View File

@@ -3,11 +3,11 @@ require "./context"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
VERSION = "0.2.0" VERSION = "0.3.0"
config_path = nil config_path = nil
parser = OptionParser.parse do |parser| OptionParser.parse do |parser|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n" parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
parser.on "-v", "--version", "Show version" do parser.on "-v", "--version", "Show version" do
@@ -18,20 +18,20 @@ parser = OptionParser.parse do |parser|
puts parser puts parser
exit exit
end end
parser.on "-c PATH", "--config=PATH", "Path to the config file. " \ parser.on "-c PATH", "--config=PATH",
"Default is `~/.config/mango/config.yml`" do |path| "Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
config_path = path config_path = path
end end
end end
config = Config.load config_path config = Config.load config_path
logger = MLogger.new config logger = Logger.new config.log_level
storage = Storage.new config.db_path, logger storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage library = Library.new config.library_path, config.scan_interval, logger, storage
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s, queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
logger logger
api = MangaDex::API.new config.mangadex["api_url"].to_s api = MangaDex::API.new config.mangadex["api_url"].to_s
downloader = MangaDex::Downloader.new queue, api, config.library_path, MangaDex::Downloader.new queue, api, config.library_path,
config.mangadex["download_wait_seconds"].to_i, config.mangadex["download_wait_seconds"].to_i,
config.mangadex["download_retries"].to_i, logger config.mangadex["download_retries"].to_i, logger

View File

@@ -26,7 +26,6 @@ class AdminRouter < Router
post "/admin/user/edit" do |env| post "/admin/user/edit" do |env|
# creating new user # creating new user
begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
# if `admin` is unchecked, the body hash # if `admin` is unchecked, the body hash
@@ -53,19 +52,16 @@ class AdminRouter < Router
rescue e rescue e
@context.error e @context.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})
env.redirect redirect_url.to_s env.redirect redirect_url.to_s
end end
end
post "/admin/user/edit/:original_username" do |env| post "/admin/user/edit/:original_username" do |env|
# editing existing user # editing existing user
begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
# if `admin` is unchecked, the body # if `admin` is unchecked, the body hash would not contain `admin`
# hash would not contain `admin`
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"]
@@ -93,15 +89,14 @@ class AdminRouter < Router
rescue e rescue e
@context.error e @context.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, \
"admin" => admin, "error" => e.message}) "admin" => admin, "error" => e.message})
env.redirect redirect_url.to_s env.redirect redirect_url.to_s
end end
end
get "/admin/downloads" do |env| get "/admin/downloads" do |env|
base_url = @context.config.mangadex["base_url"]; base_url = @context.config.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
end end

View File

@@ -1,5 +1,6 @@
require "./router" require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload"
class APIRouter < Router class APIRouter < Router
def setup def setup
@@ -12,8 +13,7 @@ class APIRouter < Router
title = @context.library.get_title tid title = @context.library.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 \ raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
entry.nil?
img = entry.read_page page img = entry.read_page page
raise "Failed to load page #{page} of " \ raise "Failed to load page #{page} of " \
"`#{title.title}/#{entry.title}`" if img.nil? "`#{title.title}/#{entry.title}`" if img.nil?
@@ -26,7 +26,7 @@ class APIRouter < Router
end end
end end
get "/api/book/:title" 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 = @context.library.get_title tid
@@ -50,7 +50,7 @@ class APIRouter < Router
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" => @context.library.titles.size,
}.to_json }.to_json
end end
@@ -62,28 +62,58 @@ class APIRouter < Router
@context.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message "error" => e.message,
}.to_json }.to_json
else else
send_json env, {"success" => true}.to_json send_json env, {"success" => true}.to_json
end end
end end
post "/api/progress/:title/:entry/:page" do |env| post "/api/progress/:title/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"])
.not_nil! .not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
entry_id = env.params.query["entry"]?
if !entry_id.nil?
entry = title.get_entry(entry_id).not_nil!
raise "incorrect page value" if page < 0 || page > entry.pages raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page title.save_progress username, entry.title, page
elsif page == 0
title.unread_all username
else
title.read_all username
end
rescue e rescue e
@context.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message "error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/admin/display_name/:title/:name" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
name = env.params.url["name"]
entry = env.params.query["entry"]?
if entry.nil?
title.set_display_name name
else
eobj = title.get_entry entry
title.set_display_name eobj.not_nil!.title, name
end
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json }.to_json
else else
send_json env, {"success" => true}.to_json send_json env, {"success" => true}.to_json
@@ -93,8 +123,7 @@ class APIRouter < Router
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"]
api = MangaDex::API.new \ api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
@context.config.mangadex["api_url"].to_s
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
@@ -119,7 +148,7 @@ class APIRouter < Router
inserted_count = @context.queue.push jobs inserted_count = @context.queue.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 @context.error e
@@ -133,12 +162,12 @@ class APIRouter < Router
send_json env, { send_json env, {
"jobs" => jobs, "jobs" => jobs,
"paused" => @context.queue.paused?, "paused" => @context.queue.paused?,
"success" => true "success" => true,
}.to_json }.to_json
rescue e rescue e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message "error" => e.message,
}.to_json }.to_json
end end
end end
@@ -172,7 +201,61 @@ class APIRouter < Router
rescue e rescue e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message "error" => e.message,
}.to_json
end
end
post "/api/admin/upload/:target" do |env|
begin
target = env.params.url["target"]
HTTP::FormData.parse env.request do |part|
next if part.name != "file"
filename = part.filename
if filename.nil?
raise "No file uploaded"
end
case target
when "cover"
title_id = env.params.query["title"]
entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil!
unless ["image/jpeg", "image/png"].includes? \
MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG"
end
ext = File.extname filename
upload = Upload.new @context.config.upload_path, @context.logger
url = upload.path_to_url upload.save "img", ext, part.body
if url.nil?
raise "Failed to generate a public URL for the uploaded file"
end
if entry_id.nil?
title.set_cover_url url
else
entry_name = title.get_entry(entry_id).not_nil!.title
title.set_cover_url entry_name, url
end
else
raise "Unkown upload target #{target}"
end
send_json env, {"success" => true}.to_json
env.response.close
end
raise "No part with name `file` found"
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json }.to_json
end end
end end

View File

@@ -8,8 +8,7 @@ class MainRouter < Router
get "/logout" do |env| get "/logout" do |env|
begin begin
cookie = env.request.cookies cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value @context.storage.logout cookie.value
rescue e rescue e
@context.error "Error when attempting to log out: #{e}" @context.error "Error when attempting to log out: #{e}"
@@ -22,30 +21,38 @@ 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) token = @context.storage.verify_user(username, password).not_nil!
.not_nil!
cookie = HTTP::Cookie.new "token", token cookie = HTTP::Cookie.new "token", token
cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie env.response.cookies << cookie
env.redirect "/" env.redirect "/"
rescue rescue
env.redirect "/login" env.redirect "/login"
end end
end end
get "/" do |env| get "/" do |env|
begin
titles = @context.library.titles titles = @context.library.titles
username = get_username env username = get_username env
percentage = titles.map &.load_percetage username percentage = titles.map &.load_percetage username
use_dotdotdot = !@context.config.disable_ellipsis_truncation
layout "index" layout "index"
rescue e
@context.error e
env.response.status_code = 500
end
end end
get "/book/:title" do |env| get "/book/:title" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.not_nil!
username = get_username env username = get_username env
percentage = title.entries.map { |e| percentage = title.entries.map { |e|
title.load_percetage username, e.title } title.load_percetage username, e.title
}
use_dotdotdot = !@context.config.disable_ellipsis_truncation
layout "title" layout "title"
rescue e rescue e
@context.error e @context.error e
@@ -54,7 +61,7 @@ class MainRouter < Router
end end
get "/download" do |env| get "/download" do |env|
base_url = @context.config.mangadex["base_url"]; base_url = @context.config.mangadex["base_url"]
layout "download" layout "download"
end end
end end

View File

@@ -4,8 +4,7 @@ class ReaderRouter < Router
def setup def setup
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
# load progress # load progress
@@ -25,8 +24,7 @@ class ReaderRouter < Router
get "/reader/:title/:entry/:page" do |env| get "/reader/:title/:entry/:page" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.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 = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0 raise "" if page > entry.pages || page <= 0
@@ -37,16 +35,21 @@ class ReaderRouter < Router
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx| urls = pages.map { |idx|
"/api/page/#{title.id}/#{entry.id}/#{idx}" } "/api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx| reader_urls = pages.map { |idx|
"/reader/#{title.id}/#{entry.id}/#{idx}" } "/reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE next_page = page + IMGS_PER_PAGE
next_url = next_page > entry.pages ? nil : next_url = next_entry_url = nil
"/reader/#{title.id}/#{entry.id}/#{next_page}"
exit_url = "/book/#{title.id}" exit_url = "/book/#{title.id}"
next_entry = title.next_entry entry next_entry = title.next_entry entry
next_entry_url = next_entry.nil? ? nil : \ unless next_page > entry.pages
"/reader/#{title.id}/#{next_entry.id}" next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil?
next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
end
render "src/views/reader.ecr" render "src/views/reader.ecr"
rescue e rescue e

View File

@@ -1,17 +1,13 @@
require "kemal" require "kemal"
require "./context" require "./context"
require "./auth_handler" require "./handlers/*"
require "./static_handler"
require "./log_handler"
require "./util" require "./util"
require "./routes/*" require "./routes/*"
class Server class Server
def initialize(@context : Context) def initialize(@context : Context)
error 403 do |env| error 403 do |env|
message = "HTTP 403: You are not authorized to visit " \ message = "HTTP 403: You are not authorized to visit #{env.request.path}"
"#{env.request.path}"
layout "message" layout "message"
end end
error 404 do |env| error 404 do |env|
@@ -31,6 +27,7 @@ class Server
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new @context.logger add_handler LogHandler.new @context.logger
add_handler AuthHandler.new @context.storage add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new @context.config.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." @context.debug "We are in release mode. Using embedded static files."

View File

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

View File

@@ -2,6 +2,7 @@ require "sqlite3"
require "crypto/bcrypt" require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
@@ -11,12 +12,8 @@ def verify_password(hash, pw)
(Crypto::Bcrypt::Password.new hash).verify pw (Crypto::Bcrypt::Password.new hash).verify pw
end end
def random_str
UUID.random.to_s.gsub "-", ""
end
class Storage class Storage
def initialize(@path : String, @logger : MLogger) def initialize(@path : String, @logger : Logger)
dir = File.dirname path dir = File.dirname path
unless Dir.exists? dir unless Dir.exists? dir
@logger.info "The DB directory #{dir} does not exist. " \ @logger.info "The DB directory #{dir} does not exist. " \
@@ -48,7 +45,7 @@ class Storage
hash = hash_password random_pw hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
"admin", hash, nil, 1 "admin", hash, nil, 1
puts "Initial user created. You can log in with " \ @logger.log "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}" "#{{"username" => "admin", "password" => random_pw}}"
end end
end end
@@ -58,7 +55,7 @@ class Storage
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
hash, token = db.query_one "select password, token from " \ hash, token = db.query_one "select password, token from " \
"users where username = (?)", \ "users where username = (?)",
username, as: {String, String?} username, as: {String, String?}
unless verify_password hash, password unless verify_password hash, password
@logger.debug "Password does not match the hash" @logger.debug "Password does not match the hash"
@@ -79,28 +76,29 @@ class Storage
end end
def verify_token(token) def verify_token(token)
username = nil
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
username = db.query_one "select username from users where " \ username = db.query_one "select username from users where " \
"token = (?)", token, as: String "token = (?)", token, as: String
return username
rescue e rescue e
@logger.debug "Unable to verify token" @logger.debug "Unable to verify token"
return nil
end end
end end
username
end end
def verify_admin(token) def verify_admin(token)
is_admin = false
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
return db.query_one "select admin from users where " \ is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool "token = (?)", token, as: Bool
rescue e rescue e
@logger.debug "Unable to verify user as admin" @logger.debug "Unable to verify user as admin"
return false
end end
end end
is_admin
end end
def list_users def list_users
@@ -129,12 +127,12 @@ class Storage
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
if password.size == 0 if password.size == 0
db.exec "update users set username = (?), admin = (?) " \ db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",\ "where username = (?)",
username, admin, original_username username, admin, original_username
else else
hash = hash_password password hash = hash_password password
db.exec "update users set username = (?), admin = (?)," \ db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",\ "password = (?) where username = (?)",
username, admin, hash, original_username username, admin, hash, original_username
end end
end end
@@ -149,26 +147,23 @@ class Storage
def logout(token) def logout(token)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
db.exec "update users set token = (?) where token = (?)", \ db.exec "update users set token = (?) where token = (?)", nil, token
nil, token
rescue rescue
end end
end end
end end
def get_id(path, is_title) def get_id(path, is_title)
id = random_str
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
id = db.query_one "select id from ids where path = (?)", id = db.query_one "select id from ids where path = (?)", path,
path, as: {String} as: {String}
return id
rescue rescue
id = random_str db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
db.exec "insert into ids values (?, ?, ?)", path, id,
is_title ? 1 : 0
return id
end end
end end
id
end end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)

60
src/upload.cr Normal file
View File

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

View File

@@ -1,7 +1,21 @@
require "big"
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
macro layout(name) macro layout(name)
begin
cookie = env.request.cookies.find { |c| c.name == "token" }
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
end
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr" render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.ecr", "src/views/layout.ecr"
end
end end
macro send_img(env, img) macro send_img(env, img)
@@ -15,9 +29,9 @@ macro get_username(env)
(@context.storage.verify_token cookie.value).not_nil! (@context.storage.verify_token cookie.value).not_nil!
end end
macro send_json(env, json) def send_json(env, json)
{{env}}.response.content_type = "application/json" env.response.content_type = "application/json"
{{json}} env.response.print json
end end
def hash_to_query(hash) def hash_to_query(hash)
@@ -30,7 +44,7 @@ def request_path_startswith(env, ary)
return true return true
end end
end end
return false false
end end
def is_numeric(str) def is_numeric(str)
@@ -56,7 +70,7 @@ def compare_alphanumerically(c, d)
return -1 if a.nil? return -1 if a.nil?
return 1 if b.nil? return 1 if b.nil?
if is_numeric(a) && is_numeric(b) if is_numeric(a) && is_numeric(b)
compare = a.to_i <=> b.to_i compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0 return compare if compare != 0
else else
compare = a <=> b compare = a <=> b
@@ -69,3 +83,19 @@ end
def compare_alphanumerically(a : String, b : String) def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b) compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end end
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
def validate_zip(path : String) : Exception?
file = Zip::File.new path
file.close
return
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end

View File

@@ -26,12 +26,14 @@
<a class="acard" href="/book/<%= t.id %>"> <a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img src="<%= t.entries[0].cover_url %>" alt=""> <img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div> </div>
<div class="uk-card-body"> <div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div> <div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title"><%= t.title %></h3> <%- end -%>
<p><%= t.entries.size %> entries</p> <h3 class="uk-card-title break-word" uk-tooltip="<%= t.display_name.gsub("\"", "&quot;") %>" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div> </div>
</div> </div>
</a> </a>
@@ -40,6 +42,10 @@
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<% if use_dotdotdot %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script>
<% end %>
<script src="/js/search.js"></script> <script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script> <script src="/js/sort-items.js"></script>
<% end %> <% end %>

View File

@@ -10,18 +10,21 @@
<link rel="stylesheet" href="/css/mango.css" /> <link rel="stylesheet" href="/css/mango.css" />
<script defer src="/js/fontawesome.min.js"></script> <script defer src="/js/fontawesome.min.js"></script>
<script defer src="/js/solid.min.js"></script> <script defer src="/js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head> </head>
<body> <body>
<script src="/js/theme.js"></script>
<div class="uk-offcanvas-content"> <div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true"> <div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical"> <ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li> <li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li> <li><a href="/download">Download</a></li>
<% end %>
<hr uk-divider> <hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li> <li><a href="/logout">Logout</a></li>
@@ -39,8 +42,10 @@
<a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a> <a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
<ul class="uk-navbar-nav"> <ul class="uk-navbar-nav">
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<% if is_admin %>
<li><a href="/admin">Admin</a></li> <li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li> <li><a href="/download">Download</a></li>
<% end %>
</ul> </ul>
</div> </div>
<div class="uk-navbar-right uk-visible@s"> <div class="uk-navbar-right uk-visible@s">
@@ -59,7 +64,9 @@
<%= content %> <%= content %>
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script>
setTheme(getTheme());
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>

View File

@@ -8,9 +8,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head> </head>
<body> <body>
<script src="/js/theme.js"></script>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport=""> <div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1"> <div class="uk-width-1-1">
<div class="uk-container"> <div class="uk-container">
@@ -33,6 +34,9 @@
</div> </div>
</div> </div>
</div> </div>
<script>
setTheme(getTheme());
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body> </body>

View File

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