mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
604c5d49a6 | ||
|
|
7449d19075 | ||
|
|
c5c9305a0b | ||
|
|
fdceab9060 | ||
|
|
c18591c5cf | ||
|
|
bb5cb9b94c | ||
|
|
fb499a5caf | ||
|
|
154d85e197 | ||
|
|
933617503e | ||
|
|
31c6893bbb | ||
|
|
171125e8ac | ||
|
|
d81334026b | ||
|
|
2b3b2eb8ba | ||
|
|
ffd5f4454b | ||
|
|
cb25d7ba00 | ||
|
|
3abd2924d0 | ||
|
|
21233df754 | ||
|
|
c61eb7554e | ||
|
|
edd9a2e093 | ||
|
|
1f50785e8f | ||
|
|
70d418d1a1 | ||
|
|
45e20c94f9 | ||
|
|
ca8e9a164e | ||
|
|
4da263c594 | ||
|
|
d67a24809b | ||
|
|
cd268af9dd | ||
|
|
135fa9fde6 | ||
|
|
77333aaafd | ||
|
|
1fad530331 | ||
|
|
a1bd87098c | ||
|
|
a389fa7178 | ||
|
|
b5db508005 | ||
|
|
30178c42ef | ||
|
|
b712db9e8f | ||
|
|
dd9c75d1c9 | ||
|
|
2d150c3bf2 | ||
|
|
c1c8cca877 | ||
|
|
07965b98b7 | ||
|
|
5779d225f6 | ||
|
|
bf18a14016 | ||
|
|
605dc61777 | ||
|
|
def64d9f98 | ||
|
|
0ba2409c9a | ||
|
|
2b0cf41336 | ||
|
|
c51cb28df2 | ||
|
|
2b079c652d | ||
|
|
68050a9025 | ||
|
|
54cd15d542 | ||
|
|
781de97c68 | ||
|
|
c7be0e0e7c | ||
|
|
667d390be4 | ||
|
|
7f76322377 | ||
|
|
377c4c6554 | ||
|
|
18e8e88c66 |
@@ -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,
|
||||
|
||||
@@ -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
6
.github/autoapproval.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
from_owner:
|
||||
- hkalexling
|
||||
required_labels:
|
||||
- autoapprove
|
||||
apply_labels:
|
||||
- autoapproved
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ public/css/uikit.css
|
||||
public/img/*.svg
|
||||
public/js/*.min.js
|
||||
public/css/*.css
|
||||
public/webfonts
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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
|
||||
|
||||
@@ -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.19.1
|
||||
Mango - Manga Server and Web Reader. Version 0.20.1
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -82,7 +82,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
|
||||
@@ -173,6 +172,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>
|
||||
|
||||
|
||||
@@ -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
|
||||
36
gulpfile.js
36
gulpfile.js
@@ -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'));
|
||||
|
||||
50
migration/ids_signature.7.cr
Normal file
50
migration/ids_signature.7.cr
Normal 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
|
||||
33
migration/relative_path.8.cr
Normal file
33
migration/relative_path.8.cr
Normal 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
|
||||
31
migration/relative_path_fix.10.cr
Normal file
31
migration/relative_path_fix.10.cr
Normal 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
|
||||
94
migration/unavailable.9.cr
Normal file
94
migration/unavailable.9.cr
Normal 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
|
||||
@@ -1,3 +1,16 @@
|
||||
// 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;
|
||||
@@ -66,7 +79,6 @@
|
||||
|
||||
// Dark theme
|
||||
.uk-light {
|
||||
.uk-navbar-dropdown,
|
||||
.uk-modal-header,
|
||||
.uk-modal-body,
|
||||
.uk-modal-footer {
|
||||
@@ -75,6 +87,7 @@
|
||||
.uk-navbar-dropdown,
|
||||
.uk-dropdown {
|
||||
color: #ccc;
|
||||
background: #333;
|
||||
}
|
||||
.uk-nav-header,
|
||||
.uk-description-list > dt {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
60
public/js/missing-items.js
Normal file
60
public/js/missing-items.js
Normal 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}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -52,9 +52,13 @@ 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.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
||||
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
||||
|
||||
myhtml:
|
||||
git: https://github.com/kostya/myhtml.git
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.19.1
|
||||
version: 0.20.1
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -43,3 +43,5 @@ dependencies:
|
||||
github: epoch/tallboy
|
||||
mg:
|
||||
github: hkalexling/mg
|
||||
mangadex:
|
||||
github: hkalexling/mangadex
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
@@ -13,7 +13,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 +22,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 +52,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 +92,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
|
||||
|
||||
@@ -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,55 +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
|
||||
end
|
||||
if env.session.string? "token"
|
||||
should_reject = !validate_token_admin(env)
|
||||
end
|
||||
if should_reject
|
||||
# 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
|
||||
send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
return
|
||||
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||
"#{env.request.path}"
|
||||
end
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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
60
src/mangadex/ext.cr
Normal 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
|
||||
@@ -8,7 +8,7 @@ require "option_parser"
|
||||
require "clim"
|
||||
require "tallboy"
|
||||
|
||||
MANGO_VERSION = "0.19.1"
|
||||
MANGO_VERSION = "0.20.1"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -777,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
212
src/storage.cr
212
src/storage.cr
@@ -15,14 +15,16 @@ def verify_password(hash, pw)
|
||||
end
|
||||
|
||||
class Storage
|
||||
@@insert_ids = [] of IDTuple
|
||||
@@insert_entry_ids = [] of IDTuple
|
||||
@@insert_title_ids = [] of IDTuple
|
||||
|
||||
@path : String
|
||||
@db : DB::Database?
|
||||
|
||||
alias IDTuple = NamedTuple(path: String,
|
||||
alias IDTuple = NamedTuple(
|
||||
path: String,
|
||||
id: String,
|
||||
is_title: Bool)
|
||||
signature: String?)
|
||||
|
||||
use_default
|
||||
|
||||
@@ -46,14 +48,6 @@ class Storage
|
||||
|
||||
user_count = db.query_one "select count(*) from users", as: Int32
|
||||
init_admin if init_user && user_count == 0
|
||||
|
||||
# Verifies that the default username in config is valid
|
||||
if Config.current.disable_login
|
||||
username = Config.current.default_username
|
||||
unless username_exists username
|
||||
raise "Default username #{username} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
unless @auto_close
|
||||
@db = DB.open "sqlite3://#{@path}"
|
||||
@@ -230,24 +224,96 @@ 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|
|
||||
if is_title
|
||||
id = db.query_one? "select id from titles where path = (?)", path,
|
||||
as: String
|
||||
else
|
||||
id = db.query_one? "select id from ids where path = (?)", path,
|
||||
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
|
||||
@@ -255,17 +321,24 @@ class Storage
|
||||
get_db do |db|
|
||||
db.transaction do |tran|
|
||||
conn = tran.connection
|
||||
@@insert_ids.each do |tp|
|
||||
if tp[:is_title]
|
||||
conn.exec "insert into titles values (?, ?, null)", tp[:id],
|
||||
tp[:path]
|
||||
else
|
||||
conn.exec "insert into ids values (?, ?)", tp[:path], tp[:id]
|
||||
end
|
||||
@@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
|
||||
|
||||
@@ -322,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
|
||||
@@ -354,42 +428,92 @@ class Storage
|
||||
end
|
||||
end
|
||||
|
||||
def optimize
|
||||
def mark_unavailable
|
||||
MainFiber.run do
|
||||
Logger.info "Starting DB optimization"
|
||||
get_db do |db|
|
||||
# Delete dangling entry IDs
|
||||
# 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
|
||||
|
||||
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 entry IDs deleted" \
|
||||
if trash_ids.size > 0
|
||||
|
||||
# Delete dangling title IDs
|
||||
# Detect dangling title IDs
|
||||
trash_titles = [] of String
|
||||
db.query "select path, id from titles" do |rs|
|
||||
db.query "select path, id from titles where unavailable = 0" do |rs|
|
||||
rs.each do
|
||||
path = rs.read String
|
||||
trash_titles << rs.read String unless Dir.exists? path
|
||||
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||
trash_titles << rs.read String unless Dir.exists? fullpath
|
||||
end
|
||||
end
|
||||
|
||||
db.exec "delete from titles where id in " \
|
||||
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 ","})"
|
||||
Logger.debug "#{trash_titles.size} dangling title IDs deleted" \
|
||||
if trash_titles.size > 0
|
||||
end
|
||||
Logger.info "DB optimization finished"
|
||||
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
|
||||
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
|
||||
MainFiber.run do
|
||||
unless @db.nil?
|
||||
|
||||
51
src/util/signature.cr
Normal file
51
src/util/signature.cr
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# Web related helper functions/macros
|
||||
|
||||
# This macro defines `is_admin` when used
|
||||
macro check_admin_access
|
||||
def is_admin?(env) : Bool
|
||||
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
|
||||
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
|
||||
check_admin_access
|
||||
is_admin = is_admin? env
|
||||
begin
|
||||
page = {{name}}
|
||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||
@@ -32,7 +32,7 @@ end
|
||||
macro send_error_page(msg)
|
||||
message = {{msg}}
|
||||
base_url = Config.current.base_url
|
||||
check_admin_access
|
||||
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"
|
||||
@@ -49,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
2
src/views/components/uikit.html.ecr
Normal file
2
src/views/components/uikit.html.ecr
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" 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>
|
||||
<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" 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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
src/views/missing-items.html.ecr
Normal file
40
src/views/missing-items.html.ecr
Normal 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 %>
|
||||
@@ -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,14 @@
|
||||
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>
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<% 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="/css/tags.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>
|
||||
|
||||
Reference in New Issue
Block a user