Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Ling
3b19883dde Use auto overflow tables
cherry-picked from a612500b0f
2021-06-07 07:35:44 +00:00
Alex Ling
6844860065 Revert "Subscription manager"
This reverts commit a612500b0f.
2021-06-07 07:32:32 +00:00
Alex Ling
9eb699ea3b Add plugin subscription types 2021-06-07 07:04:49 +00:00
Alex Ling
59bcb4db3b WIP 2021-06-05 10:53:45 +00:00
Alex Ling
87c479bf42 WIP 2021-05-30 10:54:27 +00:00
Alex Ling
e0713ccde8 WIP 2021-05-22 07:24:30 +00:00
Alex Ling
a571d21cba WIP 2021-05-15 13:37:11 +00:00
Alex Ling
23541f457e Add "title_title" to slim JSON 2021-05-15 06:54:12 +00:00
89 changed files with 3015 additions and 4679 deletions

View File

@@ -104,33 +104,6 @@
"contributions": [ "contributions": [
"infra" "infra"
] ]
},
{
"login": "lincolnthedev",
"name": "i use arch btw",
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
"profile": "https://lncn.dev",
"contributions": [
"infra"
]
},
{
"login": "BradleyDS2",
"name": "BradleyDS2",
"avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4",
"profile": "https://github.com/BradleyDS2",
"contributions": [
"doc"
]
},
{
"login": "nduja",
"name": "Robbo",
"avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4",
"profile": "https://github.com/nduja",
"contributions": [
"code"
]
} }
], ],
"contributorsPerLine": 7, "contributorsPerLine": 7,

View File

@@ -12,4 +12,3 @@ Layout/LineLength:
MaxLength: 80 MaxLength: 80
Excluded: Excluded:
- src/routes/api.cr - src/routes/api.cr
- spec/plugin_spec.cr

View File

@@ -1,9 +1,2 @@
node_modules node_modules
lib lib
Dockerfile
Dockerfile.arm32v7
Dockerfile.arm64v8
README.md
.all-contributorsrc
env.example
.github/

View File

@@ -1,11 +0,0 @@
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: { requireConfigFile: false },
plugins: ['prettier'],
rules: {
eqeqeq: ['error', 'always'],
'object-shorthand': ['error', 'always'],
'prettier/prettier': 'error',
'no-var': 'error',
},
};

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:1.0.0-alpine image: crystallang/crystal:0.36.1-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}

View File

@@ -1,9 +1,9 @@
FROM crystallang/crystal:1.0.0-alpine AS builder FROM crystallang/crystal:0.36.1-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN make static || make static RUN make static || make static
FROM library/alpine FROM library/alpine

View File

@@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm32v7.o . COPY mango-arm32v7.o .

View File

@@ -2,10 +2,10 @@ FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm64v8.o . COPY mango-arm64v8.o .

View File

@@ -29,7 +29,6 @@ test:
check: check:
crystal tool format --check crystal tool format --check
./bin/ameba ./bin/ameba
yarn lint
arm32v7: arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7 crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7

View File

@@ -2,7 +2,7 @@
# Mango # Mango
[![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) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q) [![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
@@ -13,7 +13,8 @@ Mango is a self-hosted manga server and reader. Its features include
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Thumbnail generation - Thumbnail generation
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from third-party sites - Built-in [MangaDex](https://mangadex.org/) downloader
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app - The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless - All the static files are embedded in the binary, so the deployment process is easy and painless
@@ -51,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.27.0 Mango - Manga Server and Web Reader. Version 0.22.0
Usage: Usage:
@@ -80,27 +81,30 @@ base_url: /
session_secret: mango-session-secret session_secret: mango-session-secret
library_path: ~/mango/library library_path: ~/mango/library
db_path: ~/mango/mango.db db_path: ~/mango/mango.db
queue_db_path: ~/mango/queue.db
scan_interval_minutes: 5 scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24 thumbnail_generation_interval_hours: 24
log_level: info log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins plugin_path: ~/mango/plugins
download_timeout_seconds: 30 download_timeout_seconds: 30
library_cache_path: ~/mango/library.yml.gz page_margin: 30
cache_enabled: true
cache_size_mbs: 50
cache_log_enabled: true
disable_login: false disable_login: false
default_username: "" default_username: ""
auth_proxy_header_name: "" auth_proxy_header_name: ""
plugin_update_interval_hours: 24 mangadex:
base_url: https://mangadex.org
api_url: https://api.mangadex.org/v2
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: ~/mango/queue.db
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
manga_rename_rule: '{title}'
subscription_update_interval_hours: 24
``` ```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks - `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work. - You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
### Library Structure ### Library Structure
@@ -172,9 +176,6 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td> <td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td> <td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td> <td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/BradleyDS2"><img src="https://avatars.githubusercontent.com/u/2174921?v=4?s=100" width="100px;" alt=""/><br /><sub><b>BradleyDS2</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=BradleyDS2" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/nduja"><img src="https://avatars.githubusercontent.com/u/69299134?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robbo</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=nduja" title="Code">💻</a></td>
</tr> </tr>
</table> </table>

View File

@@ -5,17 +5,13 @@ const minifyCss = require('gulp-minify-css');
const less = require('gulp-less'); const less = require('gulp-less');
gulp.task('copy-img', () => { gulp.task('copy-img', () => {
return gulp return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.src('node_modules/uikit/src/images/backgrounds/*.svg') .pipe(gulp.dest('public/img'));
.pipe(gulp.dest('public/img'));
}); });
gulp.task('copy-font', () => { gulp.task('copy-font', () => {
return gulp return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.src( .pipe(gulp.dest('public/webfonts'));
'node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**',
)
.pipe(gulp.dest('public/webfonts'));
}); });
// Copy files from node_modules // Copy files from node_modules
@@ -23,60 +19,49 @@ gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less // Compile less
gulp.task('less', () => { gulp.task('less', () => {
return gulp return gulp.src([
.src(['public/css/mango.less', 'public/css/tags.less']) 'public/css/mango.less',
.pipe(less()) 'public/css/tags.less'
.pipe(gulp.dest('public/css')); ])
.pipe(less())
.pipe(gulp.dest('public/css'));
}); });
// Transpile and minify JS files and output to dist // Transpile and minify JS files and output to dist
gulp.task('babel', () => { gulp.task('babel', () => {
return gulp return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
.src(['public/js/*.js', '!public/js/*.min.js']) .pipe(babel({
.pipe( presets: [
babel({ ['@babel/preset-env', {
presets: [ targets: '>0.25%, not dead, ios>=9'
[ }]
'@babel/preset-env', ],
{ }))
targets: '>0.25%, not dead, ios>=9', .pipe(minify({
}, removeConsole: true,
], builtIns: false
], }))
}), .pipe(gulp.dest('dist/js'));
)
.pipe(
minify({
removeConsole: true,
builtIns: false,
}),
)
.pipe(gulp.dest('dist/js'));
}); });
// Minify CSS and output to dist // Minify CSS and output to dist
gulp.task('minify-css', () => { gulp.task('minify-css', () => {
return gulp return gulp.src('public/css/*.css')
.src('public/css/*.css') .pipe(minifyCss())
.pipe(minifyCss()) .pipe(gulp.dest('dist/css'));
.pipe(gulp.dest('dist/css'));
}); });
// Copy static files (includeing images) to dist // Copy static files (includeing images) to dist
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp return gulp.src([
.src( 'public/*.*',
[ 'public/img/*',
'public/*.*', 'public/webfonts/*',
'public/img/**', 'public/js/*.min.js'
'public/webfonts/*', ], {
'public/js/*.min.js', base: 'public'
], })
{ .pipe(gulp.dest('dist'));
base: 'public',
},
)
.pipe(gulp.dest('dist'));
}); });
// Set up the public folder for development // Set up the public folder for development

View File

@@ -1,94 +0,0 @@
class SortTitle < MG::Base
def up : String
<<-SQL
-- add sort_title column to ids and titles
ALTER TABLE ids ADD COLUMN sort_title TEXT;
ALTER TABLE titles ADD COLUMN sort_title TEXT;
SQL
end
def down : String
<<-SQL
-- remove sort_title column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
signature TEXT,
unavailable INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ids
SELECT path, id, signature, unavailable
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
-- recreate the foreign key constraint on thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
FOREIGN KEY (id) REFERENCES ids (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
-- remove sort_title column from titles
ALTER TABLE titles RENAME TO tmp;
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT,
unavailable INTEGER NOT NULL DEFAULT 0
);
INSERT INTO titles
SELECT id, path, signature, unavailable
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX titles_id_idx on titles (id);
CREATE UNIQUE INDEX titles_path_idx on titles (path);
-- recreate the foreign key constraint on tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag),
FOREIGN KEY (id) REFERENCES titles (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
SQL
end
end

View File

@@ -6,25 +6,20 @@
"author": "Alex Ling <hkalexling@gmail.com>", "author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.18.9",
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0", "all-contributors-cli": "^6.19.0",
"eslint": "^8.22.0",
"eslint-plugin-prettier": "^4.2.1",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0", "gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1", "gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4", "gulp-minify-css": "^1.2.4",
"less": "^3.11.3", "less": "^3.11.3"
"prettier": "^2.7.1"
}, },
"scripts": { "scripts": {
"uglify": "gulp", "uglify": "gulp"
"lint": "eslint public/js *.js --ext .js"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0", "@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "~3.14.0" "uikit": "^3.5.4"
} }
} }

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,56 +1,55 @@
const component = () => { const component = () => {
return { return {
progress: 1.0, progress: 1.0,
generating: false, generating: false,
scanning: false, scanning: false,
scanTitles: 0, scanTitles: 0,
scanMs: -1, scanMs: -1,
themeSetting: '', themeSetting: '',
init() { init() {
this.getProgress(); this.getProgress();
setInterval(() => { setInterval(() => {
this.getProgress(); this.getProgress();
}, 5000); }, 5000);
const setting = loadThemeSetting(); const setting = loadThemeSetting();
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1); this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
}, },
themeChanged(event) { themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase(); const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
}, },
scan() { scan() {
if (this.scanning) return; if (this.scanning) return;
this.scanning = true; this.scanning = true;
this.scanMs = -1; this.scanMs = -1;
this.scanTitles = 0; this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`) $.post(`${base_url}api/admin/scan`)
.then((data) => { .then(data => {
this.scanMs = data.milliseconds; this.scanMs = data.milliseconds;
this.scanTitles = data.titles; this.scanTitles = data.titles;
}) })
.catch((e) => { .always(() => {
alert('danger', `Failed to trigger a scan. Error: ${e}`); this.scanning = false;
}) });
.always(() => { },
this.scanning = false; generateThumbnails() {
}); if (this.generating) return;
}, this.generating = true;
generateThumbnails() { this.progress = 0.0;
if (this.generating) return; $.post(`${base_url}api/admin/generate_thumbnails`)
this.generating = true; .then(() => {
this.progress = 0.0; this.getProgress()
$.post(`${base_url}api/admin/generate_thumbnails`).then(() => { });
this.getProgress(); },
}); getProgress() {
}, $.get(`${base_url}api/admin/thumbnail_progress`)
getProgress() { .then(data => {
$.get(`${base_url}api/admin/thumbnail_progress`).then((data) => { this.progress = data.progress;
this.progress = data.progress; this.generating = data.progress > 0;
this.generating = data.progress > 0; });
}); },
}, };
};
}; };

View File

