Compare commits

...

78 Commits

Author SHA1 Message Date
Alex Ling
3c3549a489 Merge pull request #172 from hkalexling/hotfix/bind-localhost 2021-03-04 13:47:59 +08:00
Alex Ling
8160b0a18e Bump version to 0.20.2 2021-03-04 04:49:37 +00:00
Alex Ling
a7eff772be Update example config in README 2021-03-04 04:48:51 +00:00
Alex Ling
bf3900f9a2 Add host to config 2021-03-03 17:35:39 +00:00
Alex Ling
6fa575cf4f Bind localhost when a proxy auth header is set 2021-03-03 16:28:31 +00:00
Alex Ling
604c5d49a6 Merge pull request #166 from hkalexling/dev 2021-02-28 19:38:02 +08:00
Alex Ling
7449d19075 Bump version to 0.20.1 2021-02-26 10:35:34 +00:00
Alex Ling
c5c9305a0b Merge pull request #162 from hkalexling/all-contributors/add-davidkna
docs: add davidkna as a contributor
2021-02-14 23:30:02 +08:00
allcontributors[bot]
fdceab9060 docs: update .all-contributorsrc [skip ci] 2021-02-14 15:28:33 +00:00
allcontributors[bot]
c18591c5cf docs: update README.md [skip ci] 2021-02-14 15:28:32 +00:00
Alex Ling
bb5cb9b94c Merge pull request #161 from davidkna/docker-usr-local
Move binary in docker image to /usr/local
2021-02-14 23:26:38 +08:00
David Knaack
fb499a5caf Move binary in docker image to /usr/local 2021-02-14 11:42:00 +01:00
Alex Ling
154d85e197 Use only woff and woff2 2021-02-11 08:40:24 +00:00
Alex Ling
933617503e Optimize the static files
- Use webfont version of FontAwesome
- Use CDN for UIKit JS files
2021-02-10 16:24:34 +00:00
Alex Ling
31c6893bbb Display book spines in original size (fixes #152) 2021-02-06 13:37:25 +00:00
Alex Ling
171125e8ac Merge pull request #159 from Leeingnyo/fix/favicon-500-error
Fix HTTP 500 Error when accessing the favicon
2021-02-06 16:34:56 +08:00
Leeingnyo
d81334026b add MIME type of ico file
The server returns 500 error when requested '/favion.ico'
The handler worked fine, but send_file has failed with
- Missing MIME type for extension ".ico"
so I register mime type for .ico file
2021-02-06 16:58:49 +09:00
Alex Ling
2b3b2eb8ba Fill default configs before pre-processing 2021-02-03 05:27:41 +00:00
Alex Ling
ffd5f4454b Merge branch 'feature/auth-proxy' into dev 2021-02-03 05:23:00 +00:00
Alex Ling
cb25d7ba00 Merge branch 'feature/mangadex-api-upgrade' into dev 2021-02-03 05:22:35 +00:00
Alex Ling
3abd2924d0 Merge pull request #156 from hkalexling/dev
v0.20.0
2021-02-02 12:16:05 +08:00
Alex Ling
21233df754 Fix group filter on the download page 2021-02-01 11:37:00 +00:00
Alex Ling
c61eb7554e Update the mangadex shard 2021-02-01 11:35:16 +00:00
Alex Ling
edd9a2e093 Add MutationObserver polyfill 2021-01-31 15:32:38 +00:00
Alex Ling
1f50785e8f Rewrite MangaDex download page with Alpine 2021-01-31 12:48:37 +00:00
Alex Ling
70d418d1a1 Upgrade to MangaDex API v2 2021-01-30 17:08:04 +00:00
Alex Ling
45e20c94f9 Merge branch 'dev' into feature/auth-proxy 2021-01-30 10:55:27 +00:00
Alex Ling
ca8e9a164e Fix the /api page error when using base URL 2021-01-30 10:54:21 +00:00
Alex Ling
4da263c594 Rewrite auth_handler
Make sure the OPDS pages are accessible without login when login is
disabled
2021-01-30 10:54:03 +00:00
Alex Ling
d67a24809b Allow proxy authentication (#141) 2021-01-30 07:43:02 +00:00
Alex Ling
cd268af9dd Fix tags.css base URL 2021-01-30 07:39:54 +00:00
Alex Ling
135fa9fde6 Update sample config in README [skip ci] 2021-01-29 14:42:58 +00:00
Alex Ling
77333aaafd Bump version to 0.20.0 2021-01-29 10:27:31 +00:00
Alex Ling
1fad530331 Fix admin page theme setting syncing (#155) 2021-01-29 08:40:50 +00:00
Alex Ling
a1bd87098c Escape single quotes in migration 8 2021-01-28 12:41:51 +00:00
Alex Ling
a389fa7178 Allow delete all missing items (#151) 2021-01-28 09:55:41 +00:00
Alex Ling
b5db508005 Fix relative path mismatch (#151) 2021-01-28 04:04:42 +00:00
Alex Ling
30178c42ef Merge branch 'master' into dev 2021-01-27 09:47:49 +00:00
Alex Ling
b712db9e8f Merge pull request #154 from hkalexling/hkalexling-patch-1
Update autoapproval.yml
2021-01-27 16:28:05 +08:00
Alex Ling
dd9c75d1c9 Update autoapproval.yml 2021-01-27 16:17:33 +08:00
Alex Ling
2d150c3bf2 Create autoapproval.yml 2021-01-27 16:14:47 +08:00
Alex Ling
40f74ea375 Merge pull request #153 from hkalexling/hotfix/reader-bg
Fix incorrect background color on reader page
2021-01-27 15:19:04 +08:00
Alex Ling
adf260bc35 Bump version to v0.19.1 2021-01-27 06:33:45 +00:00
Alex Ling
432d6f0cd5 Run CI for hotfix/* branches 2021-01-27 06:33:45 +00:00
Alex Ling
3de314ae9a Fix incorrect background color on reader page 2021-01-27 06:33:45 +00:00
Alex Ling
c1c8cca877 Use Ameba to enforce max line width
Didn't know Ameba supports this!
2021-01-27 04:18:47 +00:00
Alex Ling
07965b98b7 Force File::Info#inode to return UInt64 2021-01-27 03:42:51 +00:00
Alex Ling
5779d225f6 Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-01-27 03:23:08 +00:00
Alex Ling
bf18a14016 Use inode number 2021-01-27 03:19:58 +00:00
Alex Ling
605dc61777 Merge pull request #150 from Leeingnyo/fix/allow-uppercase-extensions
Make file extension check case-insensitive
2021-01-26 19:16:12 +08:00
Alex Ling
def64d9f98 Rename interesting files to supported files 2021-01-26 10:55:50 +00:00
Leeingnyo
0ba2409c9a add tests about is_interesting_file 2021-01-26 04:18:09 +09:00
Leeingnyo
2b0cf41336 add and apply util method is_interesting_file 2021-01-26 04:17:32 +09:00
Leeingnyo
c51cb28df2 make filename extension downcase for comparing 2021-01-25 23:13:35 +09:00
Alex Ling
2b079c652d Fix duplicating options on the download page 2021-01-20 08:02:07 +00:00
Alex Ling
68050a9025 Fix incorrect dropdown color in dark mode 2021-01-20 05:20:03 +00:00
Alex Ling
54cd15d542 Mark items unavailable and retire DB optimization
This prepares us for the moving metadata to DB in the future
2021-01-19 15:09:38 +00:00
Alex Ling
781de97c68 Make thumbnail generation slower
This reduces the IO stress
2021-01-19 15:06:27 +00:00
Alex Ling
c7be0e0e7c Separate insert_id into titles and entries 2021-01-19 09:08:31 +00:00
Alex Ling
667d390be4 Signature matching 2021-01-19 08:43:45 +00:00
Alex Ling
7f76322377 Merge branch 'dev' into feature/signature 2021-01-18 06:54:38 +00:00
Alex Ling
377c4c6554 Stop the process when the server fails to start 2021-01-18 06:44:10 +00:00
Alex Ling
952aa0c6ca Fix linter 2021-01-17 15:59:42 +00:00
Alex Ling
bd81c2e005 Fix incorrect migration SQL 2021-01-17 15:58:13 +00:00
Alex Ling
b471ed2fa0 Upgrade MG 2021-01-17 15:49:10 +00:00
Alex Ling
7507ab64ad Bump version to v0.19.0 2021-01-17 08:34:35 +00:00
Alex Ling
e4587d36bc Fix linter 2021-01-17 08:25:01 +00:00
Alex Ling
7d6d3640ad Disable the tagging UI for non-admin users 2021-01-17 08:16:40 +00:00
Alex Ling
3071d44e32 Fix admin API bypassing 2021-01-17 08:10:43 +00:00
Alex Ling
7a09c9006a Set up foreign keys 2021-01-17 04:47:06 +00:00
Alex Ling
959560c7a7 Add titles and move insert_ids to class variable
This fixes the bug where the new ids are not saved
2021-01-17 04:45:55 +00:00
Alex Ling
ff679b30d8 Capitalize the UNIQUE keyword 2021-01-17 04:41:05 +00:00
Alex Ling
f7a360c2d8 Proper DB migration 2021-01-16 17:11:57 +00:00
Alex Ling
1065b430e3 Rewrite tagging UI with suggestions (#146) 2021-01-14 13:08:50 +00:00
Alex Ling
5abf7032a5 Use less 2021-01-14 13:04:57 +00:00
Alex Ling
18e8e88c66 Initial work on title signature 2021-01-14 08:23:39 +00:00
Alex Ling
44336c546a Bump version to v0.18.3 2021-01-12 10:14:12 +00:00
Alex Ling
a4c6e6611c Try WSS first, and fallback to WS (#144) 2021-01-12 10:13:06 +00:00
63 changed files with 1928 additions and 1138 deletions

View File

@@ -95,6 +95,15 @@
"contributions": [
"code"
]
},
{
"login": "davidkna",
"name": "David Knaack",
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
"profile": "https://github.com/davidkna",
"contributions": [
"infra"
]
}
],
"contributorsPerLine": 7,

View File

@@ -7,3 +7,8 @@ Lint/UnusedArgument:
- src/routes/*
Metrics/CyclomaticComplexity:
Enabled: false
Layout/LineLength:
Enabled: true
MaxLength: 80
Excluded:
- src/routes/api.cr

6
.github/autoapproval.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
from_owner:
- hkalexling
required_labels:
- autoapprove
apply_labels:
- autoapproved

View File

@@ -2,7 +2,7 @@ name: Build
on:
push:
branches: [ master, dev ]
branches: [ master, dev, hotfix/* ]
pull_request:
branches: [ master, dev ]

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ mango
public/css/uikit.css
public/img/*.svg
public/js/*.min.js
public/css/*.css
public/webfonts

View File

@@ -10,6 +10,6 @@ FROM library/alpine
WORKDIR /
COPY --from=builder /Mango/mango .
COPY --from=builder /Mango/mango /usr/local/bin/mango
CMD ["./mango"]
CMD ["/usr/local/bin/mango"]

View File

@@ -9,6 +9,7 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["/usr/local/bin/mango"]
CMD ["./mango"]

View File

@@ -9,6 +9,6 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]
CMD ["/usr/local/bin/mango"]

View File

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

View File

@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.18.2
Mango - Manga Server and Web Reader. Version 0.20.2
Usage:
@@ -75,6 +75,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml
---
host: 0.0.0.0
port: 9000
base_url: /
session_secret: mango-session-secret
@@ -82,7 +83,6 @@ library_path: ~/mango/library
db_path: ~/mango/mango.db
scan_interval_minutes: 5
thumbnail_generation_interval_hours: 24
db_optimization_interval_hours: 24
log_level: info
upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
@@ -90,9 +90,10 @@ download_timeout_seconds: 30
page_margin: 30
disable_login: false
default_username: ""
auth_proxy_header_name: ""
mangadex:
base_url: https://mangadex.org
api_url: https://mangadex.org/api
api_url: https://mangadex.org/api/v2
download_wait_seconds: 5
download_retries: 4
download_queue_db_path: ~/mango/queue.db
@@ -173,6 +174,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
<tr>
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">đź’»</a></td>
<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>
</tr>
</table>

View File

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

View File

@@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
// Copy libraries from node_moduels to public/js
gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
gulp.task('copy-img', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('copy-font', () => {
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.pipe(gulp.dest('public/webfonts'));
});
// Copy files from node_modules
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less
gulp.task('less', () => {
return gulp.src('public/css/*.less')
return gulp.src([
'public/css/mango.less',
'public/css/tags.less'
])
.pipe(less())
.pipe(gulp.dest('public/css'));
});
@@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
// Copy static files (includeing images) to dist
gulp.task('copy-files', () => {
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
return gulp.src([
'public/*.*',
'public/img/*',
'public/webfonts/*',
'public/js/*.min.js'
], {
base: 'public'
})
.pipe(gulp.dest('dist'));
});
// Set up the public folder for development
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
// Set up the dist folder for deployment
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));

View File

@@ -0,0 +1,85 @@
class ForeignKeys < MG::Base
def up : String
<<-SQL
-- add foreign key to tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag),
FOREIGN KEY (id) REFERENCES titles (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
-- add foreign key to thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
FOREIGN KEY (id) REFERENCES ids (id)
ON UPDATE CASCADE
ON DELETE CASCADE
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
-- remove foreign key from thumbnails
ALTER TABLE thumbnails RENAME TO tmp;
CREATE TABLE thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
INSERT INTO thumbnails
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
-- remove foreign key from tags
ALTER TABLE tags RENAME TO tmp;
CREATE TABLE tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
INSERT INTO tags
SELECT * FROM tmp;
DROP TABLE tmp;
CREATE INDEX tags_id_idx ON tags (id);
CREATE INDEX tags_tag_idx ON tags (tag);
SQL
end
end

19
migration/ids.2.cr Normal file
View File

@@ -0,0 +1,19 @@
class CreateIds < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
is_title INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
DROP TABLE ids;
SQL
end
end

View File

@@ -0,0 +1,50 @@
class IDSignature < MG::Base
def up : String
<<-SQL
ALTER TABLE ids ADD COLUMN signature TEXT;
SQL
end
def down : String
<<-SQL
-- remove signature column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL
);
INSERT INTO ids
SELECT path, id
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
-- 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);
SQL
end
end

View File

@@ -0,0 +1,33 @@
class RelativePath < MG::Base
def up : String
base = Config.current.library_path
# Escape single quotes in case the path contains them, and remove the
# trailing slash (this is a mistake, fixed in DB version 10)
base = base.gsub("'", "''").rstrip "/"
<<-SQL
-- update the path column in ids to relative paths
UPDATE ids
SET path = REPLACE(path, '#{base}', '');
-- update the path column in titles to relative paths
UPDATE titles
SET path = REPLACE(path, '#{base}', '');
SQL
end
def down : String
base = Config.current.library_path
base = base.gsub("'", "''").rstrip "/"
<<-SQL
-- update the path column in ids to absolute paths
UPDATE ids
SET path = '#{base}' || path;
-- update the path column in titles to absolute paths
UPDATE titles
SET path = '#{base}' || path;
SQL
end
end

View File

@@ -0,0 +1,31 @@
# In DB version 8, we replaced the absolute paths in DB with relative paths,
# but we mistakenly left the starting slashes. This migration removes them.
class RelativePathFix < MG::Base
def up : String
<<-SQL
-- remove leading slashes from the paths in ids
UPDATE ids
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
WHERE path LIKE '/%';
-- remove leading slashes from the paths in titles
UPDATE titles
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
WHERE path LIKE '/%';
SQL
end
def down : String
<<-SQL
-- add leading slashes to paths in ids
UPDATE ids
SET path = '/' || path
WHERE path NOT LIKE '/%';
-- add leading slashes to paths in titles
UPDATE titles
SET path = '/' || path
WHERE path NOT LIKE '/%';
SQL
end
end

19
migration/tags.4.cr Normal file
View File

@@ -0,0 +1,19 @@
class CreateTags < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS tags (
id TEXT NOT NULL,
tag TEXT NOT NULL,
UNIQUE (id, tag)
);
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
SQL
end
def down : String
<<-SQL
DROP TABLE tags;
SQL
end
end

20
migration/thumbnails.3.cr Normal file
View File

@@ -0,0 +1,20 @@
class CreateThumbnails < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS thumbnails (
id TEXT NOT NULL,
data BLOB NOT NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
SQL
end
def down : String
<<-SQL
DROP TABLE thumbnails;
SQL
end
end

56
migration/titles.5.cr Normal file
View File

@@ -0,0 +1,56 @@
class CreateTitles < MG::Base
def up : String
<<-SQL
-- create titles
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT
);
CREATE UNIQUE INDEX titles_id_idx on titles (id);
CREATE UNIQUE INDEX titles_path_idx on titles (path);
-- migrate data from ids to titles
INSERT INTO titles
SELECT id, path, null
FROM ids
WHERE is_title = 1;
DELETE FROM ids
WHERE is_title = 1;
-- remove the is_title column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL
);
INSERT INTO ids
SELECT path, id
FROM tmp;
DROP TABLE tmp;
-- recreate the indices
CREATE UNIQUE INDEX path_idx ON ids (path);
CREATE UNIQUE INDEX id_idx ON ids (id);
SQL
end
def down : String
<<-SQL
-- insert the is_title column
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
-- migrate data from titles to ids
INSERT INTO ids
SELECT path, id, 1
FROM titles;
-- remove titles
DROP TABLE titles;
SQL
end
end

View File

@@ -0,0 +1,94 @@
class UnavailableIDs < MG::Base
def up : String
<<-SQL
-- add unavailable column to ids
ALTER TABLE ids ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
-- add unavailable column to titles
ALTER TABLE titles ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
SQL
end
def down : String
<<-SQL
-- remove unavailable column from ids
ALTER TABLE ids RENAME TO tmp;
CREATE TABLE ids (
path TEXT NOT NULL,
id TEXT NOT NULL,
signature TEXT
);
INSERT INTO ids
SELECT path, id, signature
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 unavailable column from titles
ALTER TABLE titles RENAME TO tmp;
CREATE TABLE titles (
id TEXT NOT NULL,
path TEXT NOT NULL,
signature TEXT
);
INSERT INTO titles
SELECT path, id, signature
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

20
migration/users.1.cr Normal file
View File

@@ -0,0 +1,20 @@
class CreateUsers < MG::Base
def up : String
<<-SQL
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL,
password TEXT NOT NULL,
token TEXT,
admin INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
SQL
end
def down : String
<<-SQL
DROP TABLE users;
SQL
end
end

View File

@@ -1,146 +0,0 @@
.uk-alert-close {
color: black !important;
}
.uk-card-body {
padding: 20px;
}
.uk-card-media-top {
width: 100%;
height: 250px;
}
@media (min-width: 600px) {
.uk-card-media-top {
height: 300px;
}
}
.uk-card-media-top>img {
height: 100%;
width: 100%;
object-fit: cover;
}
.uk-card-title {
max-height: 3em;
}
.acard:hover {
cursor: pointer;
}
.reader-bg {
background-color: black;
}
.break-word {
word-wrap: break-word;
}
.uk-logo>img {
height: 90px;
width: 90px;
}
.uk-search {
width: 100%;
}
#selectable .ui-selecting {
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
}
.uk-light #selectable .ui-selecting {
background: #5E5731;
}
.uk-light #selectable .ui-selected {
background: #9D9252;
}
td>.uk-dropdown {
white-space: pre-line;
}
#edit-modal .uk-grid>div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}
.item .uk-card-title {
font-size: 1rem;
}
.grayscale {
filter: grayscale(100%);
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-modal-header,
.uk-light .uk-modal-body,
.uk-light .uk-modal-footer {
background: #222;
}
.uk-light .uk-dropdown {
background: #333;
}
.uk-light .uk-navbar-dropdown,
.uk-light .uk-dropdown {
color: #ccc;
}
.uk-light .uk-nav-header,
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}

137
public/css/mango.less Normal file
View File

@@ -0,0 +1,137 @@
// UIKit
@import "./uikit.less";
// FontAwesome
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
@font-face {
src: url('@{fa-font-path}/fa-solid-900.woff2');
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
}
// Item cards
.item .uk-card {
cursor: pointer;
.uk-card-media-top {
width: 100%;
height: 250px;
@media (min-width: 600px) {
height: 300px;
}
img {
height: 100%;
width: 100%;
object-fit: cover;
&.grayscale {
filter: grayscale(100%);
}
}
}
.uk-card-body {
padding: 20px;
.uk-card-title {
max-height: 3em;
font-size: 1rem;
}
}
}
// jQuery selectable
#selectable {
.ui-selecting {
background: #EEE6B9;
}
.ui-selected {
background: #F4E487;
}
.uk-light & {
.ui-selecting {
background: #5E5731;
}
.ui-selected {
background: #9D9252;
}
}
}
// Edit modal
#edit-modal {
.uk-grid > div {
height: 300px;
}
#cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#cover-upload {
height: 100%;
box-sizing: border-box;
}
.uk-modal-body .uk-inline {
width: 100%;
}
}
// Dark theme
.uk-light {
.uk-modal-header,
.uk-modal-body,
.uk-modal-footer {
background: #222;
}
.uk-navbar-dropdown,
.uk-dropdown {
color: #ccc;
background: #333;
}
.uk-nav-header,
.uk-description-list > dt {
color: #555;
}
}
// Alpine magic
[x-cloak] {
display: none;
}
// Batch select bar on title page
#select-bar-controls {
a {
transform: scale(1.5, 1.5);
&:hover {
color: orange;
}
}
}
// Totop button
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}
}
// Misc
.uk-alert-close {
color: black !important;
}
.break-word {
word-wrap: break-word;
}
.uk-search {
width: 100%;
}

58
public/css/tags.less Normal file
View File

@@ -0,0 +1,58 @@
@light-gray: #e5e5e5;
@gray: #666666;
@black: #141414;
@blue: rgb(30, 135, 240);
@white1: rgba(255, 255, 255, .1);
@white2: rgba(255, 255, 255, .2);
@white7: rgba(255, 255, 255, .7);
.select2-container--default {
.select2-selection--multiple {
border: 1px solid @light-gray;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: @blue;
color: white;
border: none;
border-radius: 2px;
}
}
.select2-dropdown {
.select2-results__option--highlighted.select2-results__option--selectable {
background-color: @blue;
}
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @light-gray
}
}
}
.uk-light {
.select2-container--default {
.select2-selection {
background-color: @white1;
}
.select2-selection--multiple {
border: 1px solid @white2;
.select2-selection__choice,
.select2-selection__choice__remove,
.select2-selection__choice__remove:hover
{
background-color: white;
color: @gray;
border: none;
}
.select2-search__field {
color: @white7;
}
}
}
.select2-dropdown {
background-color: @black;
.select2-results__option--selected:not(.select2-results__option--highlighted) {
background-color: @white2;
}
}
}

View File

@@ -4,21 +4,30 @@ const component = () => {
paused: undefined,
loading: false,
toggling: false,
ws: undefined,
init() {
const ws = new WebSocket(`ws://${location.host}${base_url}api/admin/mangadex/queue`);
ws.onmessage = event => {
wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
console.log(`Connecting to ${url}`);
this.ws = new WebSocket(url);
this.ws.onmessage = event => {
const data = JSON.parse(event.data);
this.jobs = data.jobs;
this.paused = data.paused;
};
ws.onerror = err => {
alert('danger', `Socket connection failed. Error: ${err}`);
this.ws.onclose = () => {
if (this.ws.failed)
return this.wsConnect(false);
alert('danger', 'Socket connection closed');
};
ws.onclose = err => {
this.ws.onerror = () => {
if (secure)
return this.ws.failed = true;
alert('danger', 'Socket connection failed');
};
},
init() {
this.wsConnect();
this.load();
},
load() {

View File

@@ -1,305 +1,233 @@
$(() => {
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('.filter-field').each((i, ele) => {
$(ele).change(() => {
buildTable();
});
});
});
const selectAll = () => {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
};
const unselect = () => {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
};
const download = () => {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
$('#download-btn').attr('hidden', '');
$('#download-spinner').removeAttr('hidden');
const ids = selected.map((i, e) => {
return $(e).find('td').first().text();
}).get();
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
$.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}`);
const downloadComponent = () => {
return {
chaptersLimit: 1000,
loading: false,
addingToDownload: false,
searchInput: '',
data: {},
chapters: [],
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
});
},
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;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
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;
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
$('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
});
};
const toggleSpinner = () => {
var attr = $('#spinner').attr('hidden');
if (attr) {
$('#spinner').removeAttr('hidden');
$('#search-btn').attr('hidden', '');
} else {
$('#search-btn').removeAttr('hidden');
$('#spinner').attr('hidden', '');
}
searching = !searching;
};
var searching = false;
var globalChapters;
const search = () => {
if (searching) {
return;
}
$('#manga-details').attr('hidden', '');
$('#filter-form').attr('hidden', '');
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
toggleSpinner();
const input = $('input').val();
console.log('filtered chapters:', _chapters);
this.chapters = _chapters;
},
search() {
if (this.loading || this.searchInput === '') return;
this.loading = true;
this.data = {};
if (input === "") {
toggleSpinner();
return;
}
var int_id = -1;
try {
const path = new URL(input).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(input);
}
if (int_id <= 0 || isNaN(int_id)) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
toggleSpinner();
return;
}
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
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 (int_id <= 0 || isNaN(int_id)) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
this.loading = false;
return;
}
const cover = baseURL + data.cover_url;
$('#cover').attr("src", cover);
$('#title').text("Title: " + data.title);
$('#artist').text("Artist: " + data.artist);
$('#author').text("Author: " + data.author);
$.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;
}
$('#manga-details').removeAttr('hidden');
console.log(data.chapters);
globalChapters = data.chapters;
let langs = new Set();
let group_names = new Set();
data.chapters.forEach(chp => {
Object.entries(chp.groups).forEach(([k, v]) => {
group_names.add(k);
this.data = data;
this.chapters = data.chapters;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
langs.add(chp.language);
},
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');
});
},
const comp = (a, b) => {
var ai;
var bi;
try {
ai = parseFloat(a);
} catch (e) {}
try {
bi = parseFloat(b);
} catch (e) {}
if (typeof ai === 'undefined') return -1;
if (typeof bi === 'undefined') return 1;
if (ai < bi) return 1;
if (ai > bi) return -1;
return 0;
};
langs = [...langs].sort();
group_names = [...group_names].sort();
langs.unshift('All');
group_names.unshift('All');
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
$('#filter-form').removeAttr('hidden');
buildTable();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
toggleSpinner();
});
};
const 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) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
alert('danger', `Failed to parse filter input ${str}`);
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)) {
alert('danger', `Failed to parse filter input ${str}`);
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) {
alert('danger', `Failed to parse filter input ${str}`);
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
};
const getFilters = () => {
const filters = {};
$('.uk-select').each((i, ele) => {
const id = $(ele).attr('id');
const by = id.split('-')[0];
const choice = $(ele).val();
filters[by] = choice;
});
filters.volume = parseRange($('#volume-range').val());
filters.chapter = parseRange($('#chapter-range').val());
return filters;
};
const buildTable = () => {
$('table').attr('hidden', '');
$('#selection-controls').attr('hidden', '');
$('#filter-notification').attr('hidden', '');
console.log('rebuilding table');
const filters = getFilters();
console.log('filters:', filters);
var chapters = globalChapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
chapters = chapters.filter(c => {
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
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);
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
window.location.href = base_url + 'admin/downloads';
});
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.addingToDownload = false;
});
});
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);
$('#count-text').text(`${chapters.length} chapters found`);
const chaptersLimit = 1000;
if (chapters.length > chaptersLimit) {
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
$('#filter-notification').removeAttr('hidden');
return;
}
const inner = chapters.map(chp => {
const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | ');
return `<tr class="ui-widget-content">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td>
<td>${chp.language}</td>
<td>${group_str}</td>
<td>${chp.volume}</td>
<td>${chp.chapter}</td>
<td>${moment.unix(chp.time).fromNow()}</td>
</tr>`;
}).join('');
const tbody = `<tbody id="selectable">${inner}</tbody>`;
$('tbody').remove();
$('table').append(tbody);
$('table').removeAttr('hidden');
$("#selectable").selectable({
filter: 'tr'
});
$('#selection-controls').removeAttr('hidden');
};
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};
};

View File

@@ -0,0 +1,60 @@
const component = () => {
return {
empty: true,
titles: [],
entries: [],
loading: true,
load() {
this.loading = true;
this.request('GET', `${base_url}api/admin/titles/missing`, data => {
this.titles = data.titles;
this.request('GET', `${base_url}api/admin/entries/missing`, data => {
this.entries = data.entries;
this.loading = false;
this.empty = this.entries.length === 0 && this.titles.length === 0;
});
});
},
rm(event) {
const rawID = event.currentTarget.closest('tr').id;
const [type, id] = rawID.split('-');
const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
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.', {
labels: {
ok: 'Yes, delete them',
cancel: 'Cancel'
}
}).then(() => {
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
this.load();
});
});
});
},
request(method, url, cb) {
console.log(url);
$.ajax({
type: method,
url: url,
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

@@ -255,48 +255,65 @@ const bulkProgress = (action, el) => {
const tagsComponent = () => {
return {
loading: true,
isAdmin: false,
tags: [],
newTag: '',
inputShown: false,
tid: $('.upload-field').attr('data-title-id'),
loading: true,
load(admin) {
this.isAdmin = admin;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => {
this.tags = data.tags;
this.loading = false;
$('.tag-select').select2({
tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin,
templateSelection(state) {
const a = document.createElement('a');
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
a.setAttribute('class', 'uk-link-reset');
a.onclick = event => {
event.stopPropagation();
};
a.innerText = state.text;
return a;
}
});
},
add() {
const tag = this.newTag.trim();
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT', () => {
this.tags.push(tag);
this.newTag = '';
});
},
keydown(event) {
if (event.key === 'Enter')
this.add()
},
rm(event) {
const tag = event.currentTarget.id.split('-')[0];
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE', () => {
const idx = this.tags.indexOf(tag);
if (idx < 0) return;
this.tags.splice(idx, 1);
});
},
toggleInput(nextTick) {
this.inputShown = !this.inputShown;
if (this.inputShown) {
nextTick(() => {
$('#tag-input').get(0).focus();
this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', data => {
this.tags = data.tags;
allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op);
});
$('.tag-select').on('select2:select', e => {
this.onAdd(e);
});
$('.tag-select').on('select2:unselect', e => {
this.onDelete(e);
});
$('.tag-select').on('change', () => {
this.onChange();
});
$('.tag-select').trigger('change');
this.loading = false;
});
}
});
},
onChange() {
this.tags = $('.tag-select').select2('data').map(o => o.text);
},
onAdd(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT');
},
onDelete(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE');
},
request(url, method, cb) {
$.ajax({
@@ -305,9 +322,9 @@ const tagsComponent = () => {
dataType: 'json'
})
.done(data => {
if (data.success)
cb(data);
else {
if (data.success) {
if (cb) cb(data);
} else {
alert('danger', data.error);
}
})

View File

@@ -52,6 +52,14 @@ shards:
git: https://github.com/hkalexling/koa.git
version: 0.5.0
mangadex:
git: https://github.com/hkalexling/mangadex.git
version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb
mg:
git: https://github.com/hkalexling/mg.git
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
myhtml:
git: https://github.com/kostya/myhtml.git
version: 1.5.1

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.18.2
version: 0.20.2
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -41,3 +41,7 @@ dependencies:
github: hkalexling/koa
tallboy:
github: epoch/tallboy
mg:
github: hkalexling/mg
mangadex:
github: hkalexling/mangadex

View File

@@ -35,6 +35,23 @@ describe "compare_numerically" do
end
end
describe "is_supported_file" do
it "returns true when the filename has a supported extension" do
filename = "manga.cbz"
is_supported_file(filename).should eq true
end
it "returns true when the filename does not have a supported extension" do
filename = "info.json"
is_supported_file(filename).should eq false
end
it "is case insensitive" do
filename = "manga.ZiP"
is_supported_file(filename).should eq true
end
end
describe "chapter_sort" do
it "sorts correctly" do
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]

View File

@@ -1,41 +0,0 @@
Arabic,sa
Bengali,bd
Bulgarian,bg
Burmese,mm
Catalan,ct
Chinese (Simp),cn
Chinese (Trad),hk
Czech,cz
Danish,dk
Dutch,nl
English,gb
Filipino,ph
Finnish,fi
French,fr
German,de
Greek,gr
Hebrew,il
Hindi,in
Hungarian,hu
Indonesian,id
Italian,it
Japanese,jp
Korean,kr
Lithuanian,lt
Malay,my
Mongolian,mn
Other,
Persian,ir
Polish,pl
Portuguese (Br),br
Portuguese (Pt),pt
Romanian,ro
Russian,ru
Serbo-Croatian,rs
Spanish (Es),es
Spanish (LATAM),mx
Swedish,se
Thai,th
Turkish,tr
Ukrainian,ua
Vietnames,vn
1 Arabic sa
2 Bengali bd
3 Bulgarian bg
4 Burmese mm
5 Catalan ct
6 Chinese (Simp) cn
7 Chinese (Trad) hk
8 Czech cz
9 Danish dk
10 Dutch nl
11 English gb
12 Filipino ph
13 Finnish fi
14 French fr
15 German de
16 Greek gr
17 Hebrew il
18 Hindi in
19 Hungarian hu
20 Indonesian id
21 Italian it
22 Japanese jp
23 Korean kr
24 Lithuanian lt
25 Malay my
26 Mongolian mn
27 Other
28 Persian ir
29 Polish pl
30 Portuguese (Br) br
31 Portuguese (Pt) pt
32 Romanian ro
33 Russian ru
34 Serbo-Croatian rs
35 Spanish (Es) es
36 Spanish (LATAM) mx
37 Swedish se
38 Thai th
39 Turkish tr
40 Ukrainian ua
41 Vietnames vn

View File

@@ -5,6 +5,7 @@ class Config
@[YAML::Field(ignore: true)]
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"
@@ -13,7 +14,6 @@ class Config
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 db_optimization_interval_hours : Int32 = 24
property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
@@ -23,12 +23,13 @@ class Config
property page_margin : Int32 = 30
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)]
@mangadex_defaults = {
"base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api",
"api_url" => "https://mangadex.org/api/v2",
"download_wait_seconds" => 5,
"download_retries" => 4,
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
@@ -52,9 +53,9 @@ class Config
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path
config.fill_defaults
config.preprocess
return config
end
puts "The config file #{cfg_path} does not exist. " \
@@ -92,5 +93,16 @@ class Config
raise "Login is disabled, but default username is not set. " \
"Please set a default username"
end
unless mangadex["api_url"] =~ /\/v2/
# `Logger.default` is not available yet
Log.setup :debug
Log.warn { "It looks like you are using the deprecated MangaDex API " \
"v1 in your config file. Please update it to either " \
"https://mangadex.org/api/v2 or " \
"https://api.mangadex.org/v2 to suppress this warning." }
mangadex["api_url"] = "https://mangadex.org/api/v2"
end
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
end
end

View File

@@ -15,7 +15,11 @@ class AuthHandler < Kemal::Handler
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
call_next env
end
def require_auth(env)
env.session.string "callback", env.request.path
redirect env, "/login"
end
def validate_token(env)
@@ -49,50 +53,50 @@ class AuthHandler < Kemal::Handler
Storage.default.verify_user username, password
end
def handle_opds_auth(env)
if validate_token(env) || validate_auth_header(env)
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
def call(env)
# Skip all authentication if requesting /login, /logout, or a static file
if request_path_startswith(env, ["/login", "/logout"]) ||
requesting_static_file env
return call_next(env)
end
unless validate_token(env) || Config.current.disable_login
env.session.string "callback", env.request.path
return redirect env, "/login"
# Check user is logged in
if validate_token env
# Skip if the request has a valid token
elsif Config.current.disable_login
# Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username
Logger.warn "Default username #{Config.current.default_username} " \
"does not exist"
return require_auth env
end
elsif !Config.current.auth_proxy_header_name.empty?
# Check auth proxy if present
username = env.request.headers[Config.current.auth_proxy_header_name]?
unless username && Storage.default.username_exists username
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
"or is not a valid username"
return require_auth env
end
elsif request_path_startswith env, ["/opds"]
# Check auth header if requesting an opds page
unless validate_auth_header env
return require_basic_auth env
end
else
return require_auth env
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
should_reject = true
if Config.current.disable_login &&
Storage.default.username_is_admin Config.current.default_username
should_reject = false
# Check admin access when requesting an admin page
if request_path_startswith env, %w(/admin /api/admin /download)
unless is_admin? env
env.response.status_code = 403
return send_error_page "HTTP 403: You are not authorized to visit " \
"#{env.request.path}"
end
if env.session.string? "token"
should_reject = !validate_token_admin(env)
end
env.response.status_code = 403 if should_reject
end
# Let the request go through if it passes the above checks
call_next env
end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end

View File

@@ -11,13 +11,13 @@ class Entry
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_id @zip_path, false
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_id({
path: @zip_path,
id: id,
is_title: false,
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id

View File

@@ -42,16 +42,6 @@ class Library
end
end
end
db_interval = Config.current.db_optimization_interval_hours
unless db_interval < 1
spawn do
loop do
Storage.default.optimize
sleep db_interval.hours
end
end
end
end
def titles
@@ -119,6 +109,7 @@ class Library
storage.close
Logger.debug "Scan completed"
Storage.default.mark_unavailable
end
def get_continue_reading_entries(username)
@@ -241,7 +232,7 @@ class Library
e.generate_thumbnail
# Sleep after each generation to minimize the impact on disk IO
# and CPU
sleep 0.5.seconds
sleep 1.seconds
end
@thumbnails_count += 1
end

View File

@@ -3,19 +3,20 @@ require "../archive"
class Title
getter dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time
encoded_title : String, mtime : Time, signature : UInt64
@entry_display_name_cache : Hash(String, String)?
def initialize(@dir : String, @parent_id)
storage = Storage.default
id = storage.get_id @dir, true
@signature = Dir.signature dir
id = storage.get_title_id dir, signature
if id.nil?
id = random_str
storage.insert_id({
path: @dir,
id: id,
is_title: true,
storage.insert_title_id({
path: dir,
id: id,
signature: signature.to_s,
})
end
@id = id
@@ -35,7 +36,7 @@ class Title
@title_ids << title.id
next
end
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
if is_supported_file path
entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
@@ -61,6 +62,7 @@ class Title
{% 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 }

View File

@@ -6,26 +6,14 @@ class Logger
SEVERITY_IDS = [0, 4, 5, 2, 3]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
getter raw_log = Log.for ""
@@severity : Log::Severity = :info
use_default
def initialize
level = Config.current.log_level
{% begin %}
case level.downcase
when "off"
@@severity = :none
{% for lvl, i in LEVELS %}
when {{lvl}}
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
@log = Log.for("")
@@severity = Logger.get_severity
@backend = Log::IOBackend.new
format_proc = ->(entry : Log::Entry, io : IO) do
@@ -49,6 +37,24 @@ class Logger
Log.setup @@severity, @backend
end
def self.get_severity(level = "") : Log::Severity
if level.empty?
level = Config.current.log_level
end
{% begin %}
case level.downcase
when "off"
return Log::Severity::None
{% for lvl, i in LEVELS %}
when {{lvl}}
return Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
end
# Ignores @@severity and always log msg
def log(msg)
@backend.write Log::Entry.new "", Log::Severity::None, msg,
@@ -61,7 +67,7 @@ class Logger
{% for lvl in LEVELS %}
def {{lvl.id}}(msg)
@log.{{lvl.id}} { msg }
raw_log.{{lvl.id}} { msg }
end
def self.{{lvl.id}}(msg)
default.not_nil!.{{lvl.id}} msg

View File

@@ -1,217 +0,0 @@
require "json"
require "csv"
require "../rename"
macro string_properties(names)
{% for name in names %}
property {{name.id}} = ""
{% end %}
end
macro parse_strings_from_json(names)
{% for name in names %}
@{{name.id}} = obj[{{name}}].as_s
{% end %}
end
macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => @{{name.id}}.to_s,
{% end %}
}
end
module MangaDex
class Chapter
string_properties ["lang_code", "title", "volume", "chapter"]
property manga : Manga
property time = Time.local
property id : String
property full_title = ""
property language = ""
property pages = [] of {String, String} # filename, url
property groups = [] of {Int32, String} # group_id, group_name
def initialize(@id, json_obj : JSON::Any, @manga,
lang : Hash(String, String))
self.parse_json json_obj, lang
end
def to_info_json
JSON.build do |json|
json.object do
{% for name in ["id", "title", "volume", "chapter",
"language", "full_title"] %}
json.field {{name}}, @{{name.id}}
{% end %}
json.field "time", @time.to_unix.to_s
json.field "manga_title", @manga.title
json.field "manga_id", @manga.id
json.field "groups" do
json.object do
@groups.each do |gid, gname|
json.field gname, gid
end
end
end
end
end
end
def parse_json(obj, lang)
parse_strings_from_json ["lang_code", "title", "volume",
"chapter"]
language = lang[@lang_code]?
@language = language if language
@time = Time.unix obj["timestamp"].as_i
suffixes = ["", "_2", "_3"]
suffixes.each do |s|
gid = obj["group_id#{s}"].as_i
next if gid == 0
gname = obj["group_name#{s}"].as_s
@groups << {gid, gname}
end
rename_rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
@full_title = rename rename_rule
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
hash = properties_to_hash ["id", "title", "volume", "chapter",
"lang_code", "language", "pages"]
hash["groups"] = @groups.map { |g| g[1] }.join ","
rule.render hash
end
end
class Manga
string_properties ["cover_url", "description", "title", "author", "artist"]
property chapters = [] of Chapter
property id : String
def initialize(@id, json_obj : JSON::Any)
self.parse_json json_obj
end
def to_info_json(with_chapters = true)
JSON.build do |json|
json.object do
{% for name in ["id", "title", "description", "author", "artist",
"cover_url"] %}
json.field {{name}}, @{{name.id}}
{% end %}
if with_chapters
json.field "chapters" do
json.array do
@chapters.each do |c|
json.raw c.to_info_json
end
end
end
end
end
end
end
def parse_json(obj)
parse_strings_from_json ["cover_url", "description", "title", "author",
"artist"]
rescue e
raise "failed to parse json: #{e}"
end
def rename(rule : Rename::Rule)
rule.render properties_to_hash ["id", "title", "author", "artist"]
end
end
class API
use_default
def initialize
@base_url = Config.current.mangadex["api_url"].to_s ||
"https://mangadex.org/api/"
@lang = {} of String => String
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
@lang[row[1]] = row[0]
end
end
def get(url)
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
res = HTTP::Client.get url, headers
raise "Failed to get #{url}. [#{res.status_code}] " \
"#{res.status_message}" if !res.success?
JSON.parse res.body
end
def get_manga(id)
obj = self.get File.join @base_url, "manga/#{id}"
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
manga = Manga.new id, obj["manga"]
obj["chapter"].as_h.map do |k, v|
chapter = Chapter.new k, v, manga, @lang
manga.chapters << chapter
end
manga
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(chapter : Chapter)
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
server = obj["server"].as_s
hash = obj["hash"].as_s
chapter.pages = obj["page_array"].as_a.map do |fn|
{
fn.as_s,
"#{server}#{hash}/#{fn.as_s}",
}
end
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(id : String)
obj = self.get File.join @base_url, "chapter/#{id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
manga_id = ""
begin
manga_id = obj["manga_id"].as_i.to_s
rescue
raise "Failed to parse JSON"
end
manga = self.get_manga manga_id
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
self.get_chapter chapter
chapter
end
end
end

View File

@@ -1,5 +1,7 @@
require "./api"
require "mangadex"
require "compress/zip"
require "../rename"
require "./ext"
module MangaDex
class PageJob
@@ -21,7 +23,7 @@ module MangaDex
use_default
def initialize
@api = API.default
@client = Client.from_config
super
end
@@ -46,7 +48,7 @@ module MangaDex
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
chapter = @api.get_chapter(job.id)
chapter = @client.chapter job.id
rescue e
Logger.error e
@queue.set_status Queue::JobStatus::Error, job
@@ -73,8 +75,8 @@ module MangaDex
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new chapter.pages.size
spawn do
chapter.pages.each_with_index do |tuple, i|
fn, url = tuple
chapter.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

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.map(&.name).join ","
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

@@ -8,7 +8,7 @@ require "option_parser"
require "clim"
require "tallboy"
MANGO_VERSION = "0.18.2"
MANGO_VERSION = "0.20.2"
# From http://www.network-science.de/ascii/
BANNER = %{
@@ -63,7 +63,12 @@ class CLI < Clim
Plugin::Downloader.default
spawn do
Server.new.start
begin
Server.new.start
rescue e
Logger.fatal e
Process.exit 1
end
end
MainFiber.start_and_block

View File

@@ -1,6 +1,9 @@
struct AdminRouter
def initialize
get "/admin" do |env|
storage = Storage.default
missing_count = storage.missing_titles.size +
storage.missing_entries.size
layout "admin"
end
@@ -66,5 +69,9 @@ struct AdminRouter
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download-manager"
end
get "/admin/missing" do |env|
layout "missing-items"
end
end
end

View File

@@ -166,6 +166,21 @@ struct APIRouter
"error" => "string?",
}
Koa.object "missing", {
"path" => "string",
"id" => "string",
"signature" => "string",
}
Koa.array "missingAry", "$missing"
Koa.object "missingResult", {
"success" => "boolean",
"error" => "string?",
"entries" => "$missingAry?",
"titles" => "$missingAry?",
}
Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID"
@@ -399,8 +414,7 @@ struct APIRouter
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
api = MangaDex::API.default
manga = api.get_manga id
manga = MangaDex::Client.from_config.manga id
send_json env, manga.to_info_json
rescue e
Logger.error e
@@ -419,12 +433,12 @@ struct APIRouter
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
jobs = chapters.map { |chapter|
Queue::Job.new(
chapter["id"].as_s,
chapter["manga_id"].as_s,
chapter["id"].as_i64.to_s,
chapter["mangaId"].as_i64.to_s,
chapter["full_title"].as_s,
chapter["manga_title"].as_s,
chapter["mangaTitle"].as_s,
Queue::JobStatus::Pending,
Time.unix chapter["time"].as_s.to_i
Time.unix chapter["timestamp"].as_i64
)
}
inserted_count = Queue.default.push jobs
@@ -713,6 +727,24 @@ struct APIRouter
end
end
Koa.describe "Returns all tags"
Koa.response 200, ref: "$tagsResult"
get "/api/tags" do |env|
begin
tags = Storage.default.list_tags
send_json env, {
"success" => true,
"tags" => tags,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Adds a new tag to a title"
Koa.path "tid", desc: "A title ID"
Koa.response 200, ref: "$result"
@@ -759,6 +791,120 @@ struct APIRouter
end
end
Koa.describe "Lists all missing titles"
Koa.response 200, ref: "$missingResult"
Koa.tag "admin"
get "/api/admin/titles/missing" do |env|
begin
send_json env, {
"success" => true,
"error" => nil,
"titles" => Storage.default.missing_titles,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Lists all missing entries"
Koa.response 200, ref: "$missingResult"
Koa.tag "admin"
get "/api/admin/entries/missing" do |env|
begin
send_json env, {
"success" => true,
"error" => nil,
"entries" => Storage.default.missing_entries,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes all missing titles"
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/titles/missing" do |env|
begin
Storage.default.delete_missing_title
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes all missing entries"
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/entries/missing" do |env|
begin
Storage.default.delete_missing_entry
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
Does nothing if the given `tid` is not found or if the title is not missing.
MD
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/titles/missing/:tid" do |env|
begin
tid = env.params.url["tid"]
Storage.default.delete_missing_title tid
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
Does nothing if the given `eid` is not found or if the entry is not missing.
MD
Koa.response 200, ref: "$result"
Koa.tag "admin"
delete "/api/admin/entries/missing/:eid" do |env|
begin
eid = env.params.url["eid"]
Storage.default.delete_missing_entry eid
send_json env, {
"success" => true,
"error" => nil,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate
@@api_json = doc.to_json if doc

View File

@@ -154,6 +154,7 @@ struct MainRouter
end
get "/api" do |env|
base_url = Config.current.base_url
render "src/views/api.html.ecr"
end
end

View File

@@ -7,10 +7,6 @@ require "./routes/*"
class Server
def initialize
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
end
error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message"
@@ -53,6 +49,7 @@ class Server
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}
Kemal.config.host_binding = Config.current.host
Kemal.config.port = Config.current.port
Kemal.run
end

View File

@@ -3,6 +3,8 @@ require "crypto/bcrypt"
require "uuid"
require "base64"
require "./util/*"
require "mg"
require "../migration/*"
def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s
@@ -13,13 +15,16 @@ def verify_password(hash, pw)
end
class Storage
@@insert_entry_ids = [] of IDTuple
@@insert_title_ids = [] of IDTuple
@path : String
@db : DB::Database?
@insert_ids = [] of IDTuple
alias IDTuple = NamedTuple(path: String,
alias IDTuple = NamedTuple(
path: String,
id: String,
is_title: Bool)
signature: String?)
use_default
@@ -35,51 +40,14 @@ class Storage
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
begin
# v0.18.0
db.exec "create table tags (id text, tag text, unique (id, tag))"
db.exec "create index tags_id_idx on tags (id)"
db.exec "create index tags_tag_idx on tags (tag)"
# v0.15.0
db.exec "create table thumbnails " \
"(id text, data blob, filename text, " \
"mime text, size integer)"
db.exec "create unique index tn_index on thumbnails (id)"
# v0.1.1
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
# v0.1.0
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
MG::Migration.new(db, log: Logger.default.raw_log).migrate
rescue e
unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
init_admin if init_user
Logger.fatal "DB migration failed. #{e}"
raise e
end
# Verifies that the default username in config is valid
if Config.current.disable_login
username = Config.current.default_username
unless username_exists username
raise "Default username #{username} does not exist"
end
end
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
@@ -99,9 +67,11 @@ class Storage
private def get_db(&block : DB::Database ->)
if @db.nil?
DB.open "sqlite3://#{@path}" do |db|
db.exec "PRAGMA foreign_keys = 1"
yield db
end
else
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
yield @db.not_nil!
end
end
@@ -254,32 +224,121 @@ class Storage
end
end
def get_id(path, is_title)
def get_title_id(path, signature)
id = nil
path = Path.new(path).relative_to(Config.current.library_path).to_s
MainFiber.run do
get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
# First attempt to find the matching title in DB using BOTH path
# and signature
id = db.query_one? "select id from titles where path = (?) and " \
"signature = (?) and unavailable = 0",
path, signature.to_s, as: String
should_update = id.nil?
# If it fails, try to match using the path only. This could happen
# for example when a new entry is added to the title
id ||= db.query_one? "select id from titles where path = (?)", path,
as: String
# If it still fails, we will have to rely on the signature values.
# This could happen when the user moved or renamed the title, or
# a title containing the title
unless id
# If there are multiple rows with the same signature (this could
# happen simply by bad luck, or when the user copied a title),
# pick the row that has the most similar path to the give path
rows = [] of Tuple(String, String)
db.query "select id, path from titles where signature = (?)",
signature.to_s do |rs|
rs.each do
rows << {rs.read(String), rs.read(String)}
end
end
row = rows.max_by?(&.[1].components_similarity(path))
id = row[0] if row
end
# At this point, `id` would still be nil if there's no row matching
# either the path or the signature
# If we did identify a matching title, save the path and signature
# values back to the DB
if id && should_update
db.exec "update titles set path = (?), signature = (?), " \
"unavailable = 0 where id = (?)", path, signature.to_s, id
end
end
end
id
end
def insert_id(tp : IDTuple)
@insert_ids << tp
# See the comments in `#get_title_id` to see how this method works.
def get_entry_id(path, signature)
id = nil
path = Path.new(path).relative_to(Config.current.library_path).to_s
MainFiber.run do
get_db do |db|
id = db.query_one? "select id from ids where path = (?) and " \
"signature = (?) and unavailable = 0",
path, signature.to_s, as: String
should_update = id.nil?
id ||= db.query_one? "select id from ids where path = (?)", path,
as: String
unless id
rows = [] of Tuple(String, String)
db.query "select id, path from ids where signature = (?)",
signature.to_s do |rs|
rs.each do
rows << {rs.read(String), rs.read(String)}
end
end
row = rows.max_by?(&.[1].components_similarity(path))
id = row[0] if row
end
if id && should_update
db.exec "update ids set path = (?), signature = (?), " \
"unavailable = 0 where id = (?)", path, signature.to_s, id
end
end
end
id
end
def insert_entry_id(tp)
@@insert_entry_ids << tp
end
def insert_title_id(tp)
@@insert_title_ids << tp
end
def bulk_insert_ids
MainFiber.run do
get_db do |db|
db.transaction do |tx|
@insert_ids.each do |tp|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
db.transaction do |tran|
conn = tran.connection
@@insert_title_ids.each do |tp|
path = Path.new(tp[:path])
.relative_to(Config.current.library_path).to_s
conn.exec "insert into titles (id, path, signature, " \
"unavailable) values (?, ?, ?, 0)",
tp[:id], path, tp[:signature].to_s
end
@@insert_entry_ids.each do |tp|
path = Path.new(tp[:path])
.relative_to(Config.current.library_path).to_s
conn.exec "insert into ids (id, path, signature, " \
"unavailable) values (?, ?, ?, 0)",
tp[:id], path, tp[:signature].to_s
end
end
end
@insert_ids.clear
@@insert_entry_ids.clear
@@insert_title_ids.clear
end
end
@@ -336,7 +395,8 @@ class Storage
tags = [] of String
MainFiber.run do
get_db do |db|
db.query "select distinct tag from tags" do |rs|
db.query "select distinct tag from tags natural join titles " \
"where unavailable = 0" do |rs|
rs.each do
tags << rs.read String
end
@@ -368,44 +428,90 @@ class Storage
end
end
def optimize
def mark_unavailable
MainFiber.run do
Logger.info "Starting DB optimization"
get_db do |db|
# Detect dangling entry IDs
trash_ids = [] of String
db.query "select path, id from ids" do |rs|
db.query "select path, id from ids where unavailable = 0" do |rs|
rs.each do
path = rs.read String
trash_ids << rs.read String unless File.exists? path
fullpath = Path.new(path).expand(Config.current.library_path).to_s
trash_ids << rs.read String unless File.exists? fullpath
end
end
# Delete dangling IDs
db.exec "delete from ids where id in " \
unless trash_ids.empty?
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
end
db.exec "update ids set unavailable = 1 where id in " \
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
Logger.debug "#{trash_ids.size} dangling IDs deleted" \
if trash_ids.size > 0
# Delete dangling thumbnails
trash_thumbnails_count = db.query_one "select count(*) from " \
"thumbnails where id not in " \
"(select id from ids)", as: Int32
if trash_thumbnails_count > 0
db.exec "delete from thumbnails where id not in (select id from ids)"
Logger.info "#{trash_thumbnails_count} dangling thumbnails deleted"
# Detect dangling title IDs
trash_titles = [] of String
db.query "select path, id from titles where unavailable = 0" do |rs|
rs.each do
path = rs.read String
fullpath = Path.new(path).expand(Config.current.library_path).to_s
trash_titles << rs.read String unless Dir.exists? fullpath
end
end
# Delete dangling tags
trash_tags_count = db.query_one "select count(*) from tags " \
"where id not in " \
"(select id from ids)", as: Int32
if trash_tags_count > 0
db.exec "delete from tags where id not in (select id from ids)"
Logger.info "#{trash_tags_count} dangling tags deleted"
unless trash_titles.empty?
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
end
db.exec "update titles set unavailable = 1 where id in " \
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
end
end
end
private def get_missing(tablename)
ary = [] of IDTuple
MainFiber.run do
get_db do |db|
db.query "select id, path, signature from #{tablename} " \
"where unavailable = 1" do |rs|
rs.each do
ary << {
id: rs.read(String),
path: rs.read(String),
signature: rs.read(String?),
}
end
end
end
Logger.info "DB optimization finished"
end
ary
end
private def delete_missing(tablename, id : String? = nil)
MainFiber.run do
get_db do |db|
if id
db.exec "delete from #{tablename} where id = (?) " \
"and unavailable = 1", id
else
db.exec "delete from #{tablename} where unavailable = 1"
end
end
end
end
def missing_entries
get_missing "ids"
end
def missing_titles
get_missing "titles"
end
def delete_missing_entry(id = nil)
delete_missing "ids", id
end
def delete_missing_title(id = nil)
delete_missing "titles", id
end
def close

51
src/util/signature.cr Normal file
View File

@@ -0,0 +1,51 @@
require "./util"
class File
abstract struct Info
def inode : UInt64
@stat.st_ino.to_u64
end
end
# Returns the signature of the file at filename.
# When it is not a supported file, returns 0. Otherwise, uses the inode
# number as its signature. On most file systems, the inode number is
# preserved even when the file is renamed, moved or edited.
# Some cases that would cause the inode number to change:
# - Reboot/remount on some file systems
# - Replaced with a copied file
# - Moved to a different device
# Since we are also using the relative paths to match ids, we won't lose
# information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64
if is_supported_file filename
File.info(filename).inode
else
0u64
end
end
end
class Dir
# Returns the signature of the directory at dirname. See the comments for
# `File.signature` for more information.
def self.signature(dirname) : UInt64
signatures = [File.info(dirname).inode]
self.open dirname do |dir|
dir.entries.each do |fn|
next if fn.starts_with? "."
path = File.join dirname, fn
if File.directory? path
signatures << Dir.signature path
else
_sig = File.signature path
# Only add its signature value to `signatures` when it is a
# supported file
signatures << _sig if _sig > 0
end
end
end
Digest::CRC32.checksum(signatures.sort.join).to_u64
end
end

View File

@@ -1,7 +1,8 @@
IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
def random_str
UUID.random.to_s.gsub "-", ""
@@ -22,15 +23,27 @@ end
def register_mime_types
{
# Comic Archives
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".cbz" => "application/vnd.comicbook+zip",
".cbr" => "application/vnd.comicbook-rar",
# Favicon
".ico" => "image/x-icon",
# FontAwesome fonts
".woff" => "font/woff",
".woff2" => "font/woff2",
}.each do |k, v|
MIME.register k, v
end
end
def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end
struct Int
def or(other : Int)
if self == 0
@@ -92,3 +105,18 @@ def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
ary
end
class String
# Returns the similarity (in [0, 1]) of two paths.
# For the two paths, separate them into arrays of components, count the
# number of matching components backwards, and divide the count by the
# number of components of the shorter path.
def components_similarity(other : String) : Float64
s, l = [self, other]
.map { |str| Path.new(str).parts }
.sort_by &.size
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
match / s.size
end
end

View File

@@ -1,19 +1,24 @@
# Web related helper functions/macros
def is_admin?(env) : Bool
is_admin = false
if !Config.current.auth_proxy_header_name.empty? ||
Config.current.disable_login
is_admin = Storage.default.username_is_admin get_username env
end
# The token (if exists) takes precedence over other authentication methods.
if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token
end
is_admin
end
macro layout(name)
base_url = Config.current.base_url
is_admin = is_admin? env
begin
is_admin = false
# The token (if exists) takes precedence over the default user option.
# this is why we check the default username first before checking the
# token.
if Config.current.disable_login
is_admin = Storage.default.
username_is_admin Config.current.default_username
end
if token = env.session.string? "token"
is_admin = Storage.default.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
rescue e
@@ -24,6 +29,15 @@ macro layout(name)
end
end
macro send_error_page(msg)
message = {{msg}}
base_url = Config.current.base_url
is_admin = is_admin? env
page = "Error"
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
send_file env, html.to_slice, "text/html"
end
macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime
end
@@ -35,6 +49,8 @@ macro get_username(env)
rescue e
if Config.current.disable_login
Config.current.default_username
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
env.request.headers[header]
else
raise e
end

View File

@@ -1,5 +1,13 @@
<ul class="uk-list uk-list-large uk-list-divider" x-data="component()" x-init="init()">
<li><a class="uk-link-reset" href="<%= base_url %>admin/user">User Management</a></li>
<li>
<a class="uk-link-reset" href="<%= base_url %>admin/missing">Missing Items</a>
<% if missing_count > 0 %>
<div class="uk-align-right">
<span class="uk-badge"><%= missing_count %></span>
</div>
<% end %>
</li>
<li>
<a class="uk-link-reset" @click="scan()">
<span :style="`${scanning ? 'color:grey' : ''}`">Scan Library Files</span>
@@ -19,7 +27,7 @@
</li>
<li>
<span>Theme</span>
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :val="themeSetting" @change="themeChanged($event)">
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2" :value="themeSetting" @change="themeChanged($event)">
<option>Dark</option>
<option>Light</option>
<option>System</option>

View File

@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<redoc spec-url="/openapi.json"></redoc>
<redoc spec-url="<%= base_url %>openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
</body>
</html>

View File

@@ -4,13 +4,10 @@
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></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 type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>

View File

@@ -1,12 +0,0 @@
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)">
<p class="uk-text-meta" @selectstart.prevent>
<span style="position:relative; bottom:3px; margin-right:5px;">Tags: </span>
<template x-for="tag in tags" :key="tag">
<span class="uk-label uk-label-primary" style="padding:2px 5px; margin:0 5px 5px 5px; text-transform:none;">
<a class="uk-link-reset" x-show="isAdmin" @click="rm($event)" :id="`${tag}-rm`"><span uk-icon="close" style="margin-right: 5px; position: relative; bottom: 1.5px;"></span></a><a class="uk-link-reset" x-text="tag" :href="`<%= base_url %>tags/${encodeURIComponent(tag)}`"></a>
</span>
</template>
<a class="uk-link-reset" style="position:relative; bottom:3px;" :uk-icon="inputShown ? 'close' : 'plus'" @click="toggleInput($nextTick)" x-show="isAdmin"></a>
</p>
<input id="tag-input" class="uk-input" type="text" placeholder="Type in a new tag and hit enter" x-model="newTag" @keydown="keydown($event)" x-show="inputShown">
</div>

View File

@@ -0,0 +1,2 @@
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit-icons.min.js"></script>

View File

@@ -25,14 +25,14 @@
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
<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 %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
<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>
@@ -43,7 +43,7 @@
<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"></div>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>

View File

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

View File

@@ -1,91 +1,89 @@
<!DOCTYPE html>
<html>
<%= render_component "head" %>
<%= render_component "head" %>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<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>
<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 class="uk-parent">
<a href="#">Download</a>
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</li>
<% end %>
<hr uk-divider>
<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 class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></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>
<body>
<div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true">
<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>
<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 class="uk-parent">
<a href="#">Download</a>
<ul class="uk-nav-sub">
<li><a href="<%= base_url %>download">MangaDex</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</li>
<% end %>
<hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="<%= base_url %>logout">Logout</a></li>
</ul>
</div>
</div>
</li>
<% end %>
</ul>
</div>
</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 class="uk-position-top">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
</div>
<div class="uk-navbar-left uk-visible@s">
<a class="uk-navbar-item uk-logo" href="<%= 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" id="main-section">
<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 class="uk-section uk-section-small">
</div>
</div>
</div>
<script>
setTheme();
const base_url = "<%= base_url %>";
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<%= yield_content "script" %>
</body>
<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>
<script>
setTheme();
const base_url = "<%= base_url %>";
</script>
<%= render_component "uikit" %>
<%= yield_content "script" %>
</body>
</html>

View File

@@ -30,8 +30,7 @@
<script>
setTheme();
</script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<%= render_component "uikit" %>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<div x-data="component()" x-init="load()" x-cloak x-show="!loading">
<p x-show="empty" class="uk-text-lead uk-text-center">No missing items found.</p>
<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>
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
<table class="uk-table uk-table-striped uk-overflow-auto">
<thead>
<tr>
<th>Type</th>
<th>Relative Path</th>
<th>ID</th>
<th>Actions</th>
</tr>
</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>
</template>
<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>
<% content_for "script" do %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/missing-items.js"></script>
<% end %>

View File

@@ -24,7 +24,7 @@
<template x-for="item in items">
<img
uk-img
class="uk-align-center"
:class="{'uk-align-center': true, 'spine': item.width < 50}"
:style="item.style"
:data-src="item.url"
:width="item.width"
@@ -103,15 +103,15 @@
const eid = "<%= entry.id %>";
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<%= render_component "uikit" %>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script>
</body>
<style>
img[data-src][src*='data:image'] { background: white; }
img { width: 100%; }
img:not(.spine) { width: 100%; }
.reader-bg { background: black; }
</style>
</html>

View File

@@ -34,7 +34,10 @@
</ul>
<p class="uk-text-meta"><%= title.content_label %> found</p>
<%= render_component "tags" %>
<div class="uk-margin" x-data="tagsComponent()" x-cloak x-init="load(<%= is_admin %>)" x-show="!loading">
<select class="tag-select" multiple="multiple" style="width:100%">
</select>
</div>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
@@ -121,6 +124,9 @@
<% content_for "script" do %>
<%= render_component "dots-scripts" %>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
<link href="<%= base_url %>css/tags.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<script src="<%= base_url %>js/search.js"></script>