diff --git a/.all-contributorsrc b/.all-contributorsrc
index 4e511f19..6e5cc009 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -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,
diff --git a/.gitignore b/.gitignore
index 75161735..dd5d25b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ public/css/uikit.css
public/img/*.svg
public/js/*.min.js
public/css/*.css
+public/webfonts
diff --git a/Dockerfile b/Dockerfile
index 7dc5f019..f203c6c0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/Dockerfile.arm32v7 b/Dockerfile.arm32v7
index 854f4ec3..65abb908 100644
--- a/Dockerfile.arm32v7
+++ b/Dockerfile.arm32v7
@@ -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"]
diff --git a/Dockerfile.arm64v8 b/Dockerfile.arm64v8
index 888f7974..d9b41862 100644
--- a/Dockerfile.arm64v8
+++ b/Dockerfile.arm64v8
@@ -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"]
diff --git a/README.md b/README.md
index bf57b7f7..8b4ac11b 100644
--- a/README.md
+++ b/README.md
@@ -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.20.0
+ Mango - Manga Server and Web Reader. Version 0.20.1
Usage:
@@ -172,6 +172,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
μ΄μΈμ© π» |
Simon π» |
+ David Knaack π |
diff --git a/gulpfile.js b/gulpfile.js
index bd96a7c0..4496b8c0 100644
--- a/gulpfile.js
+++ b/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'));
diff --git a/public/css/mango.less b/public/css/mango.less
index 1ac84df9..3309be4c 100644
--- a/public/css/mango.less
+++ b/public/css/mango.less
@@ -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;
diff --git a/public/js/download.js b/public/js/download.js
index b8d76fe6..4041d6ad 100644
--- a/public/js/download.js
+++ b/public/js/download.js
@@ -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();
-
- 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);
+ console.log('filtered chapters:', _chapters);
+ this.chapters = _chapters;
+ },
+ search() {
+ if (this.loading || this.searchInput === '') return;
+ this.loading = true;
+ this.data = {};
+
+ 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);
-
- $('#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);
+ $.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
+ .done((data) => {
+ if (data.error) {
+ alert('danger', 'Failed to get manga info. Error: ' + data.error);
+ return;
+ }
+
+ this.data = data;
+ this.chapters = data.chapters;
+ })
+ .fail((jqXHR, status) => {
+ alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ })
+ .always(() => {
+ this.loading = false;
});
- langs.add(chp.language);
- });
-
- 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');
+ 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;
- $('select#lang-select').html(langs.map(e => ``).join(''));
- $('select#group-select').html(group_names.map(e => ``).join(''));
+ 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];
+ }
+ },
- $('#filter-form').removeAttr('hidden');
+ unescapeHTML(str) {
+ var elt = document.createElement("span");
+ elt.innerHTML = str;
+ return elt.innerText;
+ },
- 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;
+ selectAll() {
+ $('tbody > tr').each((i, e) => {
+ $(e).addClass('ui-selected');
+ });
+ },
- 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 => {
- const 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 `${k}`;
- }).join(' | ');
- return `
- ${chp.id} |
- ${chp.title} |
- ${chp.language} |
- ${group_str} |
- ${chp.volume} |
- ${chp.chapter} |
- ${moment.unix(chp.time).fromNow()} |
-
`;
- }).join('');
- const tbody = `${inner}`;
- $('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;
+ };
};
diff --git a/shard.lock b/shard.lock
index 99d3c5af..024ee1ad 100644
--- a/shard.lock
+++ b/shard.lock
@@ -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
diff --git a/shard.yml b/shard.yml
index d7505a5a..7e61ab5e 100644
--- a/shard.yml
+++ b/shard.yml
@@ -1,5 +1,5 @@
name: mango
-version: 0.20.0
+version: 0.20.1
authors:
- Alex Ling
@@ -43,3 +43,5 @@ dependencies:
github: epoch/tallboy
mg:
github: hkalexling/mg
+ mangadex:
+ github: hkalexling/mangadex
diff --git a/src/assets/lang_codes.csv b/src/assets/lang_codes.csv
deleted file mode 100644
index 035e8805..00000000
--- a/src/assets/lang_codes.csv
+++ /dev/null
@@ -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
diff --git a/src/config.cr b/src/config.cr
index 3ac5af28..a52d0a3b 100644
--- a/src/config.cr
+++ b/src/config.cr
@@ -22,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",
@@ -51,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. " \
@@ -91,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
diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr
index b6891d5c..692fa8a8 100644
--- a/src/handlers/auth_handler.cr
+++ b/src/handlers/auth_handler.cr
@@ -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"
- end
-
- if request_path_startswith env, ["/admin", "/api/admin", "/download"]
- # The token (if exists) takes precedence over the default user option.
- # this is why we check the default username first before checking the
- # token.
- should_reject = true
- if Config.current.disable_login &&
- Storage.default.username_is_admin Config.current.default_username
- should_reject = false
+ # Check 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
- if env.session.string? "token"
- should_reject = !validate_token_admin(env)
+ 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
- if should_reject
+ 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
+
+ # 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
diff --git a/src/mangadex/api.cr b/src/mangadex/api.cr
deleted file mode 100644
index b521a271..00000000
--- a/src/mangadex/api.cr
+++ /dev/null
@@ -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
diff --git a/src/mangadex/downloader.cr b/src/mangadex/downloader.cr
index e2babb69..c0b50c72 100644
--- a/src/mangadex/downloader.cr
+++ b/src/mangadex/downloader.cr
@@ -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
diff --git a/src/mangadex/ext.cr b/src/mangadex/ext.cr
new file mode 100644
index 00000000..dfb302c5
--- /dev/null
+++ b/src/mangadex/ext.cr
@@ -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
diff --git a/src/mango.cr b/src/mango.cr
index fcade44a..e585dcff 100644
--- a/src/mango.cr
+++ b/src/mango.cr
@@ -8,7 +8,7 @@ require "option_parser"
require "clim"
require "tallboy"
-MANGO_VERSION = "0.20.0"
+MANGO_VERSION = "0.20.1"
# From http://www.network-science.de/ascii/
BANNER = %{
diff --git a/src/routes/api.cr b/src/routes/api.cr
index 7fc8b46a..ff6a087f 100644
--- a/src/routes/api.cr
+++ b/src/routes/api.cr
@@ -414,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
@@ -434,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
diff --git a/src/storage.cr b/src/storage.cr
index 2bc6daa1..937200fd 100644
--- a/src/storage.cr
+++ b/src/storage.cr
@@ -48,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}"
diff --git a/src/util/util.cr b/src/util/util.cr
index 873a226f..8903f5ef 100644
--- a/src/util/util.cr
+++ b/src/util/util.cr
@@ -1,7 +1,7 @@
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
@@ -23,10 +23,18 @@ 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
diff --git a/src/util/web.cr b/src/util/web.cr
index 03c114d7..67227c71 100644
--- a/src/util/web.cr
+++ b/src/util/web.cr
@@ -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
diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr
index 49d30328..2126ab56 100644
--- a/src/views/components/head.html.ecr
+++ b/src/views/components/head.html.ecr
@@ -4,13 +4,10 @@
Mango - <%= page.split("-").map(&.capitalize).join(" ") %>
-
-
-
-
+
diff --git a/src/views/components/uikit.html.ecr b/src/views/components/uikit.html.ecr
new file mode 100644
index 00000000..619ece31
--- /dev/null
+++ b/src/views/components/uikit.html.ecr
@@ -0,0 +1,2 @@
+
+
diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr
index 920d4d1b..552405a7 100644
--- a/src/views/download-manager.html.ecr
+++ b/src/views/download-manager.html.ecr
@@ -25,14 +25,14 @@
|
- |
+ |
|
- |
+ |
|
diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr
index 402c137b..7a968587 100644
--- a/src/views/download.html.ecr
+++ b/src/views/download.html.ecr
@@ -1,81 +1,115 @@
Download from MangaDex
-
-
-
-
-
-