@@ -1,6 +1,6 @@
const alert = (level, text) => { const alert = (level, text) => {
$('#alert').empty(); $('#alert').empty();
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`; const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
$('#alert').append(html); $('#alert').append(html);
$('html, body').animate({ scrollTop: 0 }); $("html, body").animate({ scrollTop: 0 });
}; };

View File

@@ -11,7 +11,7 @@
* @param {string} selector - The jQuery selector to the root element * @param {string} selector - The jQuery selector to the root element
*/ */
const setProp = (key, prop, selector = '#root') => { const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop; $(selector).get(0).__x.$data[key] = prop;
}; };
/** /**
@@ -23,7 +23,7 @@ const setProp = (key, prop, selector = '#root') => {
* @return {*} The data property * @return {*} The data property
*/ */
const getProp = (key, selector = '#root') => { const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key]; return $(selector).get(0).__x.$data[key];
}; };
/** /**
@@ -41,10 +41,7 @@ const getProp = (key, selector = '#root') => {
* @return {bool} * @return {bool}
*/ */
const preferDarkMode = () => { const preferDarkMode = () => {
return ( return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
);
}; };
/** /**
@@ -55,7 +52,7 @@ const preferDarkMode = () => {
* @return {bool} * @return {bool}
*/ */
const validThemeSetting = (theme) => { const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0; return ['dark', 'light', 'system'].indexOf(theme) >= 0;
}; };
/** /**
@@ -65,9 +62,9 @@ const validThemeSetting = (theme) => {
* @return {string} A theme setting ('dark', 'light', or 'system') * @return {string} A theme setting ('dark', 'light', or 'system')
*/ */
const loadThemeSetting = () => { const loadThemeSetting = () => {
let str = localStorage.getItem('theme'); let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system'; if (!str || !validThemeSetting(str)) str = 'system';
return str; return str;
}; };
/** /**
@@ -77,11 +74,11 @@ const loadThemeSetting = () => {
* @return {string} The current theme to use ('dark' or 'light') * @return {string} The current theme to use ('dark' or 'light')
*/ */
const loadTheme = () => { const loadTheme = () => {
let setting = loadThemeSetting(); let setting = loadThemeSetting();
if (setting === 'system') { if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light'; setting = preferDarkMode() ? 'dark' : 'light';
} }
return setting; return setting;
}; };
/** /**
@@ -90,9 +87,9 @@ const loadTheme = () => {
* @function saveThemeSetting * @function saveThemeSetting
* @param {string} setting - A theme setting * @param {string} setting - A theme setting
*/ */
const saveThemeSetting = (setting) => { const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'system'; if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting); localStorage.setItem('theme', setting);
}; };
/** /**
@@ -102,10 +99,10 @@ const saveThemeSetting = (setting) => {
* @function toggleTheme * @function toggleTheme
*/ */
const toggleTheme = () => { const toggleTheme = () => {
const theme = loadTheme(); const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark'; const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme); saveThemeSetting(newTheme);
setTheme(newTheme); setTheme(newTheme);
}; };
/** /**
@@ -116,32 +113,31 @@ const toggleTheme = () => {
* `loadTheme` to get a theme and apply it. * `loadTheme` to get a theme and apply it.
*/ */
const setTheme = (theme) => { const setTheme = (theme) => {
if (!theme) theme = loadTheme(); if (!theme) theme = loadTheme();
if (theme === 'dark') { if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)'); $('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light'); $('body').addClass('uk-light');
$('.ui-widget-content').addClass('dark'); $('.ui-widget-content').addClass('dark');
} else { } else {
$('html').css('background', ''); $('html').css('background', '');
$('body').removeClass('uk-light'); $('body').removeClass('uk-light');
$('.ui-widget-content').removeClass('dark'); $('.ui-widget-content').removeClass('dark');
} }
}; };
// do it before document is ready to prevent the initial flash of white on // do it before document is ready to prevent the initial flash of white on
// most pages // most pages
setTheme(); setTheme();
$(() => { $(() => {
// hack for the reader page // hack for the reader page
setTheme(); setTheme();
// on system dark mode setting change // on system dark mode setting change
if (window.matchMedia) { if (window.matchMedia) {
window window.matchMedia('(prefers-color-scheme: dark)')
.matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', event => {
.addEventListener('change', (event) => { if (loadThemeSetting() === 'system')
if (loadThemeSetting() === 'system') setTheme(event.matches ? 'dark' : 'light');
setTheme(event.matches ? 'dark' : 'light'); });
}); }
}
}); });

View File

@@ -5,22 +5,22 @@
* @param {object} e - The title element to truncate * @param {object} e - The title element to truncate
*/ */
const truncate = (e) => { const truncate = (e) => {
$(e).dotdotdot({ $(e).dotdotdot({
truncate: 'letter', truncate: 'letter',
watch: true, watch: true,
callback: (truncated) => { callback: (truncated) => {
if (truncated) { if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title')); $(e).attr('uk-tooltip', $(e).attr('data-title'));
} else { } else {
$(e).removeAttr('uk-tooltip'); $(e).removeAttr('uk-tooltip');
} }
}, }
}); });
}; };
$('.uk-card-title').each((i, e) => { $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view // Truncate the title when it first enters the view
$(e).one('inview', () => { $(e).one('inview', () => {
truncate(e); truncate(e);
}); });
}); });

View File

@@ -1,135 +1,116 @@
const component = () => { const component = () => {
return { return {
jobs: [], jobs: [],
paused: undefined, paused: undefined,
loading: false, loading: false,
toggling: false, toggling: false,
ws: undefined, ws: undefined,
wsConnect(secure = true) { wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${ const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
location.host console.log(`Connecting to ${url}`);
}${base_url}api/admin/mangadex/queue`; this.ws = new WebSocket(url);
console.log(`Connecting to ${url}`); this.ws.onmessage = event => {
this.ws = new WebSocket(url); const data = JSON.parse(event.data);
this.ws.onmessage = (event) => { this.jobs = data.jobs;
const data = JSON.parse(event.data); this.paused = data.paused;
this.jobs = data.jobs; };
this.paused = data.paused; this.ws.onclose = () => {
}; if (this.ws.failed)
this.ws.onclose = () => { return this.wsConnect(false);
if (this.ws.failed) return this.wsConnect(false); alert('danger', 'Socket connection closed');
alert('danger', 'Socket connection closed'); };
}; this.ws.onerror = () => {
this.ws.onerror = () => { if (secure)
if (secure) return (this.ws.failed = true); return this.ws.failed = true;
alert('danger', 'Socket connection failed'); alert('danger', 'Socket connection failed');
}; };
}, },
init() { init() {
this.wsConnect(); this.wsConnect();
this.load(); this.load();
}, },
load() { load() {
this.loading = true; this.loading = true;
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: base_url + 'api/admin/mangadex/queue', url: base_url + 'api/admin/mangadex/queue',
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (!data.success && data.error) { if (!data.success && data.error) {
alert( alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
'danger', return;
`Failed to fetch download queue. Error: ${data.error}`, }
); this.jobs = data.jobs;
return; this.paused = data.paused;
} })
this.jobs = data.jobs; .fail((jqXHR, status) => {
this.paused = data.paused; alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { .always(() => {
alert( this.loading = false;
'danger', });
`Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, },
); jobAction(action, event) {
}) let url = `${base_url}api/admin/mangadex/queue/${action}`;
.always(() => { if (event) {
this.loading = false; const id = event.currentTarget.closest('tr').id.split('-')[1];
}); url = `${url}?${$.param({
}, id: id
jobAction(action, event) { })}`;
let url = `${base_url}api/admin/mangadex/queue/${action}`; }
if (event) { console.log(url);
const id = event.currentTarget $.ajax({
.closest('tr') type: 'POST',
.id.split('-') url: url,
.slice(1) dataType: 'json'
.join('-'); })
url = `${url}?${$.param({ .done(data => {
id, if (!data.success && data.error) {
})}`; alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
} return;
console.log(url); }
$.ajax({ this.load();
type: 'POST', })
url, .fail((jqXHR, status) => {
dataType: 'json', alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) });
.done((data) => { },
if (!data.success && data.error) { toggle() {
alert( this.toggling = true;
'danger', const action = this.paused ? 'resume' : 'pause';
`Failed to ${action} job from download queue. Error: ${data.error}`, const url = `${base_url}api/admin/mangadex/queue/${action}`;
); $.ajax({
return; type: 'POST',
} url: url,
this.load(); dataType: 'json'
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'danger', })
`Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, .always(() => {
); this.load();
}); this.toggling = false;
}, });
toggle() { },
this.toggling = true; statusClass(status) {
const action = this.paused ? 'resume' : 'pause'; let cls = 'label ';
const url = `${base_url}api/admin/mangadex/queue/${action}`; switch (status) {
$.ajax({ case 'Pending':
type: 'POST', cls += 'label-pending';
url, break;
dataType: 'json', case 'Completed':
}) cls += 'label-success';
.fail((jqXHR, status) => { break;
alert( case 'Error':
'danger', cls += 'label-danger';
`Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, break;
); case 'MissingPages':
}) cls += 'label-warning';
.always(() => { break;
this.load(); }
this.toggling = false; return cls;
}); }
}, };
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
},
};
}; };

285
public/js/download.js Normal file
View File

@@ -0,0 +1,285 @@
const downloadComponent = () => {
return {
chaptersLimit: 1000,
loading: false,
addingToDownload: false,
searchAvailable: false,
searchInput: '',
data: {},
chapters: [],
mangaAry: undefined, // undefined: not searching; []: searched but no result
candidateManga: {},
langChoice: 'All',
groupChoice: 'All',
chapterRange: '',
volumeRange: '',
get languages() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
set.add(chp.language);
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
get groups() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
Object.keys(chp.groups).forEach(g => {
set.add(g);
});
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
init() {
const tableObserver = new MutationObserver(() => {
console.log('table mutated');
$("#selectable").selectable({
filter: 'tr'
});
});
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true
});
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
this.searchAvailable = true;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
filtersUpdated() {
if (!this.data.chapters)
this.chapters = [];
const filters = {
chapter: this.parseRange(this.chapterRange),
volume: this.parseRange(this.volumeRange),
lang: this.langChoice,
group: this.groupChoice
};
console.log('filters:', filters);
let _chapters = this.data.chapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
_chapters = _chapters.filter(c => {
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return;
}
if (k === 'lang') {
_chapters = _chapters.filter(c => c.language === v);
return;
}
const lb = parseFloat(v[0]);
const ub = parseFloat(v[1]);
if (isNaN(lb) && isNaN(ub)) return;
_chapters = _chapters.filter(c => {
const val = parseFloat(c[k]);
if (isNaN(val)) return false;
if (isNaN(lb))
return val <= ub;
else if (isNaN(ub))
return val >= lb;
else
return val >= lb && val <= ub;
});
});
console.log('filtered chapters:', _chapters);
this.chapters = _chapters;
},
search() {
if (this.loading || this.searchInput === '') return;
this.data = {};
this.mangaAry = undefined;
var int_id = -1;
try {
const path = new URL(this.searchInput).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(this.searchInput);
}
if (!isNaN(int_id) && int_id > 0) {
// The input is a positive integer. We treat it as an ID.
this.loading = true;
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
return;
}
this.data = data;
this.chapters = data.chapters;
this.mangaAry = undefined;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
} else {
if (!this.searchAvailable) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
return;
}
// Search as a search term
this.loading = true;
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
query: this.searchInput
})}`)
.done((data) => {
if (data.error) {
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
return;
}
this.mangaAry = data.manga;
this.data = {};
})
.fail((jqXHR, status) => {
alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
}
},
parseRange(str) {
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
const matches = str.match(regex);
var num;
if (!matches) {
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
return [null, null];
}
switch (matches[1]) {
case '<':
return [null, num - 1];
case '<=':
return [null, num];
case '>':
return [num + 1, null];
case '>=':
return [num, null];
}
} else if (typeof matches[3] !== 'undefined') {
// a single number
num = parseInt(matches[3]);
if (isNaN(num)) {
return [null, null];
}
return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23
num = parseInt(matches[4]);
const n2 = parseInt(matches[5]);
if (isNaN(num) || isNaN(n2) || num > n2) {
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
},
unescapeHTML(str) {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
download() {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
const ids = selected.map((i, e) => {
return parseInt($(e).find('td').first().text());
}).get();
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
this.addingToDownload = true;
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/download`,
data: JSON.stringify({
chapters: chapters
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.addingToDownload = false;
});
});
},
chooseManga(manga) {
this.candidateManga = manga;
UIkit.modal($('#modal').get(0)).show();
},
confirmManga(id) {
UIkit.modal($('#modal').get(0)).hide();
this.searchInput = id;
this.search();
}
};
};

61
public/js/mangadex.js Normal file
View File

@@ -0,0 +1,61 @@
const component = () => {
return {
username: '',
password: '',
expires: undefined,
loading: true,
loggingIn: false,
init() {
this.loading = true;
$.ajax({
type: 'GET',
url: `${base_url}api/admin/mangadex/expires`,
contentType: "application/json",
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
return;
}
this.expires = data.expires;
this.loading = false;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
login() {
if (!(this.username && this.password)) return;
this.loggingIn = true;
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/login`,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
username: this.username,
password: this.password
})
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to log in. Error: ${data.error}`);
return;
}
this.expires = data.expires;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loggingIn = false;
});
},
get expired() {
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
}
};
};

View File

@@ -1,74 +1,60 @@
const component = () => { const component = () => {
return { return {
empty: true, empty: true,
titles: [], titles: [],
entries: [], entries: [],
loading: true, loading: true,
load() { load() {
this.loading = true; this.loading = true;
this.request('GET', `${base_url}api/admin/titles/missing`, (data) => { this.request('GET', `${base_url}api/admin/titles/missing`, data => {
this.titles = data.titles; this.titles = data.titles;
this.request('GET', `${base_url}api/admin/entries/missing`, (data) => { this.request('GET', `${base_url}api/admin/entries/missing`, data => {
this.entries = data.entries; this.entries = data.entries;
this.loading = false; this.loading = false;
this.empty = this.entries.length === 0 && this.titles.length === 0; this.empty = this.entries.length === 0 && this.titles.length === 0;
}); });
}); });
}, },
rm(event) { rm(event) {
const rawID = event.currentTarget.closest('tr').id; const rawID = event.currentTarget.closest('tr').id;
const [type, id] = rawID.split('-'); const [type, id] = rawID.split('-');
const url = `${base_url}api/admin/${ const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
type === 'title' ? 'titles' : 'entries' this.request('DELETE', url, () => {
}/missing/${id}`; this.load();
this.request('DELETE', url, () => { });
this.load(); },
}); rmAll() {
}, UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
rmAll() { labels: {
UIkit.modal ok: 'Yes, delete them',
.confirm( cancel: 'Cancel'
'Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', }
{ }).then(() => {
labels: { this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
ok: 'Yes, delete them', this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
cancel: 'Cancel', this.load();
}, });
}, });
) });
.then(() => { },
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => { request(method, url, cb) {
this.request( console.log(url);
'DELETE', $.ajax({
`${base_url}api/admin/entries/missing`, type: method,
() => { url: url,
this.load(); contentType: 'application/json'
}, })
); .done(data => {
}); if (data.error) {
}); alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
}, return;
request(method, url, cb) { }
console.log(url); if (cb) cb(data);
$.ajax({ })
type: method, .fail((jqXHR, status) => {
url, alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
contentType: 'application/json', });
}) }
.done((data) => { };
if (data.error) {
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
return;
}
if (cb) cb(data);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
};
}; };

View File

@@ -1,435 +1,326 @@
const component = () => { const component = () => {
return { return {
plugins: [], plugins: [],
subscribable: false, info: undefined,
info: undefined, pid: undefined,
pid: undefined, chapters: undefined, // undefined: not searched yet, []: empty
chapters: undefined, // undefined: not searched yet, []: empty manga: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty allChapters: [],
mid: undefined, // id of the selected manga query: '',
allChapters: [], mangaTitle: '',
query: '', searching: false,
mangaTitle: '', adding: false,
searching: false, sortOptions: [],
adding: false, showFilters: false,
sortOptions: [], appliedFilters: [],
showFilters: false, chaptersLimit: 500,
appliedFilters: [], listManga: false,
chaptersLimit: 500,
listManga: false,
subscribing: false,
subscriptionName: '',
init() { init() {
const tableObserver = new MutationObserver(() => { const tableObserver = new MutationObserver(() => {
console.log('table mutated'); console.log('table mutated');
$('#selectable').selectable({ $('#selectable').selectable({
filter: 'tr', filter: 'tr'
}); });
}); });
tableObserver.observe($('table').get(0), { tableObserver.observe($('table').get(0), {
childList: true, childList: true,
subtree: true, subtree: true
}); });
fetch(`${base_url}api/admin/plugin`) fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json()) .then(res => res.json())
.then((data) => { .then(data => {
if (!data.success) throw new Error(data.error); if (!data.success)
this.plugins = data.plugins; throw new Error(data.error);
this.plugins = data.plugins;
const pid = localStorage.getItem('plugin'); const pid = localStorage.getItem('plugin');
if (pid && this.plugins.map((p) => p.id).includes(pid)) if (pid && this.plugins.map(p => p.id).includes(pid))
return this.loadPlugin(pid); return this.loadPlugin(pid);
if (this.plugins.length > 0) this.loadPlugin(this.plugins[0].id); if (this.plugins.length > 0)
}) this.loadPlugin(this.plugins[0].id);
.catch((e) => { })
alert('danger', `Failed to list the available plugins. Error: ${e}`); .catch(e => {
}); alert('danger', `Failed to list the available plugins. Error: ${e}`);
}, });
loadPlugin(pid) { },
fetch( loadPlugin(pid) {
`${base_url}api/admin/plugin/info?${new URLSearchParams({ fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid, plugin: pid
})}`, })}`)
) .then(res => res.json())
.then((res) => res.json()) .then(data => {
.then((data) => { if (!data.success)
if (!data.success) throw new Error(data.error); throw new Error(data.error);
this.info = data.info; this.info = data.info;
this.subscribable = data.subscribable; this.pid = pid;
this.pid = pid; })
}) .catch(e => {
.catch((e) => { alert('danger', `Failed to get plugin metadata. Error: ${e}`);
alert('danger', `Failed to get plugin metadata. Error: ${e}`); });
}); },
}, pluginChanged() {
pluginChanged() { this.loadPlugin(this.pid);
this.manga = undefined; localStorage.setItem('plugin', this.pid);
this.chapters = undefined; },
this.mid = undefined; get chapterKeys() {
this.loadPlugin(this.pid); if (this.allChapters.length < 1) return [];
localStorage.setItem('plugin', this.pid); return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k));
}, },
get chapterKeys() { searchChapters(query) {
if (this.allChapters.length < 1) return []; this.searching = true;
return Object.keys(this.allChapters[0]).filter( this.allChapters = [];
(k) => !['manga_title'].includes(k), this.chapters = undefined;
); this.listManga = false;
}, fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({
searchChapters(query) { plugin: this.pid,
this.searching = true; query: query
this.allChapters = []; })}`)
this.sortOptions = []; .then(res => res.json())
this.chapters = undefined; .then(data => {
this.listManga = false; if (!data.success)
fetch( throw new Error(data.error);
`${base_url}api/admin/plugin/list?${new URLSearchParams({ try {
plugin: this.pid, this.mangaTitle = data.chapters[0].manga_title;
query, if (!this.mangaTitle) throw new Error();
})}`, } catch (e) {
) this.mangaTitle = data.title;
.then((res) => res.json()) }
.then((data) => {
if (!data.success) throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
this.allChapters = data.chapters; this.allChapters = data.chapters;
this.chapters = data.chapters; this.chapters = data.chapters;
}) })
.catch((e) => { .catch(e => {
alert('danger', `Failed to list chapters. Error: ${e}`); alert('danger', `Failed to list chapters. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.searching = false; this.searching = false;
}); });
}, },
searchManga(query) { searchManga() {
this.searching = true; this.searching = true;
this.allChapters = []; this.allChapters = [];
this.chapters = undefined; this.chapters = undefined;
this.manga = undefined; this.manga = undefined;
fetch( fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({
`${base_url}api/admin/plugin/search?${new URLSearchParams({ plugin: this.pid,
plugin: this.pid, query: this.query
query, })}`)
})}`, .then(res => res.json())
) .then(data => {
.then((res) => res.json()) if (!data.success)
.then((data) => { throw new Error(data.error);
if (!data.success) throw new Error(data.error); this.manga = data.manga;
this.manga = data.manga; this.listManga = true;
this.listManga = true; })
}) .catch(e => {
.catch((e) => { alert('danger', `Search failed. Error: ${e}`);
alert('danger', `Search failed. Error: ${e}`); })
}) .finally(() => {
.finally(() => { this.searching = false;
this.searching = false; });
}); },
}, search() {
search() { this.manga = undefined;
const query = this.query.trim(); if (this.info.version === 1) {
if (!query) return; this.searchChapters(this.query);
} else {
this.searchManga();
}
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
download() {
const selected = $('tbody > tr.ui-selected').get();
if (selected.length === 0) return;
this.manga = undefined; UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
this.mid = undefined; const ids = selected.map(e => e.id);
if (this.info.version === 1) { const chapters = this.chapters.filter(c => ids.includes(c.id));
this.searchChapters(query); console.log(chapters);
} else { this.adding = true;
this.searchManga(query); fetch(`${base_url}api/admin/plugin/download`, {
} method: 'POST',
}, body: JSON.stringify({
selectAll() { chapters,
$('tbody#selectable > tr').each((i, e) => { plugin: this.pid,
$(e).addClass('ui-selected'); title: this.mangaTitle
}); }),
}, headers: {
clearSelection() { "Content-Type": "application/json"
$('tbody#selectable > tr').each((i, e) => { }
$(e).removeClass('ui-selected'); })
}); .then(res => res.json())
}, .then(data => {
download() { if (!data.success)
const selected = $('tbody#selectable > tr.ui-selected').get(); throw new Error(data.error);
if (selected.length === 0) return; const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
})
.catch(e => {
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
})
.finally(() => {
this.adding = false;
});
})
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split('-')[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option)
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
UIkit.modal console.log('initial size:', ary.length);
.confirm(`Download ${selected.length} selected chapters?`) for (let filter of this.appliedFilters) {
.then(() => { if (!filter.value) continue;
const ids = selected.map((e) => e.id); if (filter.type === 'array' && filter.value === 'all') continue;
const chapters = this.chapters.filter((c) => ids.includes(c.id));
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: 'POST',
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert(
'success',
`${successCount} of ${
successCount + failCount
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`,
);
})
.catch((e) => {
alert(
'danger',
`Failed to add chapters to the download queue. Error: ${e}`,
);
})
.finally(() => {
this.adding = false;
});
});
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split('-')[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option);
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log('initial size:', ary.length); console.log('applying filter:', filter);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === 'array' && filter.value === 'all') continue;
if (filter.type.startsWith('number') && isNaN(filter.value)) continue;
if (filter.type === 'string') { if (filter.type === 'string') {
ary = ary.filter((ch) => ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()), }
); if (filter.type === 'number-min') {
} ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
if (filter.type === 'number-min') { }
ary = ary.filter( if (filter.type === 'number-max') {
(ch) => Number(ch[filter.key]) >= Number(filter.value), ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
); }
} if (filter.type === 'date-min') {
if (filter.type === 'number-max') { ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
ary = ary.filter( }
(ch) => Number(ch[filter.key]) <= Number(filter.value), if (filter.type === 'date-max') {
); ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
} }
if (filter.type === 'date-min') { if (filter.type === 'array') {
ary = ary.filter( ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
(ch) => Number(ch[filter.key]) >= Number(filter.value), }
);
}
if (filter.type === 'date-max') {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value),
);
}
if (filter.type === 'array') {
ary = ary.filter((ch) =>
ch[filter.key]
.map((s) => (typeof s === 'string' ? s.toLowerCase() : s))
.includes(filter.value.toLowerCase()),
);
}
console.log('filtered size:', ary.length); console.log('filtered size:', ary.length);
} }
return ary; return ary;
}, },
// option: // option:
// - 1: asending // - 1: asending
// - -1: desending // - -1: desending
// - 0: unsorted // - 0: unsorted
sort(key, option) { sort(key, option) {
if (option === 0) { if (option === 0) {
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
return; return;
} }
this.chapters = this.filteredChapters.sort((a, b) => { this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]); const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp; return option < 0 ? comp * -1 : comp;
}); });
}, },
compare(a, b) { compare(a, b) {
if (a === b) return 0; if (a === b) return 0;
// try numbers (also covers dates) // try numbers
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b); // this must come before the date checks, because any integer would
// also be parsed as a date.
if (!isNaN(a) && !isNaN(b))
return Number(a) - Number(b);
const preprocessString = (val) => { // try dates
if (typeof val !== 'string') return val; if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
return val.toLowerCase().replace(/\s\s/g, ' ').trim(); return this.parseDate(a) - this.parseDate(b);
};
return preprocessString(a) > preprocessString(b) ? 1 : -1; const preprocessString = (val) => {
}, if (typeof val !== 'string') return val;
fieldType(values) { return val.toLowerCase().replace(/\s\s/g, ' ').trim();
if (values.every((v) => this.numIsDate(v))) return 'date'; };
if (values.every((v) => !isNaN(v))) return 'number';
if (values.every((v) => Array.isArray(v))) return 'array';
return 'string';
},
get filters() {
if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(
(k) => !['manga_title', 'id'].includes(k),
);
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values);
if (type === 'array') { return preprocessString(a) > preprocessString(b) ? 1 : -1;
// if the type is an array, return the list of available elements },
// example: an array of groups or authors fieldType(values) {
values = Array.from( if (values.every(v => !isNaN(v))) return 'number'; // display input for number range
new Set( if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range
values.flat().map((v) => { if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains
if (typeof v === 'string') return v.toLowerCase(); return 'string'; // display input for string searching.
}), // for the last two, if the number of options is small enough (say < 50), display a multi-select2
), },
); get filters() {
} if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k));
return keys.map(k => {
let values = this.allChapters.map(c => c[k]);
const type = this.fieldType(values);
return { if (type === 'array') {
key: k, // if the type is an array, return the list of available elements
type, // example: an array of groups or authors
values, values = Array.from(new Set(values.flat().map(v => {
}; if (typeof v === 'string') return v.toLowerCase();
}); })));
}, }
get filterSettings() {
return $('#filter-form input:visible, #filter-form select:visible')
.get()
.map((i) => {
const type = i.getAttribute('data-filter-type');
let value = i.value.trim();
if (type.startsWith('date'))
value = value ? Date.parse(value).toString() : '';
return {
key: i.getAttribute('data-filter-key'),
value,
type,
};
});
},
applyFilters() {
this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
clearFilters() {
$('#filter-form input')
.get()
.forEach((i) => (i.value = ''));
$('#filter-form select').val('all');
this.appliedFilters = [];
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute('data-id');
this.mid = mid;
this.searchChapters(mid);
},
subscribe(modal) {
this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: 'POST',
body: JSON.stringify({
filters: this.filterSettings,
plugin: this.pid,
name: this.subscriptionName.trim(),
manga: this.mangaTitle,
manga_id: this.mid,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert('success', 'Subscription created');
})
.catch((e) => {
alert('danger', `Failed to subscribe. Error: ${e}`);
})
.finally(() => {
this.subscribing = false;
UIkit.modal(modal).hide();
});
},
numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format('MMM D, YYYY')}</span>`;
const maxLength = 40;
if (value && value.length > maxLength)
return `<span>${value.substr(
0,
maxLength,
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case 'number-min':
type = 'number (minimum value)';
break;
case 'number-max':
type = 'number (maximum value)';
break;
case 'date-min':
type = 'minimum date';
break;
case 'date-max':
type = 'maximum date';
break;
}
let value = ft.value;
if (ft.type.startsWith('number') && isNaN(value)) value = ''; return {
else if (ft.type.startsWith('date') && value) key: k,
value = moment(Number(value)).format('MMM D, YYYY'); type: type,
values: values
return `<td>${key}</td><td>${type}</td><td>${value}</td>`; };
}, });
}; },
applyFilters() {
const values = $('#filter-form input, #filter-form select')
.get()
.map(i => ({
key: i.getAttribute('data-filter-key'),
value: i.value.trim(),
type: i.getAttribute('data-filter-type')
}));
this.appliedFilters = values;
this.chapters = this.filteredChapters;
},
clearFilters() {
$('#filter-form input').get().forEach(i => i.value = '');
this.appliedFilters = [];
this.chapters = this.filteredChapters;
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute('data-id');
this.searchChapters(mid);
},
parseDate(str) {
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
// Basic sanity check to make sure it's an actual date.
// We need this because Date.parse thinks 'Chapter 1' is a date.
if (!regex.test(str))
return NaN;
return Date.parse(str);
}
};
}; };

View File

@@ -1,370 +1,292 @@
const readerComponent = () => { const readerComponent = () => {
return { return {
loading: true, loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width' mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...', msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary', alertClass: 'uk-alert-primary',
items: [], items: [],
curItem: {}, curItem: {},
enableFlipAnimation: true, flipAnimation: null,
flipAnimation: null, longPages: false,
longPages: false, lastSavedPage: page,
lastSavedPage: page, selectedIndex: 0, // 0: not selected; 1: the first page
selectedIndex: 0, // 0: not selected; 1: the first page margin: 30,
margin: 30,
preloadLookahead: 3,
enableRightToLeft: false,
fitType: 'vert',
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
*/ */
init(nextTick) { init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) $.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then((data) => { .then(data => {
if (!data.success && data.error) throw new Error(resp.error); if (!data.success && data.error)
const dimensions = data.dimensions; throw new Error(resp.error);
const dimensions = data.dimensions;
this.items = dimensions.map((d, i) => { this.items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i + 1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width === 0 ? '100%' : d.width, width: d.width,
height: d.height === 0 ? '100%' : d.height, height: d.height,
}; };
}); });
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`. const avgRatio = this.items.reduce((acc, cur) => {
// TODO: support more image types in image_size.cr return acc + cur.height / cur.width
const avgRatio = }, 0) / this.items.length;
dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width;
}, 0) / dimensions.length;
console.log(avgRatio); console.log(avgRatio);
this.longPages = avgRatio > 2; this.longPages = avgRatio > 2;
this.loading = false; this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous'; this.mode = localStorage.getItem('mode') || 'continuous';
// Here we save a copy of this.mode, and use the copy as // Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode` // the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`, // might change this.mode and make it `height` or `width`,
// which are not available in mode-select // which are not available in mode-select
const mode = this.mode; const mode = this.mode;
this.updateMode(this.mode, page, nextTick); this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode); $('#mode-select').val(mode);
const savedMargin = localStorage.getItem('margin'); const savedMargin = localStorage.getItem('margin');
if (savedMargin) { if (savedMargin) {
this.margin = savedMargin; this.margin = savedMargin;
} }
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
})
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
// Preload Images this.updateMode(mode, curIdx, nextTick);
this.preloadLookahead = +( },
localStorage.getItem('preloadLookahead') ?? 3 /**
); * Handles the window `resize` event
const limit = Math.min( */
page + this.preloadLookahead, resized() {
this.items.length, if (this.mode === 'continuous') return;
);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFitType = localStorage.getItem('fitType'); const wideScreen = $(window).width() > $(window).height();
if (savedFitType) { this.mode = wideScreen ? 'height' : 'width';
this.fitType = savedFitType; },
$('#fit-select').val(savedFitType); /**
} * Handles the window `keydown` event
const savedFlipAnimation = localStorage.getItem( *
'enableFlipAnimation', * @param {Event} event - The triggering event
); */
this.enableFlipAnimation = keyHandler(event) {
savedFlipAnimation === null || savedFlipAnimation === 'true'; if (this.mode === 'continuous') return;
const savedRightToLeft = localStorage.getItem('enableRightToLeft'); if (event.key === 'ArrowLeft' || event.key === 'k')
if (savedRightToLeft === null) { this.flipPage(false);
this.enableRightToLeft = false; if (event.key === 'ArrowRight' || event.key === 'j')
} else { this.flipPage(true);
this.enableRightToLeft = savedRightToLeft === 'true'; },
} /**
}) * Flips to the next or the previous page
.catch((e) => { *
const errMsg = `Failed to get the page dimensions. ${e}`; * @param {bool} isNext - Whether we are going to the next page
console.error(e); */
this.alertClass = 'uk-alert-danger'; flipPage(isNext) {
this.msg = errMsg; const idx = parseInt(this.curItem.id);
}); const newIdx = idx + (isNext ? 1 : -1);
},
/**
* Preload an image, which is expected to be cached
*/
preloadImage(url) {
new Image().src = url;
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick); if (newIdx <= 0 || newIdx > this.items.length) return;
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height(); this.toPage(newIdx);
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k') if (isNext)
this.flipPage(false ^ this.enableRightToLeft); this.flipAnimation = 'right';
if (event.key === 'ArrowRight' || event.key === 'j') else
this.flipPage(true ^ this.enableRightToLeft); this.flipAnimation = 'left';
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0) return; setTimeout(() => {
if (newIdx > this.items.length) { this.flipAnimation = null;
this.showControl(idx); }, 500);
return;
}
if (newIdx + this.preloadLookahead < this.items.length + 1) { this.replaceHistory(newIdx);
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url); },
} /**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
this.toPage(newIdx); const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
if (this.enableFlipAnimation) { // The mode to be put into the `mode` prop. It can't be `screen`
if (isNext ^ this.enableRightToLeft) this.flipAnimation = 'right'; let propMode = mode;
else this.flipAnimation = 'left';
}
setTimeout(() => { if (mode === 'paged') {
this.flipAnimation = null; const wideScreen = $(window).width() > $(window).height();
}, 500); propMode = wideScreen ? 'height' : 'width';
}
this.replaceHistory(newIdx); this.mode = propMode;
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, '', url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (
Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 ||
idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({ if (mode === 'continuous') {
eid, nextTick(() => {
})}`; this.setupScroller();
$.ajax({ });
method: 'PUT', }
url,
dataType: 'json',
})
.done((data) => {
if (data.error) alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen` nextTick(() => {
let propMode = mode; this.toPage(targetPage);
});
},
/**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
if (mode === 'paged') { this.curItem = this.items[current - 1];
const wideScreen = $(window).width() > $(window).height(); this.replaceHistory(current);
propMode = wideScreen ? 'height' : 'width'; }
} });
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
*/
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
},
this.mode = propMode; /**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
if (mode === 'continuous') { marginChanged() {
nextTick(() => { localStorage.setItem('margin', this.margin);
this.setupScroller(); this.toPage(this.selectedIndex);
}); }
} };
}
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Handles clicked image
*
* @param {Event} event - The triggering event
*/
clickImage(event) {
const idx = event.currentTarget.id;
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
*/
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
},
/**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
marginChanged() {
localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex);
},
fitChanged() {
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
enableRightToLeftChanged() {
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
},
};
};

View File

@@ -1,28 +1,30 @@
$(function () { $(function(){
let filter = []; var filter = [];
let result = []; var result = [];
$('.uk-card-title').each(function () { $('.uk-card-title').each(function(){
filter.push($(this).text()); filter.push($(this).text());
}); });
$('.uk-search-input').keyup(function () { $('.uk-search-input').keyup(function(){
let input = $('.uk-search-input').val(); var input = $('.uk-search-input').val();
let regex = new RegExp(input, 'i'); var regex = new RegExp(input, 'i');
if (input === '') { if (input === '') {
$('.item').each(function () { $('.item').each(function(){
$(this).removeAttr('hidden'); $(this).removeAttr('hidden');
}); });
} else { }
filter.forEach(function (text, i) { else {
result[i] = text.match(regex); filter.forEach(function(text, i){
}); result[i] = text.match(regex);
$('.item').each(function (i) { });
if (result[i]) { $('.item').each(function(i){
$(this).removeAttr('hidden'); if (result[i]) {
} else { $(this).removeAttr('hidden');
$(this).attr('hidden', ''); }
} else {
}); $(this).attr('hidden', '');
} }
}); });
}
});
}); });

View File

@@ -1,15 +1,15 @@
$(() => { $(() => {
$('#sort-select').change(() => { $('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id'); const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-'); const ary = sort.split('-');
const by = ary[0]; const by = ary[0];
const dir = ary[1]; const dir = ary[1];
const url = `${location.protocol}//${location.host}${location.pathname}`; const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({ const newURL = `${url}?${$.param({
sort: by, sort: by,
ascend: dir === 'up' ? 1 : 0, ascend: dir === 'up' ? 1 : 0
})}`; })}`;
window.location.href = newURL; window.location.href = newURL;
}); });
}); });

View File

@@ -1,144 +0,0 @@
const component = () => {
return {
subscriptions: [],
plugins: [],
pid: undefined,
subscription: undefined, // selected subscription
loading: false,
init() {
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
let pid = localStorage.getItem('plugin');
if (!pid || !this.plugins.find((p) => p.id === pid)) {
pid = this.plugins[0].id;
}
this.pid = pid;
this.list(pid);
})
.catch((e) => {
alert('danger', `Failed to list the available plugins. Error: ${e}`);
});
},
pluginChanged() {
localStorage.setItem('plugin', this.pid);
this.list(this.pid);
},
list(pid) {
if (!pid) return;
fetch(
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams({
plugin: pid,
})}`,
{
method: 'GET',
},
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.subscriptions = data.subscriptions;
})
.catch((e) => {
alert('danger', `Failed to list subscriptions. Error: ${e}`);
});
},
renderStrCell(str) {
const maxLength = 40;
if (str.length > maxLength)
return `<td><span>${str.substring(
0,
maxLength,
)}...</span><div uk-dropdown>${str}</div></td>`;
return `<td>${str}</td>`;
},
renderDateCell(timestamp) {
return `<td>${moment
.duration(moment.unix(timestamp).diff(moment()))
.humanize(true)}</td>`;
},
selected(event, modal) {
const id = event.currentTarget.getAttribute('sid');
this.subscription = this.subscriptions.find((s) => s.id === id);
UIkit.modal(modal).show();
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case 'number-min':
type = 'number (minimum value)';
break;
case 'number-max':
type = 'number (maximum value)';
break;
case 'date-min':
type = 'minimum date';
break;
case 'date-max':
type = 'maximum date';
break;
}
let value = ft.value;
if (ft.type.startsWith('number') && isNaN(value)) value = '';
else if (ft.type.startsWith('date') && value)
value = moment(Number(value)).format('MMM D, YYYY');
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
actionHandler(event, type) {
const id = $(event.currentTarget).closest('tr').attr('sid');
if (type !== 'delete') return this.action(id, type);
UIkit.modal
.confirm(
'Are you sure you want to delete the subscription? This cannot be undone.',
{
labels: {
ok: 'Yes, delete it',
cancel: 'Cancel',
},
},
)
.then(() => {
this.action(id, type);
});
},
action(id, type) {
if (this.loading) return;
this.loading = true;
fetch(
`${base_url}api/admin/plugin/subscriptions${
type === 'update' ? '/update' : ''
}?${new URLSearchParams({
plugin: this.pid,
subscription: id,
})}`,
{
method: type === 'delete' ? 'DELETE' : 'POST',
},
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
if (type === 'update')
alert(
'success',
`Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`,
);
})
.catch((e) => {
alert('danger', `Failed to ${type} subscription. Error: ${e}`);
})
.finally(() => {
this.loading = false;
this.list(this.pid);
});
},
};
};

View File

@@ -1,112 +1,82 @@
const component = () => { const component = () => {
return { return {
available: undefined, available: undefined,
subscriptions: [], subscriptions: [],
init() { init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`) $.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => { .done((data) => {
if (data.error) { if (data.error) {
alert( alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
'danger', return;
'Failed to check MangaDex integration status. Error: ' + }
data.error, this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
);
return;
}
this.available = Boolean(
data.expires && data.expires > Math.floor(Date.now() / 1000),
);
if (this.available) this.getSubscriptions(); if (this.available) this.getSubscriptions();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'danger', })
`Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`, },
);
});
},
getSubscriptions() { getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`) $.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
'danger', return;
'Failed to get subscriptions. Error: ' + data.error, }
); this.subscriptions = data.subscriptions;
return; })
} .fail((jqXHR, status) => {
this.subscriptions = data.subscriptions; alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { },
alert(
'danger',
`Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
rm(event) { rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id'); const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`, url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json', contentType: 'application/json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to delete subscription. Error: ${data.error}`);
'danger', }
`Failed to delete subscription. Error: ${data.error}`, this.getSubscriptions();
); })
} .fail((jqXHR, status) => {
this.getSubscriptions(); alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) });
.fail((jqXHR, status) => { },
alert(
'danger',
`Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
check(event) { check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id'); const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`, url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json', contentType: 'application/json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to check subscription. Error: ${data.error}`);
'danger', return;
`Failed to check subscription. Error: ${data.error}`, }
); alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
return; })
} .fail((jqXHR, status) => {
alert( alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'success', });
'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.', },
);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
formatRange(min, max) { formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `${min}`; if (!isNaN(min) && isNaN(max)) return `${min}`;
if (isNaN(min) && !isNaN(max)) return `${max}`; if (isNaN(min) && !isNaN(max)) return `${max}`;
if (isNaN(min) && isNaN(max)) return 'All'; if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`; if (min === max) return `= ${min}`;
return `${min} - ${max}`; return `${min} - ${max}`;
}, }
}; };
}; };

View File

@@ -1,421 +1,336 @@
$(() => { $(() => {
setupAcard(); setupAcard();
}); });
const setupAcard = () => { const setupAcard = () => {
$('.acard.is_entry').click((e) => { $('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return; if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard'); const card = $(e.target).closest('.acard');
showModal( showModal(
$(card).attr('data-encoded-path'), $(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')), parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')), parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'), $(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'), $(card).attr('data-encoded-title'),
$(card).attr('data-book-id'), $(card).attr('data-book-id'),
$(card).attr('data-id'), $(card).attr('data-id')
); );
}); });
}; };
function showModal( function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
encodedPath, const zipPath = decodeURIComponent(encodedPath);
pages, const title = decodeURIComponent(encodedeTitle);
percentage, const entry = decodeURIComponent(encodedEntryTitle);
encodedeTitle, $('#modal button, #modal a').each(function() {
encodedEntryTitle, $(this).removeAttr('hidden');
titleID, });
entryID, if (percentage === 0) {
) { $('#continue-btn').attr('hidden', '');
const zipPath = decodeURIComponent(encodedPath); $('#unread-btn').attr('hidden', '');
const title = decodeURIComponent(encodedeTitle); } else if (percentage === 100) {
const entry = decodeURIComponent(encodedEntryTitle); $('#read-btn').attr('hidden', '');
$('#modal button, #modal a').each(function () { $('#continue-btn').attr('hidden', '');
$(this).removeAttr('hidden'); } else {
}); $('#continue-btn').text('Continue from ' + percentage + '%');
if (percentage === 0) { }
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
$('#modal-entry-title').find('span').text(entry); $('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID); $('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID); $('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry); $('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`); $('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`); $('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function () { $('#read-btn').click(function() {
updateProgress(titleID, entryID, pages); updateProgress(titleID, entryID, pages);
}); });
$('#unread-btn').click(function () { $('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr( $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
'href',
`${base_url}api/download/${titleID}/${entryID}`,
);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
} }
UIkit.util.on(document, 'hidden', '#modal', () => {
$('#read-btn').off('click');
$('#unread-btn').off('click');
});
const updateProgress = (tid, eid, page) => { const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`; let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({ const query = $.param({
eid, eid: eid
}); });
if (eid) url += `?${query}`; if (eid)
url += `?${query}`;
$.ajax({ $.ajax({
method: 'PUT', method: 'PUT',
url, url: url,
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
error = data.error; error = data.error;
alert('danger', error); alert('danger', error);
} }
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
const renameSubmit = (name, eid) => { const renameSubmit = (name, eid) => {
const upload = $('.upload-field'); const upload = $('.upload-field');
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
if (name.length === 0) { console.log(name);
alert('danger', 'The display name should not be empty');
return;
}
const query = $.param({ if (name.length === 0) {
eid, alert('danger', 'The display name should not be empty');
}); return;
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; }
if (eid) url += `?${query}`;
$.ajax({ const query = $.param({
type: 'PUT', eid: eid
url, });
contentType: 'application/json', let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
dataType: 'json', if (eid)
}) url += `?${query}`;
.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 renameSortNameSubmit = (name, eid) => { $.ajax({
const upload = $('.upload-field'); type: 'PUT',
const titleId = upload.attr('data-title-id'); url: url,
contentType: "application/json",
const params = {}; dataType: 'json'
if (eid) params.eid = eid; })
if (name) params.name = name; .done(data => {
const query = $.param(params); if (data.error) {
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`; alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
$.ajax({ }
type: 'PUT', location.reload();
url, })
contentType: 'application/json', .fail((jqXHR, status) => {
dataType: 'json', alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) });
.done((data) => {
if (data.error) {
alert('danger', `Failed to update sort title. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
}; };
const edit = (eid) => { const edit = (eid) => {
const cover = $('#edit-modal #cover'); const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover'); let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text(); let displayName = $('h2.uk-title > span').text();
let fileTitle = $('h2.uk-title').attr('data-file-title');
let sortTitle = $('h2.uk-title').attr('data-sort-title');
if (eid) { if (eid) {
const item = $(`#${eid}`); const item = $(`#${eid}`);
url = item.find('img').attr('data-src'); url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title'); displayName = item.find('.uk-card-title').attr('data-title');
fileTitle = item.find('.uk-card-title').attr('data-file-title'); $('#title-progress-control').attr('hidden', '');
sortTitle = item.find('.uk-card-title').attr('data-sort-title'); } else {
$('#title-progress-control').attr('hidden', ''); $('#title-progress-control').removeAttr('hidden');
} else { }
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url); cover.attr('data-src', url);
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName); displayNameField.attr('value', displayName);
displayNameField.attr('placeholder', fileTitle); console.log(displayNameField);
displayNameField.keyup((event) => { displayNameField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSubmit(displayNameField.val() || fileTitle, eid); renameSubmit(displayNameField.val(), eid);
} }
}); });
displayNameField.siblings('a.uk-form-icon').click(() => { displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val() || fileTitle, eid); renameSubmit(displayNameField.val(), eid);
}); });
const sortTitleField = $('#sort-title-field'); setupUpload(eid);
sortTitleField.val(sortTitle);
sortTitleField.attr('placeholder', fileTitle);
sortTitleField.keyup((event) => {
if (event.keyCode === 13) {
renameSortNameSubmit(sortTitleField.val(), eid);
}
});
sortTitleField.siblings('a.uk-form-icon').click(() => {
renameSortNameSubmit(sortTitleField.val(), eid);
});
setupUpload(eid); UIkit.modal($('#edit-modal')).show();
UIkit.modal($('#edit-modal')).show();
}; };
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
const displayNameField = $('#display-name-field');
displayNameField.off('keyup');
displayNameField.off('click');
const sortTitleField = $('#sort-title-field');
sortTitleField.off('keyup');
sortTitleField.off('click');
});
const setupUpload = (eid) => { const setupUpload = (eid) => {
const upload = $('.upload-field'); const upload = $('.upload-field');
const bar = $('#upload-progress').get(0); const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = { const queryObj = {
tid: titleId, tid: titleId
}; };
if (eid) queryObj['eid'] = eid; if (eid)
const query = $.param(queryObj); queryObj['eid'] = eid;
const url = `${base_url}api/admin/upload/cover?${query}`; const query = $.param(queryObj);
UIkit.upload('.upload-field', { const url = `${base_url}api/admin/upload/cover?${query}`;
url, console.log(url);
name: 'file', UIkit.upload('.upload-field', {
error: (e) => { url: url,
alert('danger', `Failed to upload cover image: ${e.toString()}`); name: 'file',
}, error: (e) => {
loadStart: (e) => { alert('danger', `Failed to upload cover image: ${e.toString()}`);
$(bar).removeAttr('hidden'); },
bar.max = e.total; loadStart: (e) => {
bar.value = e.loaded; $(bar).removeAttr('hidden');
}, bar.max = e.total;
progress: (e) => { bar.value = e.loaded;
bar.max = e.total; },
bar.value = e.loaded; progress: (e) => {
}, bar.max = e.total;
loadEnd: (e) => { bar.value = e.loaded;
bar.max = e.total; },
bar.value = e.loaded; loadEnd: (e) => {
}, bar.max = e.total;
completeAll: () => { bar.value = e.loaded;
$(bar).attr('hidden', ''); },
location.reload(); completeAll: () => {
}, $(bar).attr('hidden', '');
}); location.reload();
}
});
}; };
const deselectAll = () => { const deselectAll = () => {
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
data['selected'] = false; data['selected'] = false;
}); });
$('#select-bar')[0].__x.$data['count'] = 0; $('#select-bar')[0].__x.$data['count'] = 0;
}; };
const selectAll = () => { const selectAll = () => {
let count = 0; let count = 0;
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
if (!data['disabled']) { if (!data['disabled']) {
data['selected'] = true; data['selected'] = true;
count++; count++;
} }
}); });
$('#select-bar')[0].__x.$data['count'] = count; $('#select-bar')[0].__x.$data['count'] = count;
}; };
const selectedIDs = () => { const selectedIDs = () => {
const ary = []; const ary = [];
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
if (!data['disabled'] && data['selected']) { if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item'); const item = $(e).closest('.item');
ary.push($(item).attr('id')); ary.push($(item).attr('id'));
} }
}); });
return ary; return ary;
}; };
const bulkProgress = (action, el) => { const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id'); const tid = $(el).attr('data-id');
const ids = selectedIDs(); const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`; const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({ $.ajax({
type: 'PUT', type: 'PUT',
url, url: url,
contentType: 'application/json', contentType: "application/json",
dataType: 'json', dataType: 'json',
data: JSON.stringify({ data: JSON.stringify({
ids, ids: ids
}), })
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
'danger', return;
`Failed to mark entries as ${action}. Error: ${data.error}`, }
); location.reload();
return; })
} .fail((jqXHR, status) => {
location.reload(); alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { .always(() => {
alert( deselectAll();
'danger', });
`Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
})
.always(() => {
deselectAll();
});
}; };
const tagsComponent = () => { const tagsComponent = () => {
return { return {
isAdmin: false, isAdmin: false,
tags: [], tags: [],
tid: $('.upload-field').attr('data-title-id'), tid: $('.upload-field').attr('data-title-id'),
loading: true, loading: true,
load(admin) { load(admin) {
this.isAdmin = admin; this.isAdmin = admin;
$('.tag-select').select2({ $('.tag-select').select2({
tags: true, tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found', placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin, disabled: !this.isAdmin,
templateSelection(state) { templateSelection(state) {
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute( a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
'href', a.setAttribute('class', 'uk-link-reset');
`${base_url}tags/${encodeURIComponent(state.text)}`, a.onclick = event => {
); event.stopPropagation();
a.setAttribute('class', 'uk-link-reset'); };
a.onclick = (event) => { a.innerText = state.text;
event.stopPropagation(); return a;
}; }
a.innerText = state.text; });
return a;
},
});
this.request(`${base_url}api/tags`, 'GET', (data) => { this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags; const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`; const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => { this.request(url, 'GET', data => {
this.tags = data.tags; this.tags = data.tags;
allTags.forEach((t) => { allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0); const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op); $('.tag-select').append(op);
}); });
$('.tag-select').on('select2:select', (e) => { $('.tag-select').on('select2:select', e => {
this.onAdd(e); this.onAdd(e);
}); });
$('.tag-select').on('select2:unselect', (e) => { $('.tag-select').on('select2:unselect', e => {
this.onDelete(e); this.onDelete(e);
}); });
$('.tag-select').on('change', () => { $('.tag-select').on('change', () => {
this.onChange(); this.onChange();
}); });
$('.tag-select').trigger('change'); $('.tag-select').trigger('change');
this.loading = false; this.loading = false;
}); });
}); });
}, },
onChange() { onChange() {
this.tags = $('.tag-select') this.tags = $('.tag-select').select2('data').map(o => o.text);
.select2('data') },
.map((o) => o.text); onAdd(event) {
}, const tag = event.params.data.text;
onAdd(event) { const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
const tag = event.params.data.text; this.request(url, 'PUT');
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent( },
tag, onDelete(event) {
)}`; const tag = event.params.data.text;
this.request(url, 'PUT'); const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
}, this.request(url, 'DELETE');
onDelete(event) { },
const tag = event.params.data.text; request(url, method, cb) {
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent( $.ajax({
tag, url: url,
)}`; method: method,
this.request(url, 'DELETE'); dataType: 'json'
}, })
request(url, method, cb) { .done(data => {
$.ajax({ if (data.success) {
url, if (cb) cb(data);
method, } else {
dataType: 'json', alert('danger', data.error);
}) }
.done((data) => { })
if (data.success) { .fail((jqXHR, status) => {
if (cb) cb(data); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
} else { });
alert('danger', data.error); }
} };
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
};
}; };

View File

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

View File

@@ -1,17 +1,16 @@
const remove = (username) => { const remove = (username) => {
$.ajax({ $.ajax({
url: `${base_url}api/admin/user/delete/${username}`, url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE', type: 'DELETE',
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.success) location.reload(); if (data.success)
else alert('danger', data.error); location.reload();
}) else
.fail((jqXHR, status) => { alert('danger', data.error);
alert( })
'danger', .fail((jqXHR, status) => {
`Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`, alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
); });
});
}; };

View File

@@ -1,23 +0,0 @@
{
"name": "Mango",
"description": "Mango: A self-hosted manga server and web reader",
"icons": [
{
"src": "/img/icons/icon_x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/img/icons/icon_x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/img/icons/icon_x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"display": "fullscreen",
"start_url": "/"
}

View File

@@ -2,31 +2,31 @@ version: 2.0
shards: shards:
ameba: ameba:
git: https://github.com/crystal-ameba/ameba.git git: https://github.com/crystal-ameba/ameba.git
version: 0.14.3 version: 0.14.0
archive: archive:
git: https://github.com/hkalexling/archive.cr.git git: https://github.com/hkalexling/archive.cr.git
version: 0.5.0 version: 0.4.0
baked_file_system: baked_file_system:
git: https://github.com/schovi/baked_file_system.git git: https://github.com/schovi/baked_file_system.git
version: 0.10.0 version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
clim: clim:
git: https://github.com/at-grandpa/clim.git git: https://github.com/at-grandpa/clim.git
version: 0.17.1 version: 0.12.0
db: db:
git: https://github.com/crystal-lang/crystal-db.git git: https://github.com/crystal-lang/crystal-db.git
version: 0.10.1 version: 0.9.0
duktape: duktape:
git: https://github.com/jessedoyle/duktape.cr.git git: https://github.com/jessedoyle/duktape.cr.git
version: 1.0.0 version: 0.20.0
exception_page: exception_page:
git: https://github.com/crystal-loot/exception_page.git git: https://github.com/crystal-loot/exception_page.git
version: 0.1.5 version: 0.1.4
http_proxy: http_proxy:
git: https://github.com/mamantoha/http_proxy.git git: https://github.com/mamantoha/http_proxy.git
@@ -34,49 +34,49 @@ shards:
image_size: image_size:
git: https://github.com/hkalexling/image_size.cr.git git: https://github.com/hkalexling/image_size.cr.git
version: 0.5.0 version: 0.4.0
kemal: kemal:
git: https://github.com/kemalcr/kemal.git git: https://github.com/kemalcr/kemal.git
version: 1.0.0 version: 0.27.0
kemal-session: kemal-session:
git: https://github.com/kemalcr/kemal-session.git git: https://github.com/kemalcr/kemal-session.git
version: 1.0.0 version: 0.13.0
kilt: kilt:
git: https://github.com/jeromegn/kilt.git git: https://github.com/jeromegn/kilt.git
version: 0.4.1 version: 0.4.0
koa: koa:
git: https://github.com/hkalexling/koa.git git: https://github.com/hkalexling/koa.git
version: 0.9.0 version: 0.7.0
mangadex:
git: https://github.com/hkalexling/mangadex.git
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
mg: mg:
git: https://github.com/hkalexling/mg.git git: https://github.com/hkalexling/mg.git
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264 version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
myhtml: myhtml:
git: https://github.com/kostya/myhtml.git git: https://github.com/kostya/myhtml.git
version: 1.5.8 version: 1.5.1
open_api: open_api:
git: https://github.com/hkalexling/open_api.cr.git git: https://github.com/jreinert/open_api.cr.git
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c
radix: radix:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git
version: 0.4.1 version: 0.3.9
sanitize:
git: https://github.com/hkalexling/sanitize.git
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0 version: 0.16.0
tallboy: tallboy:
git: https://github.com/epoch/tallboy.git git: https://github.com/epoch/tallboy.git
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6 version: 0.9.3

View File

@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.27.0 version: 0.22.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: 1.0.0 crystal: 0.36.1
license: MIT license: MIT
@@ -21,6 +21,7 @@ dependencies:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive: archive:
github: hkalexling/archive.cr github: hkalexling/archive.cr
ameba: ameba:
@@ -29,6 +30,7 @@ dependencies:
github: at-grandpa/clim github: at-grandpa/clim
duktape: duktape:
github: jessedoyle/duktape.cr github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml: myhtml:
github: kostya/myhtml github: kostya/myhtml
http_proxy: http_proxy:
@@ -39,8 +41,7 @@ dependencies:
github: hkalexling/koa github: hkalexling/koa
tallboy: tallboy:
github: epoch/tallboy github: epoch/tallboy
branch: master
mg: mg:
github: hkalexling/mg github: hkalexling/mg
sanitize: mangadex:
github: hkalexling/sanitize github: hkalexling/mangadex

View File

@@ -1,6 +0,0 @@
{
"id": "test",
"title": "Test Plugin",
"placeholder": "placeholder",
"wait_seconds": 1
}

View File

@@ -1,31 +1,14 @@
require "./spec_helper" require "./spec_helper"
describe Config do describe Config do
it "creates default config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |config, path| with_default_config do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
config.port.should eq 9000
end end
end end
it "correctly loads config" do it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml" config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000 config.port.should eq 3000
config.base_url.should eq "/"
end
it "correctly reads config defaults from ENV" do
ENV["LOG_LEVEL"] = "debug"
config = Config.load "spec/asset/test-config.yml"
config.log_level.should eq "debug"
config.base_url.should eq "/"
end
it "correctly handles ENV truthiness" do
ENV["CACHE_ENABLED"] = "false"
config = Config.load "spec/asset/test-config.yml"
config.cache_enabled.should be_false
config.cache_log_enabled.should be_true
config.disable_login.should be_false
end end
end end

View File

@@ -1,70 +0,0 @@
require "./spec_helper"
describe Plugin do
describe "helper functions" do
it "mango.text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<a href="https://github.com">Click Me<a>');
JS
res.should eq "Click Me"
end
end
it "mango.text returns empty string when no text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<img src="https://github.com" />');
JS
res.should eq ""
end
end
it "mango.css" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
JS
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
end
end
it "mango.css returns empty array when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
JS
res.should eq [] of String
end
end
it "mango.attribute" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
JS
res.should eq "https://github.com"
end
end
it "mango.attribute returns undefined when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div />', 'href') === undefined;
JS
res.should be_true
end
end
# https://github.com/hkalexling/Mango/issues/320
it "mango.attribute handles tags in attribute values" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
JS
res.should eq "test"
end
end
end
end

View File

@@ -3,7 +3,6 @@ require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber" require "../src/main_fiber"
require "../src/plugin/plugin"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@@ -55,10 +54,3 @@ def with_storage
end end
end end
end end
def with_plugin
with_default_config do
plugin = Plugin.new "test", "spec/asset/plugins"
yield plugin
end
end

View File

@@ -61,13 +61,3 @@ describe "chapter_sort" do
end.should eq ary end.should eq ary
end end
end end
describe "sanitize_filename" do
it "returns a random string for empty sanitized string" do
sanitize_filename("..").should_not eq sanitize_filename("..")
end
it "sanitizes correctly" do
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
.should eq "マンゴー_()[1_2] 3.14 hello world"
end
end

View File

@@ -1,51 +1,41 @@
require "yaml" require "yaml"
class Config class Config
private OPTIONS = {
"host" => "0.0.0.0",
"port" => 9000,
"base_url" => "/",
"session_secret" => "mango-session-secret",
"library_path" => "~/mango/library",
"library_cache_path" => "~/mango/library.yml.gz",
"db_path" => "~/mango/mango.db",
"queue_db_path" => "~/mango/queue.db",
"scan_interval_minutes" => 5,
"thumbnail_generation_interval_hours" => 24,
"log_level" => "info",
"upload_path" => "~/mango/uploads",
"plugin_path" => "~/mango/plugins",
"download_timeout_seconds" => 30,
"cache_enabled" => true,
"cache_size_mbs" => 50,
"cache_log_enabled" => true,
"disable_login" => false,
"default_username" => "",
"auth_proxy_header_name" => "",
"plugin_update_interval_hours" => 24,
}
include YAML::Serializable include YAML::Serializable
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
property path : String = "" property path : String = ""
property host : String = "0.0.0.0"
property port : Int32 = 9000
property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library",
home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true
property scan_interval_minutes : Int32 = 5
property thumbnail_generation_interval_hours : Int32 = 24
property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
property plugin_path : String = File.expand_path "~/mango/plugins",
home: true
property download_timeout_seconds : Int32 = 30
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property mangadex = Hash(String, String | Int32).new
# Go through the options constant above and define them as properties. @[YAML::Field(ignore: true)]
# Allow setting the default values through environment variables. @mangadex_defaults = {
# Overall precedence: config file > environment variable > default value "base_url" => "https://mangadex.org",
{% begin %} "api_url" => "https://api.mangadex.org/v2",
{% for k, v in OPTIONS %} "download_wait_seconds" => 5,
{% if v.is_a? StringLiteral %} "download_retries" => 4,
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }} "download_queue_db_path" => File.expand_path("~/mango/queue.db",
{% elsif v.is_a? NumberLiteral %} home: true),
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i "chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
{% elsif v.is_a? BoolLiteral %} "manga_rename_rule" => "{title}",
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }} }
{% else %}
raise "Unknown type in config option: {{ v.class_name.id }}"
{% end %}
{% end %}
{% end %}
@@singlet : Config? @@singlet : Config?
@@ -58,12 +48,12 @@ class Config
end end
def self.load(path : String?) def self.load(path : String?)
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.path = path config.path = path
config.expand_paths config.fill_defaults
config.preprocess config.preprocess
return config return config
end end
@@ -71,7 +61,7 @@ class Config
"Dumping the default config there." "Dumping the default config there."
default = self.allocate default = self.allocate
default.path = path default.path = path
default.expand_paths default.fill_defaults
cfg_dir = File.dirname cfg_path cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir Dir.mkdir_p cfg_dir
@@ -81,9 +71,13 @@ class Config
default default
end end
def expand_paths def fill_defaults
{% for p in %w(library library_cache db queue_db upload plugin) %} {% for hash_name in ["mangadex"] %}
@{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true @{{hash_name.id}}_defaults.map do |k, v|
if @{{hash_name.id}}[k]?.nil?
@{{hash_name.id}}[k] = v
end
end
{% end %} {% end %}
end end
@@ -98,5 +92,24 @@ class Config
raise "Login is disabled, but default username is not set. " \ raise "Login is disabled, but default username is not set. " \
"Please set a default username" "Please set a default username"
end end
# `Logger.default` is not available yet
Log.setup :debug
unless mangadex["api_url"] =~ /\/v2/
Log.warn { "It looks like you are using the deprecated MangaDex API " \
"v1 in your config file. Please update it to " \
"https://api.mangadex.org/v2 to suppress this warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
if mangadex["api_url"] =~ /\/api\/v2/
Log.warn { "It looks like you are using the outdated MangaDex API " \
"url (mangadex.org/api/v2) in your config file. Please " \
"update it to https://api.mangadex.org/v2 to suppress this " \
"warning." }
mangadex["api_url"] = "https://api.mangadex.org/v2"
end
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
end end
end end

View File

@@ -6,7 +6,6 @@ class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub # Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic" BASIC = "Basic"
BEARER = "Bearer"
AUTH = "Authorization" AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \ AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials" "You have to login with proper credentials"
@@ -19,14 +18,8 @@ class AuthHandler < Kemal::Handler
end end
def require_auth(env) def require_auth(env)
if request_path_startswith env, ["/api"] env.session.string "callback", env.request.path
# Do not redirect API requests redirect env, "/login"
env.response.status_code = 401
send_text env, "Unauthorized"
else
env.session.string "callback", env.request.path
redirect env, "/login"
end
end end
def validate_token(env) def validate_token(env)
@@ -42,18 +35,13 @@ class AuthHandler < Kemal::Handler
def validate_auth_header(env) def validate_auth_header(env)
if env.request.headers[AUTH]? if env.request.headers[AUTH]?
if value = env.request.headers[AUTH] if value = env.request.headers[AUTH]
if value.starts_with? BASIC if value.size > 0 && value.starts_with?(BASIC)
token = verify_user value token = verify_user value
return false if token.nil? return false if token.nil?
env.session.string "token", token env.session.string "token", token
return true return true
end end
if value.starts_with? BEARER
session_id = value.split(" ")[1]
token = Kemal::Session.get(session_id).try &.string? "token"
return !token.nil? && Storage.default.verify_token token
end
end end
end end
false false
@@ -66,20 +54,15 @@ class AuthHandler < Kemal::Handler
end end
def call(env) def call(env)
# OPTIONS requests do not require authentication # Skip all authentication if requesting /login, /logout, or a static file
if env.request.method === "OPTIONS" if request_path_startswith(env, ["/login", "/logout"]) ||
return call_next(env)
end
# Skip all authentication if requesting /login, /logout, /api/login,
# or a static file
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
requesting_static_file env requesting_static_file env
return call_next(env) return call_next(env)
end end
# Check user is logged in # Check user is logged in
if validate_token(env) || validate_auth_header(env) if validate_token env
# Skip if the request has a valid token (either from cookies or header) # Skip if the request has a valid token
elsif Config.current.disable_login elsif Config.current.disable_login
# Check default username if login is disabled # Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username unless Storage.default.username_exists Config.current.default_username

View File

@@ -1,8 +0,0 @@
class CORSHandler < Kemal::Handler
def call(env)
if request_path_startswith env, ["/api"]
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
call_next env
end
end

View File

@@ -1,111 +0,0 @@
require "yaml"
require "./entry"
class ArchiveEntry < Entry
include YAML::Serializable
getter zip_path : String
def initialize(@zip_path, @book)
storage = Storage.default
@path = @zip_path
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def examine : Bool
File.exists? @zip_path
end
def self.is_valid?(path : String) : Bool
is_supported_file path
end
end

View File

@@ -1,218 +0,0 @@
require "digest"
require "./entry"
require "./title"
require "./types"
# Base class for an entry in the LRU cache.
# There are two ways to use it:
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
# same type. That is, the input value will be stored as it is without
# any transformation.
# 2. You can also subclass it and provide custom implementations for
# `to_save_t` and `to_return_t`. This allows you to transform and store
# the input value to a different type. See `SortedEntriesCacheEntry` as
# an example.
private class CacheEntry(SaveT, ReturnT)
getter key : String, atime : Time
@value : SaveT
def initialize(@key : String, value : ReturnT)
@atime = @ctime = Time.utc
@value = self.class.to_save_t value
end
def value
@atime = Time.utc
self.class.to_return_t @value
end
def self.to_save_t(value : ReturnT)
value
end
def self.to_return_t(value : SaveT)
value
end
def instance_size
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.instance_size
end
end
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
def self.to_save_t(value : Array(Entry))
value.map &.id
end
def self.to_return_t(value : Array(String))
ids_to_entries value
end
private def self.ids_to_entries(ids : Array(String))
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
entries = [] of Entry
begin
ids.each do |id|
entries << e_map[id]
end
return entries if ids.size == entries.size
rescue
end
end
def instance_size
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.size * (instance_sizeof(String) + sizeof(String)) +
@value.sum(&.bytesize) # elements in Array(String)
end
def self.gen_key(book_id : String, username : String,
entries : Array(Entry), opt : SortOptions?)
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_entries"
end
end
class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
def self.to_save_t(value : Array(Title))
value.map &.id
end
def self.to_return_t(value : Array(String))
value.map { |title_id| Library.default.title_hash[title_id].not_nil! }
end
def instance_size
instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
@value.size * (instance_sizeof(String) + sizeof(String)) +
@value.sum(&.bytesize) # elements in Array(String)
end
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_titles"
end
end
class String
def instance_size
instance_sizeof(String) + bytesize
end
end
struct Tuple(*T)
def instance_size
sizeof(T) + # total size of non-reference types
self.sum do |e|
next 0 unless e.is_a? Reference
if e.responds_to? :instance_size
e.instance_size
else
instance_sizeof(typeof(e))
end
end
end
end
alias CacheableType = Array(Entry) | Array(Title) | String |
Tuple(String, Int32)
alias CacheEntryType = SortedEntriesCacheEntry |
SortedTitlesCacheEntry |
CacheEntry(String, String) |
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
def generate_cache_entry(key : String, value : CacheableType)
if value.is_a? Array(Entry)
SortedEntriesCacheEntry.new key, value
elsif value.is_a? Array(Title)
SortedTitlesCacheEntry.new key, value
else
CacheEntry(typeof(value), typeof(value)).new key, value
end
end
# LRU Cache
class LRUCache
@@limit : Int128 = Int128.new 0
@@should_log = true
# key => entry
@@cache = {} of String => CacheEntryType
def self.enabled
Config.current.cache_enabled
end
def self.init
cache_size = Config.current.cache_size_mbs
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
@@should_log = Config.current.cache_log_enabled
end
def self.get(key : String)
return unless enabled
entry = @@cache[key]?
if @@should_log
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
end
return entry.value unless entry.nil?
end
def self.set(cache_entry : CacheEntryType)
return unless enabled
key = cache_entry.key
@@cache[key] = cache_entry
Logger.debug "LRUCache cached #{key}" if @@should_log
remove_least_recent_access
end
def self.invalidate(key : String)
return unless enabled
@@cache.delete key
end
def self.print
return unless @@should_log
sum = @@cache.sum { |_, entry| entry.instance_size }
Logger.debug "---- LRU Cache ----"
Logger.debug "Size: #{sum} Bytes"
Logger.debug "List:"
@@cache.each do |k, v|
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
end
Logger.debug "-------------------"
end
private def self.is_cache_full
sum = @@cache.sum { |_, entry| entry.instance_size }
sum > @@limit
end
private def self.remove_least_recent_access
if @@should_log && is_cache_full
Logger.debug "Removing entries from LRUCache"
end
while is_cache_full && @@cache.size > 0
min_tuple = @@cache.min_by { |_, entry| entry.atime }
min_key = min_tuple[0]
min_entry = min_tuple[1]
Logger.debug " \
Target: #{min_key}, \
Last Access Time: #{min_entry.atime}" if @@should_log
invalidate min_key
end
end
end

View File

@@ -1,132 +0,0 @@
require "yaml"
require "./entry"
class DirEntry < Entry
include YAML::Serializable
getter dir_path : String
@[YAML::Field(ignore: true)]
@sorted_files : Array(String)?
@signature : String
def initialize(@dir_path, @book)
storage = Storage.default
@path = @dir_path
@encoded_path = URI.encode @dir_path
@title = File.basename @dir_path
@encoded_title = URI.encode @title
unless File.readable? @dir_path
@err_msg = "Directory #{@dir_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
unless DirEntry.is_valid? @dir_path
@err_msg = "Directory #{@dir_path} is not valid directory entry."
Logger.warn "#{@err_msg} Please make sure the " \
"directory has valid images."
return
end
size_sum = 0
sorted_files.each do |file_path|
size_sum += File.size file_path
end
@size = size_sum.humanize_bytes
@signature = Dir.directory_entry_signature @dir_path
id = storage.get_entry_id @dir_path, @signature
if id.nil?
id = random_str
storage.insert_entry_id({
path: @dir_path,
id: id,
signature: @signature,
})
end
@id = id
@mtime = sorted_files.map do |file_path|
File.info(file_path).modification_time
end.max
@pages = sorted_files.size
end
def read_page(page_num)
img = nil
begin
files = sorted_files
file_path = files[page_num - 1]
data = File.read(file_path).to_slice
if data
img = Image.new data, MIME.from_filename(file_path),
File.basename(file_path), data.size
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_files.each_with_index do |path, i|
data = File.read(path).to_slice
begin
data.not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
sizes
end
def examine : Bool
existence = File.exists? @dir_path
return false unless existence
files = DirEntry.image_files @dir_path
signature = Dir.directory_entry_signature @dir_path
existence = files.size > 0 && @signature == signature
@sorted_files = nil unless existence
# For more efficient, update a directory entry with new property
# and return true like Title.examine
existence
end
def sorted_files
cached_sorted_files = @sorted_files
return cached_sorted_files if cached_sorted_files
@sorted_files = DirEntry.sorted_image_files @dir_path
@sorted_files.not_nil!
end
def self.image_files(dir_path)
Dir.entries(dir_path)
.reject(&.starts_with? ".")
.map { |fn| File.join dir_path, fn }
.select { |fn| is_supported_image_file fn }
.reject { |fn| File.directory? fn }
.select { |fn| File.readable? fn }
end
def self.sorted_image_files(dir_path)
self.image_files(dir_path)
.sort { |a, b| compare_numerically a, b }
end
def self.is_valid?(path : String) : Bool
image_files(path).size > 0
end
end

View File

@@ -1,98 +1,75 @@
require "image_size" require "image_size"
private def node_has_key(node : YAML::Nodes::Mapping, key : String) class Entry
node.nodes getter zip_path : String, book : Title, title : String,
.map_with_index { |n, i| {n, i} } size : String, pages : Int32, id : String, encoded_path : String,
.select(&.[1].even?) encoded_title : String, mtime : Time, err_msg : String?
.map(&.[0])
.select(YAML::Nodes::Scalar)
.map(&.as(YAML::Nodes::Scalar).value)
.includes? key
end
abstract class Entry def initialize(@zip_path, @book)
getter id : String, book : Title, title : String, path : String, storage = Storage.default
size : String, pages : Int32, mtime : Time, @encoded_path = URI.encode @zip_path
encoded_path : String, encoded_title : String, err_msg : String? @title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
def initialize( unless File.readable? @zip_path
@id, @title, @book, @path, @err_msg = "File #{@zip_path} is not readable."
@size, @pages, @mtime, Logger.warn "#{@err_msg} Please make sure the " \
@encoded_path, @encoded_title, @err_msg "file permission is configured correctly."
) return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end end
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) def to_slim_json : String
unless node.is_a? YAML::Nodes::Mapping
raise "Unexpected node type in YAML"
end
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
# instead we are using a more hacky approach (see `node_has_key`).
# TODO: Use a more elegant approach
if node_has_key node, "zip_path"
ArchiveEntry.new ctx, node
elsif node_has_key node, "dir_path"
DirEntry.new ctx, node
else
raise "Unknown entry found in YAML cache. Try deleting the " \
"`library.yml.gz` file"
end
end
def build_json(*, slim = false)
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for str in %w(path title size id) %} {% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, {{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
if err_msg
json.field "err_msg", err_msg
end
json.field "zip_path", path # for API backward compatability
json.field "path", path
json.field "title_id", @book.id json.field "title_id", @book.id
json.field "title_title", @book.title json.field "title_title", @book.title
json.field "sort_title", sort_title
json.field "pages" { json.number @pages } json.field "pages" { json.number @pages }
unless slim
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
end
end end
end end
end end
@[YAML::Field(ignore: true)] def to_json(json : JSON::Builder)
@sort_title : String? json.object do
{% for str in ["zip_path", "title", "size", "id"] %}
def sort_title json.field {{str}}, @{{str.id}}
sort_title_cached = @sort_title {% end %}
return sort_title_cached if sort_title_cached json.field "title_id", @book.id
sort_title = @book.entry_sort_title_db id json.field "display_name", @book.display_name @title
if sort_title json.field "cover_url", cover_url
@sort_title = sort_title json.field "pages" { json.number @pages }
return sort_title json.field "mtime" { json.number @mtime.to_unix }
end end
@sort_title = @title
@title
end
def set_sort_title(sort_title : String | Nil, username : String)
Storage.default.set_entry_sort_title id, sort_title
if sort_title == "" || sort_title.nil?
@sort_title = nil
else
@sort_title = sort_title
end
@book.entry_sort_title_cache = nil
@book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
username
end
def sort_title_db
@book.entry_sort_title_db @id
end end
def display_name def display_name
@@ -104,18 +81,10 @@ abstract class Entry
end end
def cover_url def cover_url
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg return "#{Config.current.base_url}img/icon.png" if @err_msg
unless @book.entry_cover_url_cache
TitleInfo.new @book.dir do |info|
@book.entry_cover_url_cache = info.entry_cover_url
end
end
entry_cover_url = @book.entry_cover_url_cache
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}" url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
if entry_cover_url TitleInfo.new @book.dir do |info|
info_url = entry_cover_url[@title]? info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty? unless info_url.nil? || info_url.empty?
url = File.join Config.current.base_url, info_url url = File.join Config.current.base_url, info_url
end end
@@ -123,6 +92,54 @@ abstract class Entry
url url
end end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
@@ -137,15 +154,23 @@ abstract class Entry
entries[idx - 1] entries[idx - 1]
end end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
end
# For backward backward compatibility with v0.1.0, we save entry titles # 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, page) def save_progress(username, page)
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
@book.parents.each do |parent|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
end
@book.remove_sorted_caches [SortMethod::Progress], username
TitleInfo.new @book.dir do |info| TitleInfo.new @book.dir do |info|
if info.progress[username]?.nil? if info.progress[username]?.nil?
info.progress[username] = {@title => page} info.progress[username] = {@title => page}
@@ -216,7 +241,7 @@ abstract class Entry
end end
Storage.default.save_thumbnail @id, img Storage.default.save_thumbnail @id, img
rescue e rescue e
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}" Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end end
img img
@@ -225,34 +250,4 @@ abstract class Entry
def get_thumbnail : Image? def get_thumbnail : Image?
Storage.default.get_thumbnail @id Storage.default.get_thumbnail @id
end end
def date_added : Time
date_added = Time::UNIX_EPOCH
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime path
info.save
else
date_added = info_da
end
end
date_added
end
# Hack to have abstract class methods
# https://github.com/crystal-lang/crystal/issues/5956
private module ClassMethods
abstract def is_valid?(path : String) : Bool
end
macro inherited
extend ClassMethods
end
abstract def read_page(page_num)
abstract def page_dimensions
abstract def examine : Bool?
end end

View File

@@ -1,94 +1,20 @@
class Library class Library
struct ThumbnailContext
property current : Int32, total : Int32
def initialize
@current = 0
@total = 0
end
def progress
if total == 0
0
else
current / total
end
end
def reset
@current = 0
@total = 0
end
def increment
@current += 1
end
end
include YAML::Serializable
getter dir : String, title_ids : Array(String), getter dir : String, title_ids : Array(String),
title_hash : Hash(String, Title) title_hash : Hash(String, Title)
@[YAML::Field(ignore: true)]
getter thumbnail_ctx = ThumbnailContext.new
use_default use_default
def save_instance
path = Config.current.library_cache_path
Logger.debug "Caching library to #{path}"
writer = Compress::Gzip::Writer.new path,
Compress::Gzip::BEST_COMPRESSION
writer.write self.to_yaml.to_slice
writer.close
end
def self.load_instance
path = Config.current.library_cache_path
return unless File.exists? path
Logger.debug "Loading cached library from #{path}"
begin
Compress::Gzip::Reader.open path do |content|
loaded = Library.from_yaml content
# We will have to do a full restart in these cases. Otherwise having
# two instances of the library will cause some weirdness.
if loaded.dir != Config.current.library_path
Logger.fatal "Cached library dir #{loaded.dir} does not match " \
"current library dir #{Config.current.library_path}. " \
"Deleting cache"
delete_cache_and_exit path
end
if loaded.title_ids.size > 0 &&
Storage.default.count_titles == 0
Logger.fatal "The library cache is inconsistent with the DB. " \
"Deleting cache"
delete_cache_and_exit path
end
@@default = loaded
Logger.debug "Library cache loaded"
end
Library.default.register_jobs
rescue e
Logger.error e
end
end
def initialize def initialize
register_mime_types
@dir = Config.current.library_path @dir = Config.current.library_path
# explicitly initialize @titles to bypass the compiler check. it will # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@title_hash = {} of String => Title @title_hash = {} of String => Title
register_jobs @entries_count = 0
end @thumbnails_count = 0
protected def register_jobs
register_mime_types
scan_interval = Config.current.scan_interval_minutes scan_interval = Config.current.scan_interval_minutes
if scan_interval < 1 if scan_interval < 1
@@ -99,7 +25,7 @@ class Library
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds ms = (Time.local - start).total_milliseconds
Logger.debug "Library initialized in #{ms}ms" Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep scan_interval.minutes sleep scan_interval.minutes
end end
end end
@@ -125,6 +51,11 @@ class Library
def sorted_titles(username, opt : SortOptions? = nil) def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil? if opt.nil?
opt = SortOptions.from_info_json @dir, username opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end end
# Helper function from src/util/util.cr # Helper function from src/util/util.cr
@@ -135,35 +66,14 @@ class Library
titles + titles.flat_map &.deep_titles titles + titles.flat_map &.deep_titles
end end
def deep_entries def to_slim_json : String
titles.flat_map &.deep_entries
end
def build_json(*, slim = false, depth = -1, sort_context = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
JSON.build do |json| JSON.build do |json|
json.object do json.object do
json.field "dir", @dir json.field "dir", @dir
json.field "titles" do json.field "titles" do
json.array do json.array do
_titles.each do |title| self.titles.each do |title|
json.raw title.build_json(slim: slim, depth: depth, json.raw title.to_slim_json
sort_context: sort_context, percentage: percentage)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |title|
json.number title.load_percentage sort_context[:username]
end
end end
end end
end end
@@ -171,6 +81,15 @@ class Library
end end
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) def get_title(tid)
@title_hash[tid]? @title_hash[tid]?
end end
@@ -180,7 +99,6 @@ class Library
end end
def scan def scan
start = Time.local
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"
@@ -189,38 +107,14 @@ class Library
storage = Storage.new auto_close: false storage = Storage.new auto_close: false
examine_context : ExamineContext = { (Dir.entries @dir)
cached_contents_signature: {} of String => String,
deleted_title_ids: [] of String,
deleted_entry_ids: [] of String,
}
library_paths = (Dir.entries @dir)
.select { |fn| !fn.starts_with? "." } .select { |fn| !fn.starts_with? "." }
.map { |fn| File.join @dir, fn } .map { |fn| File.join @dir, fn }
@title_ids.select! do |title_id|
title = @title_hash[title_id]
next false unless library_paths.includes? title.dir
existence = title.examine examine_context
unless existence
examine_context["deleted_title_ids"].concat [title_id] +
title.deep_titles.map &.id
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
end
existence
end
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
examine_context["deleted_title_ids"].each do |title_id|
@title_hash.delete title_id
end
cache = examine_context["cached_contents_signature"]
library_paths
.select { |path| !(remained_title_dirs.includes? path) }
.select { |path| File.directory? path } .select { |path| File.directory? path }
.map { |path| Title.new path, "", cache } .map { |path| Title.new path, "" }
.select { |title| !(title.entries.empty? && title.titles.empty?) } .select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort! { |a, b| a.sort_title <=> b.sort_title } .sort! { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.each do |title| .each do |title|
@title_hash[title.id] = title @title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
@@ -229,15 +123,8 @@ class Library
storage.bulk_insert_ids storage.bulk_insert_ids
storage.close storage.close
ms = (Time.local - start).total_milliseconds Logger.debug "Scan completed"
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms" Storage.default.mark_unavailable
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
examine_context["deleted_title_ids"]
spawn do
save_instance
end
end end
def get_continue_reading_entries(username) def get_continue_reading_entries(username)
@@ -321,29 +208,34 @@ class Library
.shuffle! .shuffle!
end end
def thumbnail_generation_progress
return 0 if @entries_count == 0
@thumbnails_count / @entries_count
end
def generate_thumbnails def generate_thumbnails
if thumbnail_ctx.current > 0 if @thumbnails_count > 0
Logger.debug "Thumbnail generation in progress" Logger.debug "Thumbnail generation in progress"
return return
end end
Logger.info "Starting thumbnail generation" Logger.info "Starting thumbnail generation"
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
thumbnail_ctx.total = entries.size @entries_count = entries.size
thumbnail_ctx.current = 0 @thumbnails_count = 0
# Report generation progress regularly # Report generation progress regularly
spawn do spawn do
loop do loop do
unless thumbnail_ctx.current == 0 unless @thumbnails_count == 0
Logger.debug "Thumbnail generation progress: " \ Logger.debug "Thumbnail generation progress: " \
"#{(thumbnail_ctx.progress * 100).round 1}%" "#{(thumbnail_generation_progress * 100).round 1}%"
end end
# Generation is completed. We reset the count to 0 to allow subsequent # Generation is completed. We reset the count to 0 to allow subsequent
# calls to the function, and break from the loop to stop the progress # calls to the function, and break from the loop to stop the progress
# report fiber # report fiber
if thumbnail_ctx.progress.to_i == 1 if thumbnail_generation_progress.to_i == 1
thumbnail_ctx.reset @thumbnails_count = 0
break break
end end
sleep 10.seconds sleep 10.seconds
@@ -357,7 +249,7 @@ class Library
# and CPU # and CPU
sleep 1.seconds sleep 1.seconds
end end
thumbnail_ctx.increment @thumbnails_count += 1
end end
Logger.info "Thumbnail generation finished" Logger.info "Thumbnail generation finished"
end end

View File

@@ -1,30 +1,13 @@
require "digest"
require "../archive" require "../archive"
class Title class Title
include YAML::Serializable
getter dir : String, parent_id : String, title_ids : Array(String), getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time, signature : UInt64, encoded_title : String, mtime : Time, signature : UInt64
entry_cover_url_cache : Hash(String, String)?
setter entry_cover_url_cache : Hash(String, String)?,
entry_sort_title_cache : Hash(String, String | Nil)?
@[YAML::Field(ignore: true)]
@sort_title : String?
@[YAML::Field(ignore: true)]
@entry_sort_title_cache : Hash(String, String | Nil)?
@[YAML::Field(ignore: true)]
@entry_display_name_cache : Hash(String, String)? @entry_display_name_cache : Hash(String, String)?
@[YAML::Field(ignore: true)]
@entry_cover_url_cache : Hash(String, String)?
@[YAML::Field(ignore: true)]
@cached_display_name : String?
@[YAML::Field(ignore: true)]
@cached_cover_url : String?
def initialize(@dir : String, @parent_id, cache = {} of String => String) def initialize(@dir : String, @parent_id)
storage = Storage.default storage = Storage.default
@signature = Dir.signature dir @signature = Dir.signature dir
id = storage.get_title_id dir, signature id = storage.get_title_id dir, signature
@@ -37,7 +20,6 @@ class Title
}) })
end end
@id = id @id = id
@contents_signature = Dir.contents_signature dir, cache
@title = File.basename dir @title = File.basename dir
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@title_ids = [] of String @title_ids = [] of String
@@ -48,19 +30,14 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, cache title = Title.new path, @id
unless title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
end
if DirEntry.is_valid? path
entry = DirEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
next next
end end
if is_supported_file path if is_supported_file path
entry = ArchiveEntry.new path, self entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
@@ -76,212 +53,28 @@ class Title
end end
sorter = ChapterSorter.new @entries.map &.title sorter = ChapterSorter.new @entries.map &.title
@entries.sort! do |a, b| @entries.sort! do |a, b|
sorter.compare a.sort_title, b.sort_title sorter.compare a.title, b.title
end end
end end
# Utility method used in library rescanning. def to_slim_json : String
# - When the title does not exist on the file system anymore, return false
# and let it be deleted from the library instance
# - When the title exists, but its contents signature is now different from
# the cache, it means some of its content (nested titles or entries)
# has been added, deleted, or renamed. In this case we update its
# contents signature and instance variables
# - When the title exists and its contents signature is still the same, we
# return true so it can be reused without rescanning
def examine(context : ExamineContext) : Bool
return false unless Dir.exists? @dir
contents_signature = Dir.contents_signature @dir,
context["cached_contents_signature"]
return true if @contents_signature == contents_signature
@contents_signature = contents_signature
@signature = Dir.signature @dir
storage = Storage.default
id = storage.get_title_id dir, signature
if id.nil?
id = random_str
storage.insert_title_id({
path: dir,
id: id,
signature: signature.to_s,
})
end
@id = id
@mtime = File.info(@dir).modification_time
previous_titles_size = @title_ids.size
@title_ids.select! do |title_id|
title = Library.default.get_title title_id
unless title # for if data consistency broken
context["deleted_title_ids"].concat [title_id]
next false
end
existence = title.examine context
unless existence
context["deleted_title_ids"].concat [title_id] +
title.deep_titles.map &.id
context["deleted_entry_ids"].concat title.deep_entries.map &.id
end
existence
end
remained_title_dirs = @title_ids.map do |title_id|
title = Library.default.get_title! title_id
title.dir
end
previous_entries_size = @entries.size
@entries.select! do |entry|
existence = entry.examine
Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence
existence
end
remained_entry_paths = @entries.map &.path
is_titles_added = false
is_entries_added = false
Dir.entries(dir).each do |fn|
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
unless remained_entry_paths.includes? path
if DirEntry.is_valid? path
entry = DirEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"]
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
end
end
next
end
if is_supported_file path
next if remained_entry_paths.includes? path
entry = ArchiveEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
mtimes = [@mtime]
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
mtimes += @entries.map &.mtime
@mtime = mtimes.max
if is_titles_added || previous_titles_size != @title_ids.size
@title_ids.sort! do |a, b|
compare_numerically Library.default.title_hash[a].title,
Library.default.title_hash[b].title
end
end
if is_entries_added || previous_entries_size != @entries.size
sorter = ChapterSorter.new @entries.map &.sort_title
@entries.sort! do |a, b|
sorter.compare a.sort_title, b.sort_title
end
end
if @title_ids.size > 0 || @entries.size > 0
true
else
context["deleted_title_ids"].concat [@id]
false
end
end
alias SortContext = NamedTuple(username: String, opt: SortOptions)
def build_json(*, slim = false, depth = -1,
sort_context : SortContext? = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for str in ["dir", "title", "id"] %} {% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "signature" { json.number @signature } json.field "signature" { json.number @signature }
json.field "sort_title", sort_title json.field "titles" do
unless slim json.array do
json.field "display_name", display_name self.titles.each do |title|
json.field "cover_url", cover_url json.raw title.to_slim_json
json.field "mtime" { json.number @mtime.to_unix } end
end
end end
unless depth == 0 json.field "entries" do
json.field "titles" do json.array do
json.array do @entries.each do |entry|
_titles.each do |title| json.raw entry.to_slim_json
json.raw title.build_json(slim: slim,
depth: depth > 0 ? depth - 1 : depth,
sort_context: sort_context, percentage: percentage)
end
end
end
json.field "entries" do
json.array do
_entries.each do |entry|
json.raw entry.build_json(slim: slim)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |t|
json.number t.load_percentage sort_context[:username]
end
end
end
json.field "entry_percentages" do
json.array do
load_percentage_for_all_entries(
sort_context[:username],
sort_context[:opt]
).each do |p|
json.number p.nan? ? 0 : p
end
end
end end
end end
end end
@@ -299,17 +92,36 @@ class Title
end end
end end
def titles def to_json(json : JSON::Builder)
@title_ids.map { |tid| Library.default.get_title! tid } json.object do
{% for str in ["dir", "title", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "signature" { json.number @signature }
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 end
def sorted_titles(username, opt : SortOptions? = nil) def titles
if opt.nil? @title_ids.map { |tid| Library.default.get_title! tid }
opt = SortOptions.from_info_json @dir, username
end
# Helper function from src/util/util.cr
sort_titles titles, opt.not_nil!, username
end end
# Get all entries, including entries in nested titles # Get all entries, including entries in nested titles
@@ -348,48 +160,6 @@ class Title
ary.join " and " ary.join " and "
end end
def sort_title
sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached
sort_title = Storage.default.get_title_sort_title id
if sort_title
@sort_title = sort_title
return sort_title
end
@sort_title = @title
@title
end
def set_sort_title(sort_title : String | Nil, username : String)
Storage.default.set_title_sort_title id, sort_title
if sort_title == "" || sort_title.nil?
@sort_title = nil
else
@sort_title = sort_title
end
if parents.size > 0
target = parents[-1].titles
else
target = Library.default.titles
end
remove_sorted_titles_cache target,
[SortMethod::Auto, SortMethod::Title], username
end
def sort_title_db
Storage.default.get_title_sort_title id
end
def entry_sort_title_db(entry_id)
unless @entry_sort_title_cache
@entry_sort_title_cache =
Storage.default.get_entries_sort_title @entries.map &.id
end
@entry_sort_title_cache.not_nil![entry_id]?
end
def tags def tags
Storage.default.get_title_tags @id Storage.default.get_title_tags @id
end end
@@ -407,15 +177,11 @@ class Title
end end
def display_name def display_name
cached_display_name = @cached_display_name
return cached_display_name unless cached_display_name.nil?
dn = @title dn = @title
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info_dn = info.display_name info_dn = info.display_name
dn = info_dn unless info_dn.empty? dn = info_dn unless info_dn.empty?
end end
@cached_display_name = dn
dn dn
end end
@@ -439,7 +205,6 @@ class Title
end end
def set_display_name(dn) def set_display_name(dn)
@cached_display_name = dn
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info.display_name = dn info.display_name = dn
info.save info.save
@@ -449,16 +214,12 @@ class Title
def set_display_name(entry_name : String, dn) def set_display_name(entry_name : String, dn)
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn info.entry_display_name[entry_name] = dn
@entry_display_name_cache = info.entry_display_name
info.save info.save
end end
end end
def cover_url def cover_url
cached_cover_url = @cached_cover_url url = "#{Config.current.base_url}img/icon.png"
return cached_cover_url unless cached_cover_url.nil?
url = "#{Config.current.base_url}img/icons/icon_x192.png"
readable_entries = @entries.select &.err_msg.nil? readable_entries = @entries.select &.err_msg.nil?
if readable_entries.size > 0 if readable_entries.size > 0
url = readable_entries[0].cover_url url = readable_entries[0].cover_url
@@ -469,12 +230,10 @@ class Title
url = File.join Config.current.base_url, info_url url = File.join Config.current.base_url, info_url
end end
end end
@cached_cover_url = url
url url
end end
def set_cover_url(url : String) def set_cover_url(url : String)
@cached_cover_url = url
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info.cover_url = url info.cover_url = url
info.save info.save
@@ -484,7 +243,6 @@ class Title
def set_cover_url(entry_name : String, url : String) def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url info.entry_cover_url[entry_name] = url
@entry_cover_url_cache = info.entry_cover_url
info.save info.save
end end
end end
@@ -504,15 +262,8 @@ class Title
end end
def deep_read_page_count(username) : Int32 def deep_read_page_count(username) : Int32
key = "#{@id}:#{username}:progress_sum" load_progress_for_all_entries(username).sum +
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s titles.flat_map(&.deep_read_page_count username).sum
cached_sum = LRUCache.get key
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
cached_sum[0] == sig
sum = load_progress_for_all_entries(username, nil, true).sum +
titles.flat_map(&.deep_read_page_count username).sum
LRUCache.set generate_cache_entry key, {sig, sum}
sum
end end
def deep_total_page_count : Int32 def deep_total_page_count : Int32
@@ -566,46 +317,44 @@ class Title
# use the default (auto, ascending) # use the default (auto, ascending)
# When `opt` is not nil, it saves the options to info.json # When `opt` is not nil, it saves the options to info.json
def sorted_entries(username, opt : SortOptions? = nil) def sorted_entries(username, opt : SortOptions? = nil)
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
cached_entries = LRUCache.get cache_key
return cached_entries if cached_entries.is_a? Array(Entry)
if opt.nil? if opt.nil?
opt = SortOptions.from_info_json @dir, username opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end end
case opt.not_nil!.method case opt.not_nil!.method
when .title? when .title?
ary = @entries.sort do |a, b| ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
compare_numerically a.sort_title, b.sort_title
end
when .time_modified? when .time_modified?
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \ ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.sort_title, b.sort_title } compare_numerically a.title, b.title }
when .time_added? when .time_added?
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \ ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
compare_numerically a.sort_title, b.sort_title } compare_numerically a.title, b.title }
when .progress? when .progress?
percentage_ary = load_percentage_for_all_entries username, opt, true percentage_ary = load_percentage_for_all_entries username, opt, true
ary = @entries.zip(percentage_ary) ary = @entries.zip(percentage_ary)
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \ .sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
compare_numerically a_tp[0].sort_title, b_tp[0].sort_title } compare_numerically a_tp[0].title, b_tp[0].title }
.map &.[0] .map &.[0]
else else
unless opt.method.auto? unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead" "Auto instead"
end end
sorter = ChapterSorter.new @entries.map &.sort_title sorter = ChapterSorter.new @entries.map &.title
ary = @entries.sort do |a, b| ary = @entries.sort do |a, b|
sorter.compare(a.sort_title, b.sort_title).or \ sorter.compare(a.title, b.title).or \
compare_numerically a.sort_title, b.sort_title compare_numerically a.title, b.title
end end
end end
ary.reverse! unless opt.not_nil!.ascend ary.reverse! unless opt.not_nil!.ascend
LRUCache.set generate_cache_entry cache_key, ary
ary ary
end end
@@ -632,16 +381,6 @@ class Title
if last_read_entry && last_read_entry.finished? username if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username last_read_entry = last_read_entry.next_entry username
if last_read_entry.nil?
# The last entry is finished. Return the first unfinished entry
# (if any)
sorted_entries(username).each do |e|
unless e.finished? username
last_read_entry = e
break
end
end
end
end end
last_read_entry last_read_entry
@@ -656,7 +395,7 @@ class Title
@entries.each do |e| @entries.each do |e|
next if da.has_key? e.title next if da.has_key? e.title
da[e.title] = ctime e.path da[e.title] = ctime e.zip_path
end end
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|
@@ -676,33 +415,7 @@ class Title
zip + titles.flat_map &.deep_entries_with_date_added zip + titles.flat_map &.deep_entries_with_date_added
end end
def remove_sorted_entries_cache(sort_methods : Array(SortMethod),
username : String)
[false, true].each do |ascend|
sort_methods.each do |sort_method|
sorted_entries_cache_key =
SortedEntriesCacheEntry.gen_key @id, username, @entries,
SortOptions.new(sort_method, ascend)
LRUCache.invalidate sorted_entries_cache_key
end
end
end
def remove_sorted_caches(sort_methods : Array(SortMethod), username : String)
remove_sorted_entries_cache sort_methods, username
parents.each do |parent|
remove_sorted_titles_cache parent.titles, sort_methods, username
end
remove_sorted_titles_cache Library.default.titles, sort_methods, username
end
def bulk_progress(action, ids : Array(String), username) def bulk_progress(action, ids : Array(String), username)
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
parents.each do |parent|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
end
remove_sorted_caches [SortMethod::Progress], username
selected_entries = ids selected_entries = ids
.map { |id| .map { |id|
@entries.find &.id.==(id) @entries.find &.id.==(id)

View File

@@ -1,3 +1,5 @@
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
enum SortMethod enum SortMethod
Auto Auto
Title Title
@@ -45,13 +47,6 @@ class SortOptions
def to_tuple def to_tuple
{@method.to_s.underscore, ascend} {@method.to_s.underscore, ascend}
end end
def to_json
{
"method" => method.to_s.underscore,
"ascend" => ascend,
}.to_json
end
end end
struct Image struct Image
@@ -93,18 +88,6 @@ class TitleInfo
@@mutex_hash = {} of String => Mutex @@mutex_hash = {} of String => Mutex
def self.new(dir, &) def self.new(dir, &)
key = "#{dir}:info.json"
info = LRUCache.get key
if info.is_a? String
begin
instance = TitleInfo.from_json info
instance.dir = dir
yield instance
return
rescue
end
end
if @@mutex_hash[dir]? if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir] mutex = @@mutex_hash[dir]
else else
@@ -118,7 +101,6 @@ class TitleInfo
instance = TitleInfo.from_json File.read json_path instance = TitleInfo.from_json File.read json_path
end end
instance.dir = dir instance.dir = dir
LRUCache.set generate_cache_entry key, instance.to_json
yield instance yield instance
end end
end end
@@ -126,12 +108,5 @@ class TitleInfo
def save def save
json_path = File.join @dir, "info.json" json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json File.write json_path, self.to_pretty_json
key = "#{@dir}:info.json"
LRUCache.set generate_cache_entry key, self.to_json
end end
end end
alias ExamineContext = NamedTuple(
cached_contents_signature: Hash(String, String),
deleted_title_ids: Array(String),
deleted_entry_ids: Array(String))

View File

@@ -34,12 +34,7 @@ class Logger
end end
@backend.formatter = Log::Formatter.new &format_proc @backend.formatter = Log::Formatter.new &format_proc
Log.setup @@severity, @backend
Log.setup do |c|
c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend
c.bind "duktape", :none, @backend
end
end end
def self.get_severity(level = "") : Log::Severity def self.get_severity(level = "") : Log::Severity

172
src/mangadex/downloader.cr Normal file
View File

@@ -0,0 +1,172 @@
require "mangadex"
require "compress/zip"
require "../rename"
require "./ext"
module MangaDex
class PageJob
property success = false
property url : String
property filename : String
property writer : Compress::Zip::Writer
property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
end
end
class Downloader < Queue::Downloader
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
use_default
def initialize
@client = Client.from_config
super
end
def pop : Queue::Job?
job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id not like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
end
end
job
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
chapter = @client.chapter job.id
# We must put the `.pages` call in a rescue block to handle external
# chapters.
pages = chapter.pages
rescue e
Logger.error e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
return
end
@queue.set_pages pages.size, job
lib_dir = @library_path
rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages
len = Math.log10(pages.size).to_i + 1
writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new pages.size
spawn do
pages.each_with_index do |url, i|
fn = Path.new(URI.parse(url).path).basename
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job
break unless @queue.exists? job
end
end
spawn do
page_jobs = [] of PageJob
pages.size.times do
page_job = channel.receive
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job
if page_job.success
@queue.add_success job
else
@queue.add_fail job
msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job
Logger.error msg
end
end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path,
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive filename
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status Queue::JobStatus::Error, job
elsif fail_count > 0
@queue.set_status Queue::JobStatus::MissingPages, job
else
@queue.set_status Queue::JobStatus::Completed, job
end
@downloading = false
end
end
private def download_page(job : PageJob)
Logger.debug "downloading #{job.url}"
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
begin
HTTP::Client.get job.url, headers do |res|
unless res.success?
raise "Failed to download page #{job.url}. " \
"[#{res.status_code}] #{res.status_message}"
end
job.writer.add job.filename, res.body_io
end
job.success = true
rescue e
Logger.error e
job.success = false
end
end
end
end

60
src/mangadex/ext.cr Normal file
View File

@@ -0,0 +1,60 @@
private macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => {{name.id}}.to_s,
{% end %}
}
end
# Monkey-patch the structures in the `mangadex` shard to suit our needs
module MangaDex
struct Client
@@group_cache = {} of String => Group
def self.from_config : Client
self.new base_url: Config.current.mangadex["base_url"].to_s,
api_url: Config.current.mangadex["api_url"].to_s
end
end
struct Manga
def rename(rule : Rename::Rule)
rule.render properties_to_hash %w(id title author artist)
end
def to_info_json
hash = JSON.parse(to_json).as_h
_chapters = chapters.map do |c|
JSON.parse c.to_info_json
end
hash["chapters"] = JSON::Any.new _chapters
hash.to_json
end
end
struct Chapter
def rename(rule : Rename::Rule)
hash = properties_to_hash %w(id title volume chapter lang_code language)
hash["groups"] = groups.join(",", &.name)
rule.render hash
end
def full_title
rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
rename rule
end
def to_info_json
hash = JSON.parse(to_json).as_h
hash["language"] = JSON::Any.new language
_groups = {} of String => JSON::Any
groups.each do |g|
_groups[g.name] = JSON::Any.new g.id
end
hash["groups"] = JSON::Any.new _groups
hash["full_title"] = JSON::Any.new full_title
hash.to_json
end
end
end

View File

@@ -2,12 +2,13 @@ require "./config"
require "./queue" require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./main_fiber"
require "./mangadex/*"
require "./plugin/*" require "./plugin/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "tallboy" require "tallboy"
MANGO_VERSION = "0.27.0" MANGO_VERSION = "0.22.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -55,13 +56,11 @@ class CLI < Clim
Config.load(opts.config).set_current Config.load(opts.config).set_current
# Initialize main components # Initialize main components
LRUCache.init
Storage.default Storage.default
Queue.default Queue.default
Library.load_instance
Library.default Library.default
MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
Plugin::Updater.default
spawn do spawn do
begin begin

View File

@@ -23,6 +23,11 @@ class Plugin
job job
end end
private def process_filename(str)
return "_" if str == ".."
str.gsub "/", "_"
end
private def download(job : Queue::Job) private def download(job : Queue::Job)
@downloading = true @downloading = true
@queue.set_status Queue::JobStatus::Downloading, job @queue.set_status Queue::JobStatus::Downloading, job
@@ -37,8 +42,8 @@ class Plugin
pages = info["pages"].as_i pages = info["pages"].as_i
manga_title = sanitize_filename job.manga_title manga_title = process_filename job.manga_title
chapter_title = sanitize_filename info["title"].as_s chapter_title = process_filename info["title"].as_s
@queue.set_pages pages, job @queue.set_pages pages, job
lib_dir = @library_path lib_dir = @library_path
@@ -63,7 +68,7 @@ class Plugin
while page = plugin.next_page while page = plugin.next_page
break unless @queue.exists? job break unless @queue.exists? job
fn = sanitize_filename page["filename"].as_s fn = process_filename page["filename"].as_s
url = page["url"].as_s url = page["url"].as_s
headers = HTTP::Headers.new headers = HTTP::Headers.new

View File

@@ -105,10 +105,9 @@ class Plugin
getter js_path = "" getter js_path = ""
getter storage_path = "" getter storage_path = ""
def self.build_info_ary(dir : String? = nil) def self.build_info_ary
@@info_ary.clear @@info_ary.clear
dir ||= Config.current.plugin_path dir = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f| Dir.each_child dir do |f|
@@ -144,25 +143,14 @@ class Plugin
SubscriptionList.new(info.dir).ary SubscriptionList.new(info.dir).ary
end end
def list_subscriptions_raw
SubscriptionList.new(info.dir)
end
def unsubscribe(id : String) def unsubscribe(id : String)
list = SubscriptionList.new info.dir list = SubscriptionList.new info.dir
list.reject! &.id.== id list.reject &.id.== id
list.save list.save
end end
def check_subscription(id : String) def initialize(id : String)
list = list_subscriptions_raw Plugin.build_info_ary
sub = list.find &.id.== id
Plugin::Updater.default.check_subscription self, sub.not_nil!
list.save
end
def initialize(id : String, dir : String? = nil)
Plugin.build_info_ary dir
@info = @@info_ary.find &.id.== id @info = @@info_ary.find &.id.== id
if @info.nil? if @info.nil?
@@ -224,14 +212,10 @@ class Plugin
raise Error.new "Missing required fields in the Page type" raise Error.new "Missing required fields in the Page type"
end end
def can_subscribe? : Bool
info.version > 1 && eval_exists?("newChapters")
end
def search_manga(query : String) def search_manga(query : String)
if info.version == 1 if info.version == 1
raise Error.new "Manga searching is only available for plugins " \ raise Error.new "Manga searching is only available for plugins targeting API " \
"targeting API v2 or above" "v2 or above"
end end
json = eval_json "searchManga('#{query}')" json = eval_json "searchManga('#{query}')"
begin begin
@@ -307,9 +291,7 @@ class Plugin
end end
def new_chapters(manga_id : String, after : Int64) def new_chapters(manga_id : String, after : Int64)
# Converting standard timestamp to milliseconds so plugins can easily do json = eval_json "newChapters('#{manga_id}', #{after})"
# `new Date(ms_timestamp)` in JS.
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
begin begin
json.as_a.each do |obj| json.as_a.each do |obj|
assert_chapter_type obj assert_chapter_type obj
@@ -320,7 +302,7 @@ class Plugin
json json
end end
def eval(str) private def eval(str)
@rt.eval str @rt.eval str
rescue e : Duktape::SyntaxError rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message raise SyntaxError.new e.message
@@ -332,15 +314,6 @@ class Plugin
JSON.parse eval(str).as String JSON.parse eval(str).as String
end end
private def eval_exists?(str) : Bool
@rt.eval str
true
rescue e : Duktape::ReferenceError
false
rescue e : Duktape::Error
raise Error.new e.message
end
private def def_helper_functions(sbx) private def def_helper_functions(sbx)
sbx.push_object sbx.push_object
@@ -449,15 +422,9 @@ class Plugin
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
begin str = XML.parse(html).inner_text
parser = Myhtml::Parser.new html
str = parser.body!.children.first.inner_text
env.push_string str
rescue
env.push_string ""
end
env.push_string str
env.call_success env.call_success
end end
sbx.put_prop_string -2, "text" sbx.put_prop_string -2, "text"
@@ -468,9 +435,8 @@ class Plugin
name = env.require_string 1 name = env.require_string 1
begin begin
parser = Myhtml::Parser.new html attr = XML.parse(html).first_element_child.not_nil![name]
attr = parser.body!.children.first.attribute_by name env.push_string attr
env.push_string attr.not_nil!
rescue rescue
env.push_undefined env.push_undefined
end end

View File

@@ -1,5 +1,4 @@
require "uuid" require "uuid"
require "big"
enum FilterType enum FilterType
String String
@@ -44,54 +43,27 @@ struct Filter
key = json["key"].as_s key = json["key"].as_s
type = FilterType.from_string json["type"].as_s type = FilterType.from_string json["type"].as_s
_value = json["value"] _value = json["value"]
value = _value.as_s? || _value.as_i? || _value.as_i64? || value = _value.as_s? || _value.as_i32? || _value.as_i64? ||
_value.as_f32? || nil _value.as_f32? || nil
self.new key, value, type self.new key, value, type
end end
def match_chapter(obj : JSON::Any) : Bool
return true if value.nil? || value.to_s.empty?
raw_value = obj[key]
case type
when FilterType::String
raw_value.as_s.downcase == value.to_s.downcase
when FilterType::NumMin, FilterType::DateMin
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
when FilterType::NumMax, FilterType::DateMax
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
when FilterType::Array
return true if value == "all"
raw_value.as_s.downcase.split(",")
.map(&.strip).includes? value.to_s.downcase.strip
else
false
end
end
end end
# We use class instead of struct so we can update `last_checked` from struct Subscription
# `SubscriptionList`
class Subscription
include JSON::Serializable include JSON::Serializable
property id : String property id : String
property plugin_id : String property plugin_id : String
property manga_id : String
property manga_title : String
property name : String property name : String
property created_at : Int64 property created_at : Int64
property last_checked : Int64 property last_checked : Int64
property filters = [] of Filter property filters = [] of Filter
def initialize(@plugin_id, @manga_id, @manga_title, @name) def initialize(@plugin_id, @name)
@id = UUID.random.to_s @id = UUID.random.to_s
@created_at = Time.utc.to_unix @created_at = Time.utc.to_unix
@last_checked = Time.utc.to_unix @last_checked = Time.utc.to_unix
end end
def match_chapter(obj : JSON::Any) : Bool
filters.all? &.match_chapter(obj)
end
end end
struct SubscriptionList struct SubscriptionList
@@ -103,13 +75,13 @@ struct SubscriptionList
forward_missing_to @ary forward_missing_to @ary
def initialize(@dir) def initialize(@dir)
@path = Path[@dir, "subscriptions.json"].to_s @path = Path[@dir, "subscriptions.json"]
if File.exists? @path if File.exists? @path
@ary = Array(Subscription).from_json File.read @path @ary = Array(Subscription).from_json File.read @path
end end
end end
def save def save
File.write @path, @ary.to_pretty_json File.write @path, @ary.to_json
end end
end end

View File

@@ -1,75 +0,0 @@
class Plugin
class Updater
use_default
def initialize
interval = Config.current.plugin_update_interval_hours
return if interval <= 0
spawn do
loop do
Plugin.list.map(&.["id"]).each do |pid|
check_updates pid
end
sleep interval.hours
end
end
end
def check_updates(plugin_id : String)
Logger.debug "Checking plugin #{plugin_id} for updates"
plugin = Plugin.new plugin_id
if plugin.info.version == 1
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
"Skipping update check"
return
end
subscriptions = plugin.list_subscriptions_raw
subscriptions.each do |sub|
check_subscription plugin, sub
end
subscriptions.save
rescue e
Logger.error "Error checking plugin #{plugin_id} for updates: " \
"#{e.message}"
end
def check_subscription(plugin : Plugin, sub : Subscription)
Logger.debug "Checking subscription #{sub.name} for updates"
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
.as_a.select do |chapter|
sub.match_chapter chapter
end
if matches.empty?
Logger.debug "No new chapters found."
sub.last_checked = Time.utc.to_unix
return
end
Logger.debug "Found #{matches.size} new chapters. " \
"Pushing to download queue"
jobs = matches.map { |ch|
Queue::Job.new(
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
"", # manga_id
ch["title"].as_s,
sub.manga_title,
Queue::JobStatus::Pending,
Time.utc
)
}
inserted_count = Queue.default.push jobs
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
"to the download queue. Plugin ID #{plugin.info.id}, " \
"subscription name #{sub.name}"
if inserted_count != matches.size
Logger.error "Failed to add #{matches.size - inserted_count} " \
"chapters to download queue"
end
sub.last_checked = Time.utc.to_unix
rescue e
Logger.error "Error when checking updates for subscription " \
"#{sub.name}: #{e.message}"
end
end
end

View File

@@ -70,13 +70,7 @@ class Queue
ary = @id.split("-") ary = @id.split("-")
if ary.size == 2 if ary.size == 2
@plugin_id = ary[0] @plugin_id = ary[0]
# This begin-rescue block is for backward compatibility. In earlier @plugin_chapter_id = ary[1]
# versions we didn't encode the chapter ID
@plugin_chapter_id = begin
Base64.decode_string ary[1]
rescue
ary[1]
end
end end
end end
@@ -118,7 +112,7 @@ class Queue
use_default use_default
def initialize(db_path : String? = nil) def initialize(db_path : String? = nil)
@path = db_path || Config.current.queue_db_path.to_s @path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
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. " \

View File

@@ -1,5 +1,3 @@
require "sanitize"
struct AdminRouter struct AdminRouter
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
@@ -16,13 +14,13 @@ struct AdminRouter
end end
get "/admin/user/edit" do |env| get "/admin/user/edit" do |env|
sanitizer = Sanitize::Policy::Text.new username = env.params.query["username"]?
username = env.params.query["username"]?.try { |s| sanitizer.process s }
admin = env.params.query["admin"]? admin = env.params.query["admin"]?
if admin if admin
admin = admin == "true" admin = admin == "true"
end end
error = env.params.query["error"]?.try { |s| sanitizer.process s } error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil? new_user = username.nil? && admin.nil?
layout "user-edit" layout "user-edit"
end end
@@ -68,15 +66,16 @@ struct AdminRouter
end end
get "/admin/downloads" do |env| get "/admin/downloads" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
get "/admin/subscriptions" do |env|
layout "subscription-manager"
end
get "/admin/missing" do |env| get "/admin/missing" do |env|
layout "missing-items" layout "missing-items"
end end
get "/admin/mangadex" do |env|
layout "mangadex"
end
end end
end end

View File

@@ -1,6 +1,6 @@
require "../mangadex/*"
require "../upload" require "../upload"
require "koa" require "koa"
require "digest"
struct APIRouter struct APIRouter
@@api_json : String? @@api_json : String?
@@ -23,7 +23,7 @@ struct APIRouter
# Authentication # Authentication
All endpoints except `/api/login` require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access. All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access.
# Terminologies # Terminologies
@@ -40,19 +40,14 @@ struct APIRouter
Koa.schema "entry", { Koa.schema "entry", {
"pages" => Int32, "pages" => Int32,
"mtime" => Int64, "mtime" => Int64,
}.merge(s %w(zip_path path title size id title_id display_name cover_url)), }.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book" desc: "An entry in a book"
Koa.schema "title", { Koa.schema "title", {
"mtime" => Int64, "mtime" => Int64,
"entries" => ["entry"], "entries" => ["entry"],
"titles" => ["title"], "titles" => ["title"],
"parents" => [{ "parents" => [String],
"title" => String,
"id" => String,
}],
"title_percentages" => [Float64?],
"entry_percentages" => [Float64?],
}.merge(s %w(dir title id display_name cover_url)), }.merge(s %w(dir title id display_name cover_url)),
desc: "A manga title (a collection of entries and sub-titles)" desc: "A manga title (a collection of entries and sub-titles)"
@@ -61,58 +56,18 @@ struct APIRouter
"error" => String?, "error" => String?,
} }
Koa.schema "filter", { Koa.schema("mdChapter", {
"key" => String, "id" => Int64,
"type" => String, "group" => {} of String => String,
"value" => String | Int32 | Int64 | Float32, }.merge(s %w(title volume chapter language full_title time
} manga_title manga_id)),
desc: "A MangaDex chapter")
Koa.schema "subscription", { Koa.schema "mdManga", {
"id" => String, "id" => Int64,
"plugin_id" => String, "chapters" => ["mdChapter"],
"manga_id" => String, }.merge(s %w(title description author artist cover_url)),
"manga_title" => String, desc: "A MangaDex manga"
"name" => String,
"created_at" => Int64,
"last_checked" => Int64,
"filters" => ["filter"],
}
Koa.describe "Authenticates a user", <<-MD
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
MD
Koa.body schema: {
"username" => String,
"password" => String,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"session_id" => String?,
"is_admin" => Bool?,
}
Koa.tag "users"
post "/api/login" do |env|
begin
username = env.params.json["username"].as String
password = env.params.json["password"].as String
token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token
send_json env, {
"success" => true,
"session_id" => env.session.id,
"is_admin" => Storage.default.username_is_admin username,
}.to_json
rescue e
Logger.error e
env.response.status_code = 403
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns a page in a manga entry" Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
@@ -120,14 +75,12 @@ struct APIRouter
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)" Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
Koa.tag "reader" Koa.tag "reader"
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
@@ -137,24 +90,11 @@ struct APIRouter
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?
e_tag = Digest::SHA1.hexdigest img.data send_img env, img
if prev_e_tag == e_tag
env.response.status_code = 304
""
else
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control
send_img env, img
end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
send_text env, e.message e.message
end end
end end
@@ -162,14 +102,12 @@ struct APIRouter
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.tag "library" Koa.tag "library"
get "/api/cover/:tid/:eid" do |env| get "/api/cover/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
@@ -180,303 +118,55 @@ struct APIRouter
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \ raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
if img.nil? if img.nil?
e_tag = Digest::SHA1.hexdigest img.data send_img env, img
if prev_e_tag == e_tag
env.response.status_code = 304
""
else
env.response.headers["ETag"] = e_tag
send_img env, img
end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
send_text env, e.message e.message
end end
end end
Koa.describe "Returns the book with title `tid`", <<-MD Koa.describe "Returns the book with title `tid`", <<-MD
The entries and titles will be sorted by the default sorting method for the logged-in user. Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `percentage` query parameter to include the reading progress
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `depth` query parameter to control the depth of nested titles to return.
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
- When `depth` is 0, returns the top-level titles without their sub-titles/entries
- When `depth` is N, returns the top-level titles and sub-titles/entries N levels in them
- When `depth` is negative, returns the entire library
MD MD
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "slim" Koa.query "slim"
Koa.query "depth"
Koa.query "percentage"
Koa.response 200, schema: "title" Koa.response 200, schema: "title"
Koa.response 404, "Title not found" Koa.response 404, "Title not found"
Koa.tag "library" Koa.tag "library"
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
username = get_username env
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
sort_opt = SortOptions.from_info_json title.dir, username if env.params.query["slim"]?
send_json env, title.to_slim_json
slim = !env.params.query["slim"]?.nil? else
depth = env.params.query["depth"]?.try(&.to_i?) || -1 send_json env, title.to_json
percentage = !env.params.query["percentage"]?.nil? end
send_json env, title.build_json(slim: slim, depth: depth,
sort_context: {username: username,
opt: sort_opt}, percentage: percentage)
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
send_text env, e.message e.message
end end
end end
Koa.describe "Returns the sorting option of a title or the library", <<-MD
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
- If the query parameter `tid` is missing, returns the sorting option of the library.
MD
Koa.query "tid"
Koa.response 200, schema: {
"method" => String?,
"ascend" => Bool?,
"error" => String?,
}
Koa.tag "library"
get "/api/sort_opt" do |env|
username = get_username env
tid = env.params.query["tid"]?
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
sort_opt = SortOptions.from_info_json dir, username
send_json env, sort_opt.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Updates the sorting option of a title or the library", <<-MD
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
- When the `tid` field is missing in the body, updates the sorting option of the library.
MD
Koa.body schema: {
"tid" => String?,
"method" => String,
"ascend" => Bool,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tag "library"
put "/api/sort_opt" do |env|
username = get_username env
tid = env.params.json["tid"]?.try &.as String
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
method = env.params.json["sort"].as String
ascend = env.params.json["ascend"].as Bool
sort_opt = SortOptions.new method, ascend
TitleInfo.new dir do |info|
info.sort_by[username] = sort_opt.to_tuple
info.save
end
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the entire library with all titles and entries", <<-MD Koa.describe "Returns the entire library with all titles and entries", <<-MD
The titles will be sorted by the default sorting method for the logged-in user. Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
- Supply the `percentage` query parameter to include the reading progress
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
- When `depth` is 0, returns the requested title without its sub-titles/entries
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
- When `depth` is negative, returns the requested title and all sub-titles/entries in it
MD MD
Koa.query "slim" Koa.query "slim"
Koa.query "depth"
Koa.query "percentage"
Koa.response 200, schema: { Koa.response 200, schema: {
"dir" => String, "dir" => String,
"titles" => ["title"], "titles" => ["title"],
"title_percentage" => [Float64?],
} }
Koa.tag "library" Koa.tag "library"
get "/api/library" do |env| get "/api/library" do |env|
username = get_username env if env.params.query["slim"]?
send_json env, Library.default.to_slim_json
sort_opt = SortOptions.from_info_json Library.default.dir, username else
send_json env, Library.default.to_json
slim = !env.params.query["slim"]?.nil?
depth = env.params.query["depth"]?.try(&.to_i?) || -1
percentage = !env.params.query["percentage"]?.nil?
send_json env, Library.default.build_json(slim: slim, depth: depth,
sort_context: {username: username,
opt: sort_opt}, percentage: percentage)
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the continue reading entries"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"entries" => ["entry"],
"entry_percentages" => [Float64],
}
Koa.tag "library"
get "/api/library/continue_reading" do |env|
username = get_username env
cr_entries = Library.default.get_continue_reading_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "entries" do
j.array do
cr_entries.each do |e|
j.raw e[:entry].build_json
end
end
end
j.field "entry_percentages" do
j.array do
cr_entries.each do |e|
j.number e[:percentage]
end
end
end
end
end end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the start reading titles"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library/start_reading" do |env|
username = get_username env
titles = Library.default.get_start_reading_titles username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "titles" do
j.array do
titles.each do |t|
j.raw t.build_json depth: 1
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the recently added items"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"items" => [{
"item" => "title | entry",
"percentage" => Float64,
"count" => Int32,
}],
}
Koa.tag "library"
get "/api/library/recently_added" do |env|
username = get_username env
ra_entries = Library.default.get_recently_added_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "items" do
j.array do
ra_entries.each do |e|
j.object do
j.field "item" do
if e[:grouped_count] === 1
j.raw e[:entry].build_json
else
j.raw e[:entry].book.build_json depth: 0
end
end
j.field "percentage" do
j.number e[:percentage]
end
j.field "count" do
j.number e[:grouped_count]
end
end
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end end
Koa.describe "Triggers a library scan" Koa.describe "Triggers a library scan"
@@ -502,7 +192,7 @@ struct APIRouter
} }
get "/api/admin/thumbnail_progress" do |env| get "/api/admin/thumbnail_progress" do |env|
send_json env, { send_json env, {
"progress" => Library.default.thumbnail_ctx.progress, "progress" => Library.default.thumbnail_generation_progress,
}.to_json }.to_json
end end
@@ -512,7 +202,6 @@ struct APIRouter
spawn do spawn do
Library.default.generate_thumbnails Library.default.generate_thumbnails
end end
send_text env, ""
end end
Koa.describe "Deletes a user with `username`" Koa.describe "Deletes a user with `username`"
@@ -634,35 +323,55 @@ struct APIRouter
end end
end end
Koa.describe "Sets the sort title of a title or an entry", <<-MD Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
When `eid` is provided, apply the sort title to the entry. Otherwise, apply the sort title to the title identified by `tid`. On error, returns a JSON that contains the error message in the `error` field.
MD MD
Koa.tags ["admin", "library"] Koa.tags ["admin", "mangadex"]
Koa.path "tid", desc: "Title ID" Koa.path "id", desc: "A MangaDex manga ID"
Koa.query "eid", desc: "Entry ID", required: false Koa.response 200, schema: "mdManga"
Koa.query "name", desc: "The new sort title" get "/api/admin/mangadex/manga/:id" do |env|
Koa.response 200, schema: "result"
put "/api/admin/sort_title/:tid" do |env|
username = get_username env
begin begin
title = (Library.default.get_title env.params.url["tid"]) id = env.params.url["id"]
.not_nil! manga = MangaDex::Client.from_config.manga id
name = env.params.query["name"]? send_json env, manga.to_info_json
entry = env.params.query["eid"]?
if entry.nil?
title.set_sort_title name, username
else
eobj = title.get_entry entry
eobj.set_sort_title name, username unless eobj.nil?
end
rescue e rescue e
Logger.error e Logger.error e
send_json env, {"error" => e.message}.to_json
end
end
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tags ["admin", "mangadex", "downloader"]
Koa.body schema: {
"chapters" => ["mdChapter"],
}
Koa.response 200, schema: {
"success" => Int32,
"fail" => Int32,
}
post "/api/admin/mangadex/download" do |env|
begin
chapters = env.params.json["chapters"].as(Array).map &.as_h
jobs = chapters.map { |chapter|
Queue::Job.new(
chapter["id"].as_i64.to_s,
chapter["mangaId"].as_i64.to_s,
chapter["full_title"].as_s,
chapter["mangaTitle"].as_s,
Queue::JobStatus::Pending,
Time.unix chapter["timestamp"].as_i64
)
}
inserted_count = Queue.default.push jobs
send_json env, { send_json env, {
"success" => false, "success": inserted_count,
"error" => e.message, "fail": jobs.size - inserted_count,
}.to_json }.to_json
else rescue e
send_json env, {"success" => true}.to_json Logger.error e
send_json env, {"error" => e.message}.to_json
end end
end end
@@ -871,15 +580,13 @@ struct APIRouter
"version" => Int32, "version" => Int32,
"settings" => {} of String => String, "settings" => {} of String => String,
}, },
"subscribable" => Bool,
} }
get "/api/admin/plugin/info" do |env| get "/api/admin/plugin/info" do |env|
begin begin
plugin = Plugin.new env.params.query["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
send_json env, { send_json env, {
"success" => true, "success" => true,
"info" => plugin.info, "info" => plugin.info,
"subscribable" => plugin.can_subscribe?,
}.to_json }.to_json
rescue e rescue e
Logger.error e Logger.error e
@@ -923,118 +630,6 @@ struct APIRouter
end end
end end
Koa.describe "Creates a new subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"manga" => String,
"manga_id" => String,
"name" => String,
"filters" => ["filter"],
}
Koa.response 200, schema: "result"
post "/api/admin/plugin/subscriptions" do |env|
begin
plugin_id = env.params.json["plugin"].as String
manga_title = env.params.json["manga"].as String
manga_id = env.params.json["manga_id"].as String
filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f|
Filter.from_json f.to_json
end
name = env.params.json["name"].as String
sub = Subscription.new plugin_id, manga_id, manga_title, name
sub.filters = filters
plugin = Plugin.new plugin_id
plugin.subscribe sub
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the list of subscriptions for a plugin"
Koa.tags ["admin", "downloader", "subscription"]
Koa.query "plugin", desc: "The ID of the plugin"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"subscriptions" => ["subscription"],
}
get "/api/admin/plugin/subscriptions" do |env|
begin
pid = env.params.query["plugin"].as String
send_json env, {
"success" => true,
"subscriptions" => Plugin.new(pid).list_subscriptions,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"subscription" => String,
}
Koa.response 200, schema: "result"
delete "/api/admin/plugin/subscriptions" do |env|
begin
pid = env.params.query["plugin"].as String
sid = env.params.query["subscription"].as String
Plugin.new(pid).unsubscribe sid
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Checks for updates for a subscription"
Koa.tags ["admin", "downloader", "subscription"]
Koa.body schema: {
"plugin" => String,
"subscription" => String,
}
Koa.response 200, schema: "result"
post "/api/admin/plugin/subscriptions/update" do |env|
pid = env.params.query["plugin"].as String
sid = env.params.query["subscription"].as String
Plugin.new(pid).check_subscription sid
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Lists the chapters in a title from a plugin" Koa.describe "Lists the chapters in a title from a plugin"
Koa.tags ["admin", "downloader"] Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String Koa.query "plugin", schema: String
@@ -1099,7 +694,7 @@ struct APIRouter
jobs = chapters.map { |ch| jobs = chapters.map { |ch|
Queue::Job.new( Queue::Job.new(
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}", "#{plugin.info.id}-#{ch["id"]}",
"", # manga_id "", # manga_id
ch["title"].as_s, ch["title"].as_s,
manga_title, manga_title,
@@ -1133,41 +728,21 @@ struct APIRouter
"height" => Int32, "height" => Int32,
}], }],
} }
Koa.response 304, "Not modified (only available when `If-None-Match` is set)"
get "/api/dimensions/:tid/:eid" do |env| get "/api/dimensions/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
if entry.is_a? DirEntry sizes = entry.page_dimensions
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size) send_json env, {
else "success" => true,
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s) "dimensions" => sizes,
end }.to_json
e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag
env.response.status_code = 304
send_text env, ""
else
sizes = entry.page_dimensions
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control
send_json env, {
"success" => true,
"dimensions" => sizes,
}.to_json
end
rescue e rescue e
Logger.error e Logger.error e
send_json env, { send_json env, {
@@ -1188,11 +763,10 @@ struct APIRouter
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil! entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.path send_attachment env, entry.zip_path
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
send_text env, e.message
end end
end end
@@ -1427,6 +1001,115 @@ struct APIRouter
end end
end end
Koa.describe "Logs the current user into their MangaDex account", <<-MD
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
MD
Koa.body schema: {
"username" => String,
"password" => String,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
post "/api/admin/mangadex/login" do |env|
begin
username = env.params.json["username"].as String
password = env.params.json["password"].as String
mango_username = get_username env
client = MangaDex::Client.from_config
client.auth username, password
Storage.default.save_md_token mango_username, client.token.not_nil!,
client.token_expires
send_json env, {
"success" => true,
"error" => nil,
"expires" => client.token_expires.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
get "/api/admin/mangadex/expires" do |env|
begin
username = get_username env
_, expires = Storage.default.get_md_token username
send_json env, {
"success" => true,
"error" => nil,
"expires" => expires.try &.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
Returns an empty list if the current user hasn't logged in to MangaDex.
MD
Koa.query "query"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"manga?" => [{
"id" => Int64,
"title" => String,
"description" => String,
"mainCover" => String,
}],
}
Koa.tags ["admin", "mangadex"]
get "/api/admin/mangadex/search" do |env|
begin
username = get_username env
token, expires = Storage.default.get_md_token username
unless expires && token
raise "No token found for user #{username}"
end
client = MangaDex::Client.from_config
client.token = token
client.token_expires = expires
query = env.params.query["query"]
send_json env, {
"success" => true,
"error" => nil,
"manga" => client.partial_search query,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate doc = Koa.generate
@@api_json = doc.to_json if doc @@api_json = doc.to_json if doc

View File

@@ -41,7 +41,7 @@ struct MainRouter
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json Library.default.dir, username sort_opt = SortOptions.from_info_json Library.default.dir, username
get_and_save_sort_opt Library.default.dir get_sort_opt
titles = Library.default.sorted_titles username, sort_opt titles = Library.default.sorted_titles username, sort_opt
percentage = titles.map &.load_percentage username percentage = titles.map &.load_percentage username
@@ -59,18 +59,12 @@ struct MainRouter
username = get_username env username = get_username env
sort_opt = SortOptions.from_info_json title.dir, username sort_opt = SortOptions.from_info_json title.dir, username
get_and_save_sort_opt title.dir get_sort_opt
sorted_titles = title.sorted_titles username, sort_opt
entries = title.sorted_entries username, sort_opt entries = title.sorted_entries username, sort_opt
percentage = title.load_percentage_for_all_entries username, sort_opt percentage = title.load_percentage_for_all_entries username, sort_opt
title_percentage = title.titles.map &.load_percentage username title_percentage = title.titles.map &.load_percentage username
title_percentage_map = {} of String => Float64
title_percentage.each_with_index do |tp, i|
t = title.titles[i]
title_percentage_map[t.id] = tp
end
layout "title" layout "title"
rescue e rescue e
Logger.error e Logger.error e
@@ -78,6 +72,11 @@ struct MainRouter
end end
end end
get "/download" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/download/plugins" do |env| get "/download/plugins" do |env|
begin begin
layout "plugin-download" layout "plugin-download"

View File

@@ -53,7 +53,6 @@ struct ReaderRouter
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
Logger.error e Logger.error e
Logger.debug e.backtrace?
env.response.status_code = 404 env.response.status_code = 404
end end
end end

View File

@@ -25,17 +25,6 @@ class Server
APIRouter.new APIRouter.new
OPDSRouter.new OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env|
cors
halt env
end
{% end %}
static_headers do |response|
response.headers.add("Access-Control-Allow-Origin", "*")
end
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
add_handler AuthHandler.new add_handler AuthHandler.new

View File

@@ -342,67 +342,6 @@ class Storage
end end
end end
def get_title_sort_title(title_id : String)
sort_title = nil
MainFiber.run do
get_db do |db|
sort_title =
db.query_one? "Select sort_title from titles where id = (?)",
title_id, as: String | Nil
end
end
sort_title
end
def set_title_sort_title(title_id : String, sort_title : String | Nil)
sort_title = nil if sort_title == ""
MainFiber.run do
get_db do |db|
db.exec "update titles set sort_title = (?) where id = (?)",
sort_title, title_id
end
end
end
def get_entry_sort_title(entry_id : String)
sort_title = nil
MainFiber.run do
get_db do |db|
sort_title =
db.query_one? "Select sort_title from ids where id = (?)",
entry_id, as: String | Nil
end
end
sort_title
end
def get_entries_sort_title(ids : Array(String))
results = Hash(String, String | Nil).new
MainFiber.run do
get_db do |db|
db.query "select id, sort_title from ids where id in " \
"(#{ids.join "," { |id| "'#{id}'" }})" do |rs|
rs.each do
id = rs.read String
sort_title = rs.read String | Nil
results[id] = sort_title
end
end
end
end
results
end
def set_entry_sort_title(entry_id : String, sort_title : String | Nil)
sort_title = nil if sort_title == ""
MainFiber.run do
get_db do |db|
db.exec "update ids set sort_title = (?) where id = (?)",
sort_title, entry_id
end
end
end
def save_thumbnail(id : String, img : Image) def save_thumbnail(id : String, img : Image)
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
@@ -489,21 +428,12 @@ class Storage
end end
end end
# Mark titles and entries that no longer exist on the file system as def mark_unavailable
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
# only checks the existence of the candidate titles/entries to speed up
# the process.
def mark_unavailable(ids_candidates : Array(String)?,
titles_candidates : Array(String)?)
MainFiber.run do MainFiber.run do
get_db do |db| get_db do |db|
# Detect dangling entry IDs # Detect dangling entry IDs
trash_ids = [] of String trash_ids = [] of String
query = "select path, id from ids where unavailable = 0" db.query "select path, id from ids where unavailable = 0" do |rs|
unless ids_candidates.nil?
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
end
db.query query do |rs|
rs.each do rs.each do
path = rs.read String path = rs.read String
fullpath = Path.new(path).expand(Config.current.library_path).to_s fullpath = Path.new(path).expand(Config.current.library_path).to_s
@@ -519,11 +449,7 @@ class Storage
# Detect dangling title IDs # Detect dangling title IDs
trash_titles = [] of String trash_titles = [] of String
query = "select path, id from titles where unavailable = 0" db.query "select path, id from titles where unavailable = 0" do |rs|
unless titles_candidates.nil?
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
end
db.query query do |rs|
rs.each do rs.each do
path = rs.read String path = rs.read String
fullpath = Path.new(path).expand(Config.current.library_path).to_s fullpath = Path.new(path).expand(Config.current.library_path).to_s
@@ -619,20 +545,6 @@ class Storage
{token, expires} {token, expires}
end end
def count_titles : Int32
count = 0
MainFiber.run do
get_db do |db|
db.query "select count(*) from titles" do |rs|
rs.each do
count = rs.read Int32
end
end
end
end
count
end
def close def close
MainFiber.run do MainFiber.run do
unless @db.nil? unless @db.nil?

83
src/subscription.cr Normal file
View File

@@ -0,0 +1,83 @@
require "db"
require "json"
struct Subscription
include DB::Serializable
include JSON::Serializable
getter id : Int64 = 0
getter username : String
getter manga_id : Int64
property language : String?
property group_id : Int64?
property min_volume : Int64?
property max_volume : Int64?
property min_chapter : Int64?
property max_chapter : Int64?
@[DB::Field(key: "last_checked")]
@[JSON::Field(key: "last_checked")]
@raw_last_checked : Int64
@[DB::Field(key: "created_at")]
@[JSON::Field(key: "created_at")]
@raw_created_at : Int64
def last_checked : Time
Time.unix @raw_last_checked
end
def created_at : Time
Time.unix @raw_created_at
end
def initialize(@manga_id, @username)
@raw_created_at = Time.utc.to_unix
@raw_last_checked = Time.utc.to_unix
end
private def in_range?(value : String, lowerbound : Int64?,
upperbound : Int64?) : Bool
lb = lowerbound.try &.to_f64
ub = upperbound.try &.to_f64
return true if lb.nil? && ub.nil?
v = value.to_f64?
return false unless v
if lb.nil?
v <= ub.not_nil!
elsif ub.nil?
v >= lb.not_nil!
else
v >= lb.not_nil! && v <= ub.not_nil!
end
end
def match?(chapter : MangaDex::Chapter) : Bool
if chapter.manga_id != manga_id ||
(language && chapter.language != language) ||
(group_id && !chapter.groups.map(&.id).includes? group_id)
return false
end
in_range?(chapter.volume, min_volume, max_volume) &&
in_range?(chapter.chapter, min_chapter, max_chapter)
end
def check_for_updates : Int32
Logger.debug "Checking updates for subscription with ID #{id}"
jobs = [] of Queue::Job
get_client(username).user.updates_after last_checked do |chapter|
next unless match? chapter
jobs << chapter.to_job
end
Storage.default.update_subscription_last_checked id
count = Queue.default.push jobs
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
count
rescue e
Logger.error "Error occurred when checking updates for " \
"subscription with ID #{id}. #{e}"
0
end
end

View File

@@ -19,7 +19,7 @@ class File
# information as long as the above changes do not happen together with # information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between. # a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64 def self.signature(filename) : UInt64
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename) if is_supported_file filename
File.info(filename).inode File.info(filename).inode
else else
0u64 0u64
@@ -48,49 +48,4 @@ class Dir
end end
Digest::CRC32.checksum(signatures.sort.join).to_u64 Digest::CRC32.checksum(signatures.sort.join).to_u64
end end
# Returns the contents signature of the directory at dirname for checking
# to rescan.
# Rescan conditions:
# - When a file added, moved, removed, renamed (including which in nested
# directories)
def self.contents_signature(dirname, cache = {} of String => String) : String
return cache[dirname] if cache[dirname]?
Fiber.yield
signatures = [] of String
self.open dirname do |dir|
dir.entries.sort.each do |fn|
next if fn.starts_with? "."
path = File.join dirname, fn
if File.directory? path
signatures << Dir.contents_signature path, cache
else
# Only add its signature value to `signatures` when it is a
# supported file
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
signatures << fn
end
end
Fiber.yield
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname] = hash
hash
end
def self.directory_entry_signature(dirname, cache = {} of String => String)
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
Fiber.yield
signatures = [] of String
image_files = DirEntry.sorted_image_files dirname
if image_files.size > 0
image_files.each do |path|
signatures << File.signature(path).to_s
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname + "?entry"] = hash
hash
end
end end

View File

@@ -1,19 +1,8 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8 ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
/manifest.json) SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
image/jxl
)
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
@@ -46,12 +35,6 @@ def register_mime_types
# FontAwesome fonts # FontAwesome fonts
".woff" => "font/woff", ".woff" => "font/woff",
".woff2" => "font/woff2", ".woff2" => "font/woff2",
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
# defiend by Crystal in `MIME.DEFAULT_TYPES`
".apng" => "image/apng",
".avif" => "image/avif",
".jxl" => "image/jxl",
}.each do |k, v| }.each do |k, v|
MIME.register k, v MIME.register k, v
end end
@@ -61,10 +44,6 @@ def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end end
def is_supported_image_file(path)
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
end
struct Int struct Int
def or(other : Int) def or(other : Int)
if self == 0 if self == 0
@@ -96,56 +75,37 @@ class String
end end
end end
def env_is_true?(key : String, default : Bool = false) : Bool def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]? val = ENV[key.upcase]? || ENV[key.downcase]?
return default unless val return false unless val
val.downcase.in? "1", "true" val.downcase.in? "1", "true"
end end
def sort_titles(titles : Array(Title), opt : SortOptions, username : String) def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt ary = titles
cached_titles = LRUCache.get cache_key
return cached_titles if cached_titles.is_a? Array(Title)
case opt.method case opt.method
when .time_modified? when .time_modified?
ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \ ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.sort_title, b.sort_title } compare_numerically a.title, b.title }
when .progress? when .progress?
ary = titles.sort do |a, b| ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \ (a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.sort_title, b.sort_title compare_numerically a.title, b.title
end
when .title?
ary = titles.sort do |a, b|
compare_numerically a.sort_title, b.sort_title
end end
else else
unless opt.method.auto? unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead" "Auto instead"
end end
ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title } ary.sort! { |a, b| compare_numerically a.title, b.title }
end end
ary.reverse! unless opt.not_nil!.ascend ary.reverse! unless opt.not_nil!.ascend
LRUCache.set generate_cache_entry cache_key, ary
ary ary
end end
def remove_sorted_titles_cache(titles : Array(Title),
sort_methods : Array(SortMethod),
username : String)
[false, true].each do |ascend|
sort_methods.each do |sort_method|
sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
titles, SortOptions.new(sort_method, ascend)
LRUCache.invalidate sorted_titles_cache_key
end
end
end
class String class String
# Returns the similarity (in [0, 1]) of two paths. # Returns the similarity (in [0, 1]) of two paths.
# For the two paths, separate them into arrays of components, count the # For the two paths, separate them into arrays of components, count the
@@ -160,31 +120,3 @@ class String
match / s.size match / s.size
end end
end end
# Does the followings:
# - turns space-like characters into the normal whitespaces ( )
# - strips and collapses spaces
# - removes ASCII control characters
# - replaces slashes (/) with underscores (_)
# - removes leading dots (.)
# - removes the following special characters: \:*?"<>|
#
# If the sanitized string is empty, returns a random string instead.
def sanitize_filename(str : String) : String
sanitized = str
.gsub(/\s+/, " ")
.strip
.gsub(/\//, "_")
.gsub(/^[\.\s]+/, "")
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
sanitized.size > 0 ? sanitized : random_str
end
def delete_cache_and_exit(path : String)
File.delete path
Logger.fatal "Invalid library cache deleted. Mango needs to " \
"perform a full reset to recover from this. " \
"Please restart Mango. This is NOT a bug."
Logger.fatal "Exiting"
exit 1
end

View File

@@ -39,28 +39,13 @@ macro send_error_page(msg)
end end
macro send_img(env, img) macro send_img(env, img)
cors
send_file {{env}}, {{img}}.data, {{img}}.mime send_file {{env}}, {{img}}.data, {{img}}.mime
end end
def get_token_from_auth_header(env) : String?
value = env.request.headers["Authorization"]
if value && value.starts_with? "Bearer"
session_id = value.split(" ")[1]
return Kemal::Session.get(session_id).try &.string? "token"
end
end
macro get_username(env) macro get_username(env)
begin begin
# Check if we can get the session id from the cookie token = env.session.string "token"
token = env.session.string? "token" (Storage.default.verify_token token).not_nil!
if token.nil?
# If not, check if we can get the session id from the auth header
token = get_token_from_auth_header env
end
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
(Storage.default.verify_token token.not_nil!).not_nil!
rescue e rescue e
if Config.current.disable_login if Config.current.disable_login
Config.current.default_username Config.current.default_username
@@ -72,29 +57,12 @@ macro get_username(env)
end end
end end
macro cors
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
"DELETE,OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
"Authorization"
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
def send_json(env, json) def send_json(env, json)
cors
env.response.content_type = "application/json" env.response.content_type = "application/json"
env.response.print json env.response.print json
end end
def send_text(env, text)
cors
env.response.content_type = "text/plain"
env.response.print text
end
def send_attachment(env, path) def send_attachment(env, path)
cors
send_file env, path, filename: File.basename(path), disposition: "attachment" send_file env, path, filename: File.basename(path), disposition: "attachment"
end end
@@ -139,26 +107,6 @@ macro get_sort_opt
end end
end end
macro get_and_save_sort_opt(dir)
sort_method = env.params.query["sort"]?
if sort_method
is_ascending = true
ascend = env.params.query["ascend"]?
if ascend && ascend.to_i? == 0
is_ascending = false
end
sort_opt = SortOptions.new sort_method, is_ascending
TitleInfo.new {{dir}} do |info|
info.sort_by[username] = sort_opt.to_tuple
info.save
end
end
end
module HTTP module HTTP
class Client class Client
private def self.exec(uri : URI, tls : TLSContext = nil) private def self.exec(uri : URI, tls : TLSContext = nil)

View File

@@ -33,6 +33,7 @@
<option>System</option> <option>System</option>
</select> </select>
</li> </li>
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
</ul> </ul>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
@@ -40,6 +41,5 @@
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a> <a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
<% content_for "script" do %> <% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/admin.js"></script> <script src="<%= base_url %>js/admin.js"></script>
<% end %> <% end %>

View File

@@ -61,9 +61,7 @@
<% if page == "home" && item.is_a? Entry %> <% if page == "home" && item.is_a? Entry %>
<%= "uk-margin-remove-bottom" %> <%= "uk-margin-remove-bottom" %>
<% end %> <% end %>
" data-title="<%= HTML.escape(item.display_name) %>" " data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
data-file-title="<%= HTML.escape(item.title || "") %>"
data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %>
</h3> </h3>
<% if page == "home" && item.is_a? Entry %> <% if page == "home" && item.is_a? Entry %>
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a> <a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>

View File

@@ -6,7 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/mango.css" /> <link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico"> <link rel="icon" href="<%= base_url %>favicon.ico">
<link rel="manifest" href="<%= base_url %>manifest.json">
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script> <script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>

View File

@@ -5,55 +5,63 @@
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
</div> </div>
<table class="uk-table uk-table-striped uk-overflow-auto"> <div class="uk-overflow-auto">
<thead> <table class="uk-table uk-table-striped">
<tr> <thead>
<th>Chapter</th> <tr>
<th>Manga</th> <th>Chapter</th>
<th>Progress</th> <th>Manga</th>
<th>Time</th> <th>Progress</th>
<th>Status</th> <th>Time</th>
<th>Plugin</th> <th>Status</th>
<th>Actions</th> <th>Plugin</th>
</tr> <th>Actions</th>
</thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
</template>
</td>
</tr> </tr>
</template> </thead>
</tbody> <tbody>
</table> <template x-for="job in jobs" :key="job">
</div> <tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>

162
src/views/download.html.ecr Normal file
View File

@@ -0,0 +1,162 @@
<h2 class=uk-title>Download from MangaDex</h2>
<div x-data="downloadComponent()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-width-expand">
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
</div>
<div class="uk-width-auto">
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
</div>
</div>
<template x-if="mangaAry">
<div>
<p x-show="mangaAry.length === 0">No matching manga found.</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<template x-for="manga in mangaAry" :key="manga.id">
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="manga.mainCover">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div x-show="data && data.chapters" x-cloak>
<div class"uk-grid-small" uk-grid>
<div class="uk-width-1-4@s">
<img :src="data.mainCover">
</div>
<div class="uk-width-1-4@s">
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
<p x-text="`Artist: ${data.artist}`"></p>
<p x-text="`Author: ${data.author}`"></p>
</div>
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
<div class="uk-margin">
<label class="uk-form-label">Language</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
<template x-for="lang in languages" :key="lang">
<option x-text="lang"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Group</label>
<div class="uk-form-controls">
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
<template x-for="group in groups" :key="group">
<option x-text="group"></option>
</template>
</select>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Volume</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Chapter</label>
<div class="uk-form-controls">
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
</div>
</div>
</div>
</div>
<div class="uk-margin">
<div class="uk-margin">
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
</div>
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
</div>
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Language</th>
<th>Group</th>
<th>Volume</th>
<th>Chapter</th>
<th>Timestamp</th>
</tr>
</thead>
<template x-if="chapters.length <= chaptersLimit">
<tbody id="selectable">
<template x-for="chp in chapters" :key="chp">
<tr class="ui-widget-content">
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
<td x-text="chp.title"></td>
<td x-text="chp.language"></td>
<td>
<template x-for="grp in Object.entries(chp.groups)">
<div>
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
</div>
</template>
</td>
<td x-text="chp.volume"></td>
<td x-text="chp.chapter"></td>
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
</tr>
</template>
</tbody>
</template>
</table>
</div>
<div id="modal" class="uk-flex-top" uk-modal="container: false">
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
</div>
<div class="uk-modal-body">
<div class="uk-grid">
<div class="uk-width-1-3@s">
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
</div>
<div class="uk-width-2-3@s" uk-overflow-auto>
<p x-text="candidateManga.description"></p>
</div>
</div>
</div>
<div class="uk-modal-footer">
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<%= render_component "jquery-ui" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/download.js"></script>
<% end %>

View File

@@ -1,89 +1,89 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
<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-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav> <ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li> <li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent"> <li class="uk-parent">
<a href="#">Download</a> <a href="#">Download</a>
<ul class="uk-nav-sub"> <ul class="uk-nav-sub">
<li><a href="<%= base_url %>download/plugins">Plugins</a></li> <li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li> <li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li> <li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul> </ul>
</li> </li>
<% end %> <% 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="<%= base_url %>logout">Logout</a></li> <li><a href="<%= base_url %>logout">Logout</a></li>
</ul> </ul>
</div> </div>
</div>
</div>
</div>
<div class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@m">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@m">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icons/icon.png" style="width:90px;height:90px;"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
</ul>
</div> </div>
</li> </div>
<% end %>
</ul>
</div> </div>
<div class="uk-navbar-right uk-visible@m"> <div class="uk-position-top">
<ul class="uk-navbar-nav"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <div class="uk-navbar-left uk-hidden@s">
<li><a href="<%= base_url %>logout">Logout</a></li> <div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</ul> </div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li>
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</div>
</li>
<% end %>
</ul>
</div>
<div class="uk-navbar-right uk-visible@s">
<ul class="uk-navbar-nav">
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
</div> </div>
</div> <div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small" style="position:relative;">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div> </div>
</div> <div class="uk-section uk-section-small" style="position:relative;">
</div> <div class="uk-container uk-container-small">
<script> <div id="alert"></div>
setTheme(); <%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div>
</div>
</div>
<script>
setTheme();
const base_url = "<%= base_url %>"; const base_url = "<%= base_url %>";
</script> </script>
<%= render_component "uikit" %> <%= render_component "uikit" %>
<%= yield_content "script" %> <%= yield_content "script" %>
</body> </body>
</html> </html>

View File

@@ -10,7 +10,6 @@
<div class="uk-margin-bottom uk-width-1-4@s"> <div class="uk-margin-bottom uk-width-1-4@s">
<% hash = { <% hash = {
"auto" => "Auto", "auto" => "Auto",
"title" => "Name",
"time_modified" => "Date Modified", "time_modified" => "Date Modified",
"progress" => "Progress" "progress" => "Progress"
} %> } %>

View File

@@ -0,0 +1,39 @@
<div x-data="component()" x-init="init()">
<h2 class="uk-title">Connect to MangaDex</h2>
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
<div class="uk-width-1-2@s" x-show="!expires">
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
<ul>
<li>Search MangaDex by search terms in addition to manga IDs</li>
<li>Automatically download new chapters when they are available (coming soon)</li>
</ul>
</div>
<div class="uk-width-1-2@s" x-show="expires">
<p>
<span x-show="!expired">You have logged in to MangaDex!</span>
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
<span x-show="!expired">If the integration is not working, you</span>
<span x-show="expired">You</span>
can log in again and the token will be updated.
</p>
</div>
<div class="uk-width-1-2@s">
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin">
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
</div>
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/mangadex.js"></script>
<% end %>

View File

@@ -3,34 +3,36 @@
<div x-show="!empty"> <div x-show="!empty">
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p> <p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button> <button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
<table class="uk-table uk-table-striped uk-overflow-auto"> <div class="uk-overflow-auto">
<thead> <table class="uk-table uk-table-striped">
<tr> <thead>
<th>Type</th> <tr>
<th>Relative Path</th> <th>Type</th>
<th>ID</th> <th>Relative Path</th>
<th>Actions</th> <th>ID</th>
</tr> <th>Actions</th>
</thead>
<tbody>
<template x-for="title in titles" :key="title">
<tr :id="`title-${title.id}`">
<td>Title</td>
<td x-text="title.path"></td>
<td x-text="title.id"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td>
</tr> </tr>
</template> </thead>
<template x-for="entry in entries" :key="entry"> <tbody>
<tr :id="`entry-${entry.id}`"> <template x-for="title in titles" :key="title">
<td>Entry</td> <tr :id="`title-${title.id}`">
<td x-text="entry.path"></td> <td>Title</td>
<td x-text="entry.id"></td> <td x-text="title.path"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td> <td x-text="title.id"></td>
</tr> <td><a @click="rm($event)" uk-icon="trash"></a></td>
</template> </tr>
</tbody> </template>
</table> <template x-for="entry in entries" :key="entry">
<tr :id="`entry-${entry.id}`">
<td>Entry</td>
<td x-text="entry.path"></td>
<td x-text="entry.id"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />

View File

@@ -46,7 +46,7 @@
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx"> <template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
<dl class="uk-description-list" id="toggle" hidden> <dl class="uk-description-list" id="toggle" hidden>
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt> <dt x-text="entry[0]"></dt>
<dd x-text="entry[1]"></dd> <dd x-text="entry[1]"></dd>
</dl> </dl>
</template> </template>
@@ -114,10 +114,10 @@
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid> <div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s"> <div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min"> <input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
</div> </div>
<div class="uk-width-1-2@s"> <div class="uk-width-1-2@s">
<input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-max"> <input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
</div> </div>
</div> </div>
@@ -133,10 +133,6 @@
</template> </template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button> <button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button> <button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span x-show="subscribable">
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
</form> </form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p> <p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
@@ -163,7 +159,7 @@
<template x-for="ch in chapters" :key="ch"> <template x-for="ch in chapters" :key="ch">
<tr class="ui-widget-content" :id="ch.id"> <tr class="ui-widget-content" :id="ch.id">
<template x-for="k in chapterKeys" :key="k"> <template x-for="k in chapterKeys" :key="k">
<td x-html="renderCell(ch[k])"></td> <td x-text="ch[k]"></td>
</template> </template>
</tr> </tr>
</template> </template>
@@ -175,42 +171,10 @@
</div> </div>
</div> </div>
</div> </div>
<div uk-modal="container:false" x-ref="modal">
<div class="uk-modal-dialog">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Confirmation</h2>
</div>
<div class="uk-modal-body">
<p>A subscription with the following filters with be created. All <strong>FUTURE</strong> chapters matching the filters will be automatically downloaded.</p>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in filterSettings" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p>Enter a meaningful name for the subscription to continue:</p>
<input class="uk-input" type="text" x-model="subscriptionName">
</div>
<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
<button class="uk-button uk-button-primary" type="button" :disabled="subscriptionName.trim().length === 0" @click="subscribe($refs.modal)">Confirm</button>
</div>
</div>
</div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "jquery-ui" %> <%= render_component "jquery-ui" %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script> <script src="<%= base_url %>js/plugin-download.js"></script>
<% end %> <% end %>

View File

@@ -5,7 +5,7 @@
<div> <div>
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3> <h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
</div> </div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p> <p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">

View File

@@ -5,7 +5,7 @@
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()"> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'"> <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@@ -19,9 +19,9 @@
</div> </div>
<div <div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;"> :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak> <div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-if="!loading && mode === 'continuous'" x-for="item in items"> <template x-for="item in items">
<img <img
uk-img uk-img
:class="{'uk-align-center': true, 'spine': item.width < 50}" :class="{'uk-align-center': true, 'spine': item.width < 50}"
@@ -30,7 +30,7 @@
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`" :style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="clickImage($event)" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
@@ -40,23 +40,20 @@
<%- end -%> <%- end -%>
</div> </div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`"> <div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{ <img uk-img :class="{
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${fitType === 'horz' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${fitType === 'vert' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
object-fit: contain;
`" /> `" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false ^ enableRightToLeft)"></div> <div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true ^ enableRightToLeft)"></div> <div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
</div> </div>
</div> </div>
@@ -67,7 +64,7 @@
<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 break-word"><%= entry.display_name %></h3> <h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <div class="uk-margin">
@@ -94,17 +91,6 @@
</div> </div>
</div> </div>
<div class="uk-margin" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="mode-select">Page fit</label>
<div class="uk-form-controls">
<select id="fit-select" class="uk-select" @change="fitChanged()">
<option value="vert">Fit height</option>
<option value="horz">Fit width</option>
<option value="real">Real size</option>
</select>
</div>
</div>
<div class="uk-margin" x-show="mode === 'continuous'"> <div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label> <label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls"> <div class="uk-form-controls">
@@ -112,25 +98,6 @@
</div> </div>
</div> </div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
<div class="uk-form-controls">
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
<div class="uk-form-controls">
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="enable-right-to-left">Right to Left</label>
<div class="uk-form-controls">
<input id="enable-right-to-left" class="uk-checkbox" type="checkbox" x-model="enableRightToLeft" @change="enableRightToLeftChanged()">
</div>
</div>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
<div class="uk-margin"> <div class="uk-margin">

View File

@@ -1,101 +0,0 @@
<h2 class=uk-title>Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
<h2>No Plugins Found</h2>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
</div>
<div x-show="plugins.length > 0" style="width:100%">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
<p x-show="subscriptions.length === 0" class="uk-text-meta">No subscriptions found.</p>
<div class="uk-overflow-auto" x-show="subscriptions.length > 0">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Name</th>
<th>Plugin ID</th>
<th>Manga Title</th>
<th>Created At</th>
<th>Last Checked</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr :sid="sub.id" @click="selected($event, $refs.modal)">
<td x-html="renderStrCell(sub.name)"></td>
<td x-html="renderStrCell(sub.plugin_id)"></td>
<td x-html="renderStrCell(sub.manga_title)"></td>
<td x-html="renderDateCell(sub.created_at)"></td>
<td x-html="renderDateCell(sub.last_checked)"></td>
<td>
<a @click.prevent.stop="actionHandler($event, 'delete')" uk-icon="trash" uk-tooltip="Delete" :disabled="loading"></a>
<a @click.prevent.stop="actionHandler($event, 'update')" uk-icon="refresh" uk-tooltip="Check for updates" :disabled="loading"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div uk-modal="container:false" x-ref="modal" class="uk-flex-top">
<div class="uk-modal-dialog uk-margin-auto-vertical uk-overflow-auto">
<div class="uk-modal-header">
<h2 class="uk-modal-title">Subscription Details</h2>
</div>
<div class="uk-modal-body">
<dl>
<dt>Name</dt>
<dd x-html="subscription && subscription.name"></dd>
<dt>Subscription ID</dt>
<dd x-html="subscription && subscription.id"></dd>
<dt>Plugin ID</dt>
<dd x-html="subscription && subscription.plugin_id"></dd>
<dt>Manga Title</dt>
<dd x-html="subscription && subscription.manga_title"></dd>
<dt>Manga ID</dt>
<dd x-html="subscription && subscription.manga_id"></dd>
<dt>Filters</dt>
</dl>
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<template x-for="ft in (subscription && subscription.filters || [])" :key="ft">
<tr x-html="renderFilterRow(ft)"></tr>
</template>
</tbody>
</table>
<p class="uk-text-right">
<button class="uk-button uk-button-default uk-modal-close" type="button">OK</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription-manager.js"></script>
<% end %>

View File

@@ -0,0 +1,54 @@
<h2 class="uk-title">MangaDex Subscription Manager</h2>
<div x-data="component()" x-init="init()">
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
<template x-if="subscriptions.length > 0">
<div class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<th>Manga ID</th>
<th>Language</th>
<th>Group ID</th>
<th>Volume Range</th>
<th>Chapter Range</th>
<th>Creator</th>
<th>Last Checked</th>
<th>Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="sub in subscriptions" :key="sub">
<tr>
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
<td x-text="sub.language || 'All'"></td>
<td>
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
<span x-show="!sub.group_id">All</span>
</td>
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
<td x-text="sub.username"></td>
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
<td :data-id="sub.id">
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<% content_for "script" do %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/subscription.js"></script>
<% end %>

View File

@@ -18,8 +18,7 @@
</div> </div>
</div> </div>
</div> </div>
<h2 class=uk-title data-file-title="<%= HTML.escape(title.title) %>" data-sort-title="<%= HTML.escape(title.sort_title_db || "") %>"> <h2 class=uk-title><span><%= title.display_name %></span>
<span><%= title.display_name %></span>
&nbsp; &nbsp;
<% if is_admin %> <% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a> <a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
@@ -60,8 +59,8 @@
</div> </div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% sorted_titles.each do |item| %> <% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage_map[item.id] %> <% progress = title_percentage[i] %>
<%= render_component "card" %> <%= render_component "card" %>
<% end %> <% end %>
</div> </div>
@@ -90,13 +89,6 @@
<input class="uk-input" type="text" name="display-name" id="display-name-field"> <input class="uk-input" type="text" name="display-name" id="display-name-field">
</div> </div>
</div> </div>
<div class="uk-margin">
<label class="uk-form-label" for="sort-title">Sort Title</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="sort-title" id="sort-title-field">
</div>
</div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label">Cover Image</label> <label class="uk-form-label">Cover Image</label>
<div class="uk-grid"> <div class="uk-grid">