Compare commits

..

29 Commits

Author SHA1 Message Date
Alex Ling 8d84a3c502 Merge pull request #221 from hkalexling/rc/0.23.0
v0.23.0
2021-08-28 18:44:50 +08:00
Alex Ling a26b4b3965 Add Discord badge 2021-08-28 10:27:43 +00:00
Alex Ling f2dd20cdec Bump version to 0.23.0 2021-08-22 08:20:24 +00:00
Alex Ling 64d6cd293c Remove MangaDex from README 2021-08-22 08:20:01 +00:00
Alex Ling 08dc0601e8 Remove page_margin from README 2021-08-22 08:18:28 +00:00
Alex Ling 9c983df7e9 Merge pull request #216 from Leeingnyo/feature/update-crystal-1.0
Update crystal version to 1.0
2021-08-22 15:59:20 +08:00
Alex Ling efc547f5b2 Fix "Executing query" spam in log 2021-08-22 07:34:29 +00:00
Alex Ling 995ca3b40f Update ARM Dockerfile 2021-08-20 08:26:11 +00:00
Alex Ling 864435d3f9 Upgrade dependencies for Crystal 1.0.0 2021-08-20 07:15:16 +00:00
Alex Ling 64c145cf80 Merge branch 'dev' of https://github.com/hkalexling/Mango into feature/update-crystal-1.0 2021-08-20 00:36:38 +00:00
Alex Ling 6549253ed1 Merge branch 'dev' of https://github.com/hkalexling/Mango into dev 2021-08-20 00:26:59 +00:00
Alex Ling d9565718a4 Remove MangaDex integration 2021-08-20 00:25:21 +00:00
Alex Ling 400c3024fd Merge pull request #219 from Leeingnyo/feature/enhance-paged-mode
Enhance the paged mode reader
2021-08-20 08:13:39 +08:00
Leeingnyo a703175b3a Parenthesize to avoid a misbehavior 2021-08-19 15:03:01 +09:00
Leeingnyo 83b122ab75 Implement a controller to toggle flip animation 2021-08-18 23:46:45 +09:00
Leeingnyo 1e7d6ba5b1 Keep an image ratio at the paged mode, clamping the image in a screen 2021-08-18 23:46:45 +09:00
Leeingnyo 4d1ad8fb38 Prevent load images 2021-08-18 23:46:45 +09:00
Leeingnyo d544252e3e Add preload lookahead controller 2021-08-18 23:46:45 +09:00
Leeingnyo b02b28d3e3 Implement to preload images when viewer is paged mode 2021-08-18 23:07:38 +09:00
Leeingnyo d7efe1e553 Use yaml-static in Dockerfile 2021-08-18 21:23:28 +09:00
Alex Ling 1973564272 Revert "Subscription manager"
This reverts commit a612500b0f.
2021-08-18 12:09:59 +00:00
Alex Ling 29923f6dc7 Merge pull request #214 from Leeingnyo/feature/http-cache
HTTP cache for thumbnails, pages, dimensions
2021-08-18 07:37:21 +08:00
Leeingnyo 4a261d5ff8 Set Cache-Control header at page, dimensions API 2021-08-18 01:33:22 +09:00
Alex Ling 31d425d462 Document the 304 responses 2021-08-17 07:14:24 +00:00
Leeingnyo a21681a6d7 Update a container version of bulid workflow 2021-08-16 13:23:58 +09:00
Leeingnyo 208019a0b9 Update Docker image 2021-08-16 01:48:18 +09:00
Leeingnyo 54e2a54ecb Update cyrstal-lang, libraries 2021-08-16 01:48:18 +09:00
Leeingnyo 2426ef05ec Apply cache on dimensions api
Use zip_path and mtime for hashing
It used for weak validation
2021-08-15 21:41:44 +09:00
Leeingnyo 25b90a8724 Apply cache on page, cover api
Get image data and use it for hashing
2021-08-15 21:33:08 +09:00
27 changed files with 519 additions and 1803 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.36.1-alpine image: crystallang/crystal:1.0.0-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
+2 -2
View File
@@ -1,9 +1,9 @@
FROM crystallang/crystal:0.36.1-alpine AS builder FROM crystallang/crystal:1.0.0-alpine AS builder
WORKDIR /Mango WORKDIR /Mango
COPY . . COPY . .
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN make static || make static RUN make static || make static
FROM library/alpine FROM library/alpine
+4 -4
View File
@@ -2,10 +2,10 @@ FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
COPY mango-arm32v7.o . COPY mango-arm32v7.o .
+4 -4
View File
@@ -2,10 +2,10 @@ FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd .. RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
COPY mango-arm64v8.o . COPY mango-arm64v8.o .
+2 -4
View File
@@ -2,7 +2,7 @@
# Mango # Mango
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q)
Mango is a self-hosted manga server and reader. Its features include Mango is a self-hosted manga server and reader. Its features include
@@ -13,7 +13,6 @@ Mango is a self-hosted manga server and reader. Its features include
- Supports nested folders in library - Supports nested folders in library
- Automatically stores reading progress - Automatically stores reading progress
- Thumbnail generation - Thumbnail generation
- Built-in [MangaDex](https://mangadex.org/) downloader
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites - Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app - The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless - All the static files are embedded in the binary, so the deployment process is easy and painless
@@ -52,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.22.0 Mango - Manga Server and Web Reader. Version 0.23.0
Usage: Usage:
@@ -87,7 +86,6 @@ log_level: info
upload_path: ~/mango/uploads upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins plugin_path: ~/mango/plugins
download_timeout_seconds: 30 download_timeout_seconds: 30
page_margin: 30
disable_login: false disable_login: false
default_username: "" default_username: ""
auth_proxy_header_name: "" auth_proxy_header_name: ""
-285
View File
@@ -1,285 +0,0 @@
const downloadComponent = () => {
return {
chaptersLimit: 1000,
loading: false,
addingToDownload: false,
searchAvailable: false,
searchInput: '',
data: {},
chapters: [],
mangaAry: undefined, // undefined: not searching; []: searched but no result
candidateManga: {},
langChoice: 'All',
groupChoice: 'All',
chapterRange: '',
volumeRange: '',
get languages() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
set.add(chp.language);
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
get groups() {
const set = new Set();
if (this.data.chapters) {
this.data.chapters.forEach(chp => {
Object.keys(chp.groups).forEach(g => {
set.add(g);
});
});
}
const ary = [...set].sort();
ary.unshift('All');
return ary;
},
init() {
const tableObserver = new MutationObserver(() => {
console.log('table mutated');
$("#selectable").selectable({
filter: 'tr'
});
});
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true
});
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
if (data.expires && data.expires > Math.floor(Date.now() / 1000))
this.searchAvailable = true;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
filtersUpdated() {
if (!this.data.chapters)
this.chapters = [];
const filters = {
chapter: this.parseRange(this.chapterRange),
volume: this.parseRange(this.volumeRange),
lang: this.langChoice,
group: this.groupChoice
};
console.log('filters:', filters);
let _chapters = this.data.chapters.slice();
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
_chapters = _chapters.filter(c => {
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return;
}
if (k === 'lang') {
_chapters = _chapters.filter(c => c.language === v);
return;
}
const lb = parseFloat(v[0]);
const ub = parseFloat(v[1]);
if (isNaN(lb) && isNaN(ub)) return;
_chapters = _chapters.filter(c => {
const val = parseFloat(c[k]);
if (isNaN(val)) return false;
if (isNaN(lb))
return val <= ub;
else if (isNaN(ub))
return val >= lb;
else
return val >= lb && val <= ub;
});
});
console.log('filtered chapters:', _chapters);
this.chapters = _chapters;
},
search() {
if (this.loading || this.searchInput === '') return;
this.data = {};
this.mangaAry = undefined;
var int_id = -1;
try {
const path = new URL(this.searchInput).pathname;
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
int_id = parseInt(match[1]);
} catch (e) {
int_id = parseInt(this.searchInput);
}
if (!isNaN(int_id) && int_id > 0) {
// The input is a positive integer. We treat it as an ID.
this.loading = true;
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to get manga info. Error: ' + data.error);
return;
}
this.data = data;
this.chapters = data.chapters;
this.mangaAry = undefined;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
} else {
if (!this.searchAvailable) {
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex. If you are trying to search MangaDex with a search term, please log in to MangaDex first by going to "Admin -> Connect to MangaDex".');
return;
}
// Search as a search term
this.loading = true;
$.getJSON(`${base_url}api/admin/mangadex/search?${$.param({
query: this.searchInput
})}`)
.done((data) => {
if (data.error) {
alert('danger', `Failed to search MangaDex. Error: ${data.error}`);
return;
}
this.mangaAry = data.manga;
this.data = {};
})
.fail((jqXHR, status) => {
alert('danger', `Failed to search MangaDex. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
}
},
parseRange(str) {
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
const matches = str.match(regex);
var num;
if (!matches) {
return [null, null];
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
// e.g., <= 30
num = parseInt(matches[2]);
if (isNaN(num)) {
return [null, null];
}
switch (matches[1]) {
case '<':
return [null, num - 1];
case '<=':
return [null, num];
case '>':
return [num + 1, null];
case '>=':
return [num, null];
}
} else if (typeof matches[3] !== 'undefined') {
// a single number
num = parseInt(matches[3]);
if (isNaN(num)) {
return [null, null];
}
return [num, num];
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
// e.g., 10 - 23
num = parseInt(matches[4]);
const n2 = parseInt(matches[5]);
if (isNaN(num) || isNaN(n2) || num > n2) {
return [null, null];
}
return [num, n2];
} else {
// empty or space only
return [null, null];
}
},
unescapeHTML(str) {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
download() {
const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
const ids = selected.map((i, e) => {
return parseInt($(e).find('td').first().text());
}).get();
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
console.log(ids);
this.addingToDownload = true;
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/download`,
data: JSON.stringify({
chapters: chapters
}),
contentType: "application/json",
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
return;
}
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.addingToDownload = false;
});
});
},
chooseManga(manga) {
this.candidateManga = manga;
UIkit.modal($('#modal').get(0)).show();
},
confirmManga(id) {
UIkit.modal($('#modal').get(0)).hide();
this.searchInput = id;
this.search();
}
};
};
-61
View File
@@ -1,61 +0,0 @@
const component = () => {
return {
username: '',
password: '',
expires: undefined,
loading: true,
loggingIn: false,
init() {
this.loading = true;
$.ajax({
type: 'GET',
url: `${base_url}api/admin/mangadex/expires`,
contentType: "application/json",
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
return;
}
this.expires = data.expires;
this.loading = false;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
login() {
if (!(this.username && this.password)) return;
this.loggingIn = true;
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/login`,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
username: this.username,
password: this.password
})
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to log in. Error: ${data.error}`);
return;
}
this.expires = data.expires;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loggingIn = false;
});
},
get expired() {
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
}
};
};
+132 -319
View File
@@ -1,326 +1,139 @@
const component = () => { const loadPlugin = id => {
return { localStorage.setItem('plugin', id);
plugins: [], const url = `${location.protocol}//${location.host}${location.pathname}`;
info: undefined, const newURL = `${url}?${$.param({
pid: undefined, plugin: id
chapters: undefined, // undefined: not searched yet, []: empty })}`;
manga: undefined, // undefined: not searched yet, []: empty window.location.href = newURL;
allChapters: [], };
query: '',
mangaTitle: '',
searching: false,
adding: false,
sortOptions: [],
showFilters: false,
appliedFilters: [],
chaptersLimit: 500,
listManga: false,
init() { $(() => {
const tableObserver = new MutationObserver(() => { var storedID = localStorage.getItem('plugin');
console.log('table mutated'); if (storedID && storedID !== pid) {
$('#selectable').selectable({ loadPlugin(storedID);
filter: 'tr' } else {
}); $('#controls').removeAttr('hidden');
}); }
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true
});
fetch(`${base_url}api/admin/plugin`)
.then(res => res.json())
.then(data => {
if (!data.success)
throw new Error(data.error);
this.plugins = data.plugins;
const pid = localStorage.getItem('plugin'); $('#search-input').keypress(event => {
if (pid && this.plugins.map(p => p.id).includes(pid)) if (event.which === 13) {
return this.loadPlugin(pid); search();
}
});
$('#plugin-select').val(pid);
$('#plugin-select').change(() => {
const id = $('#plugin-select').val();
loadPlugin(id);
});
});
if (this.plugins.length > 0) let mangaTitle = "";
this.loadPlugin(this.plugins[0].id); let searching = false;
}) const search = () => {
.catch(e => { if (searching)
alert('danger', `Failed to list the available plugins. Error: ${e}`); return;
});
},
loadPlugin(pid) {
fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid
})}`)
.then(res => res.json())
.then(data => {
if (!data.success)
throw new Error(data.error);
this.info = data.info;
this.pid = pid;
})
.catch(e => {
alert('danger', `Failed to get plugin metadata. Error: ${e}`);
});
},
pluginChanged() {
this.loadPlugin(this.pid);
localStorage.setItem('plugin', this.pid);
},
get chapterKeys() {
if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k));
},
searchChapters(query) {
this.searching = true;
this.allChapters = [];
this.chapters = undefined;
this.listManga = false;
fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({
plugin: this.pid,
query: query
})}`)
.then(res => res.json())
.then(data => {
if (!data.success)
throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
this.allChapters = data.chapters; const query = $.param({
this.chapters = data.chapters; query: $('#search-input').val(),
}) plugin: pid
.catch(e => { });
alert('danger', `Failed to list chapters. Error: ${e}`); $.ajax({
}) type: 'GET',
.finally(() => { url: `${base_url}api/admin/plugin/list?${query}`,
this.searching = false; contentType: "application/json",
}); dataType: 'json'
}, })
searchManga() { .done(data => {
this.searching = true; console.log(data);
this.allChapters = []; if (data.error) {
this.chapters = undefined; alert('danger', `Search failed. Error: ${data.error}`);
this.manga = undefined;
fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid,
query: this.query
})}`)
.then(res => res.json())
.then(data => {
if (!data.success)
throw new Error(data.error);
this.manga = data.manga;
this.listManga = true;
})
.catch(e => {
alert('danger', `Search failed. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
search() {
this.manga = undefined;
if (this.info.version === 1) {
this.searchChapters(this.query);
} else {
this.searchManga();
}
},
selectAll() {
$('tbody > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
download() {
const selected = $('tbody > tr.ui-selected').get();
if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
const ids = selected.map(e => e.id);
const chapters = this.chapters.filter(c => ids.includes(c.id));
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: 'POST',
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle
}),
headers: {
"Content-Type": "application/json"
}
})
.then(res => res.json())
.then(data => {
if (!data.success)
throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
})
.catch(e => {
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
})
.finally(() => {
this.adding = false;
});
})
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split('-')[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option)
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log('initial size:', ary.length);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === 'array' && filter.value === 'all') continue;
console.log('applying filter:', filter);
if (filter.type === 'string') {
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
}
if (filter.type === 'number-min') {
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
}
if (filter.type === 'number-max') {
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
}
if (filter.type === 'date-min') {
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
}
if (filter.type === 'date-max') {
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
}
if (filter.type === 'array') {
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
}
console.log('filtered size:', ary.length);
}
return ary;
},
// option:
// - 1: asending
// - -1: desending
// - 0: unsorted
sort(key, option) {
if (option === 0) {
this.chapters = this.filteredChapters;
return; return;
} }
mangaTitle = data.title;
this.chapters = this.filteredChapters.sort((a, b) => { $('#title-text').text(data.title);
const comp = this.compare(a[key], b[key]); buildTable(data.chapters);
return option < 0 ? comp * -1 : comp; })
}); .fail((jqXHR, status) => {
}, alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
compare(a, b) { })
if (a === b) return 0; .always(() => {});
};
// try numbers
// this must come before the date checks, because any integer would const buildTable = (chapters) => {
// also be parsed as a date. $('#table').attr('hidden', '');
if (!isNaN(a) && !isNaN(b)) $('table').empty();
return Number(a) - Number(b);
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
// try dates const thead = `<thead><tr>${keys}</tr></thead>`;
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b))) $('table').append(thead);
return this.parseDate(a) - this.parseDate(b);
const rows = chapters.map(ch => {
const preprocessString = (val) => { const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
if (typeof val !== 'string') return val; return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
return val.toLowerCase().replace(/\s\s/g, ' ').trim(); });
}; const tbody = `<tbody id="selectable">${rows}</tbody>`;
$('table').append(tbody);
return preprocessString(a) > preprocessString(b) ? 1 : -1;
}, $('#selectable').selectable({
fieldType(values) { filter: 'tr'
if (values.every(v => !isNaN(v))) return 'number'; // display input for number range });
if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range
if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains $('#table table').tablesorter();
return 'string'; // display input for string searching. $('#table').removeAttr('hidden');
// for the last two, if the number of options is small enough (say < 50), display a multi-select2 };
},
get filters() { const selectAll = () => {
if (this.allChapters.length < 1) return []; $('tbody > tr').each((i, e) => {
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k)); $(e).addClass('ui-selected');
return keys.map(k => { });
let values = this.allChapters.map(c => c[k]); };
const type = this.fieldType(values);
const unselect = () => {
if (type === 'array') { $('tbody > tr').each((i, e) => {
// if the type is an array, return the list of available elements $(e).removeClass('ui-selected');
// example: an array of groups or authors });
values = Array.from(new Set(values.flat().map(v => { };
if (typeof v === 'string') return v.toLowerCase();
}))); const download = () => {
} const selected = $('tbody > tr.ui-selected');
if (selected.length === 0) return;
return { UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
key: k, $('#download-btn').attr('hidden', '');
type: type, $('#download-spinner').removeAttr('hidden');
values: values const chapters = selected.map((i, e) => {
}; return {
}); id: $(e).attr('data-id'),
}, title: $(e).attr('data-title')
applyFilters() { }
const values = $('#filter-form input, #filter-form select') }).get();
.get() console.log(chapters);
.map(i => ({ $.ajax({
key: i.getAttribute('data-filter-key'), type: 'POST',
value: i.value.trim(), url: base_url + 'api/admin/plugin/download',
type: i.getAttribute('data-filter-type') data: JSON.stringify({
})); plugin: pid,
this.appliedFilters = values; chapters: chapters,
this.chapters = this.filteredChapters; title: mangaTitle
}, }),
clearFilters() { contentType: "application/json",
$('#filter-form input').get().forEach(i => i.value = ''); dataType: 'json'
this.appliedFilters = []; })
this.chapters = this.filteredChapters; .done(data => {
}, console.log(data);
mangaSelected(event) { if (data.error) {
const mid = event.currentTarget.getAttribute('data-id'); alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
this.searchChapters(mid); return;
}, }
parseDate(str) { const successCount = parseInt(data.success);
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g; const failCount = parseInt(data.fail);
// Basic sanity check to make sure it's an actual date. alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
// We need this because Date.parse thinks 'Chapter 1' is a date. })
if (!regex.test(str)) .fail((jqXHR, status) => {
return NaN; alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
return Date.parse(str); })
} .always(() => {
}; $('#download-spinner').attr('hidden', '');
$('#download-btn').removeAttr('hidden');
});
});
}; };
+37 -5
View File
@@ -6,11 +6,13 @@ const readerComponent = () => {
alertClass: 'uk-alert-primary', alertClass: 'uk-alert-primary',
items: [], items: [],
curItem: {}, curItem: {},
enableFlipAnimation: true,
flipAnimation: null, flipAnimation: null,
longPages: false, longPages: false,
lastSavedPage: page, lastSavedPage: page,
selectedIndex: 0, // 0: not selected; 1: the first page selectedIndex: 0, // 0: not selected; 1: the first page
margin: 30, margin: 30,
preloadLookahead: 3,
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
@@ -52,6 +54,16 @@ const readerComponent = () => {
if (savedMargin) { if (savedMargin) {
this.margin = savedMargin; this.margin = savedMargin;
} }
// Preload Images
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
@@ -60,6 +72,12 @@ const readerComponent = () => {
this.msg = errMsg; this.msg = errMsg;
}) })
}, },
/**
* Preload an image, which is expected to be cached
*/
preloadImage(url) {
(new Image()).src = url;
},
/** /**
* Handles the `change` event for the page selector * Handles the `change` event for the page selector
*/ */
@@ -111,12 +129,18 @@ const readerComponent = () => {
if (newIdx <= 0 || newIdx > this.items.length) return; if (newIdx <= 0 || newIdx > this.items.length) return;
if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
}
this.toPage(newIdx); this.toPage(newIdx);
if (isNext) if (this.enableFlipAnimation) {
this.flipAnimation = 'right'; if (isNext)
else this.flipAnimation = 'right';
this.flipAnimation = 'left'; else
this.flipAnimation = 'left';
}
setTimeout(() => { setTimeout(() => {
this.flipAnimation = null; this.flipAnimation = null;
@@ -287,6 +311,14 @@ const readerComponent = () => {
marginChanged() { marginChanged() {
localStorage.setItem('margin', this.margin); localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex); this.toPage(this.selectedIndex);
} },
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
}; };
} }
+19 -23
View File
@@ -2,31 +2,31 @@ version: 2.0
shards: shards:
ameba: ameba:
git: https://github.com/crystal-ameba/ameba.git git: https://github.com/crystal-ameba/ameba.git
version: 0.14.0 version: 0.14.3
archive: archive:
git: https://github.com/hkalexling/archive.cr.git git: https://github.com/hkalexling/archive.cr.git
version: 0.4.0 version: 0.5.0
baked_file_system: baked_file_system:
git: https://github.com/schovi/baked_file_system.git git: https://github.com/schovi/baked_file_system.git
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b version: 0.10.0
clim: clim:
git: https://github.com/at-grandpa/clim.git git: https://github.com/at-grandpa/clim.git
version: 0.12.0 version: 0.17.1
db: db:
git: https://github.com/crystal-lang/crystal-db.git git: https://github.com/crystal-lang/crystal-db.git
version: 0.9.0 version: 0.10.1
duktape: duktape:
git: https://github.com/jessedoyle/duktape.cr.git git: https://github.com/jessedoyle/duktape.cr.git
version: 0.20.0 version: 1.0.0
exception_page: exception_page:
git: https://github.com/crystal-loot/exception_page.git git: https://github.com/crystal-loot/exception_page.git
version: 0.1.4 version: 0.1.5
http_proxy: http_proxy:
git: https://github.com/mamantoha/http_proxy.git git: https://github.com/mamantoha/http_proxy.git
@@ -34,49 +34,45 @@ shards:
image_size: image_size:
git: https://github.com/hkalexling/image_size.cr.git git: https://github.com/hkalexling/image_size.cr.git
version: 0.4.0 version: 0.5.0
kemal: kemal:
git: https://github.com/kemalcr/kemal.git git: https://github.com/kemalcr/kemal.git
version: 0.27.0 version: 1.0.0
kemal-session: kemal-session:
git: https://github.com/kemalcr/kemal-session.git git: https://github.com/kemalcr/kemal-session.git
version: 0.13.0 version: 1.0.0
kilt: kilt:
git: https://github.com/jeromegn/kilt.git git: https://github.com/jeromegn/kilt.git
version: 0.4.0 version: 0.4.1
koa: koa:
git: https://github.com/hkalexling/koa.git git: https://github.com/hkalexling/koa.git
version: 0.7.0 version: 0.8.0
mangadex:
git: https://github.com/hkalexling/mangadex.git
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
mg: mg:
git: https://github.com/hkalexling/mg.git git: https://github.com/hkalexling/mg.git
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3 version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
myhtml: myhtml:
git: https://github.com/kostya/myhtml.git git: https://github.com/kostya/myhtml.git
version: 1.5.1 version: 1.5.8
open_api: open_api:
git: https://github.com/jreinert/open_api.cr.git git: https://github.com/hkalexling/open_api.cr.git
version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
radix: radix:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git
version: 0.3.9 version: 0.4.1
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.16.0 version: 0.18.0
tallboy: tallboy:
git: https://github.com/epoch/tallboy.git git: https://github.com/epoch/tallboy.git
version: 0.9.3 version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
+3 -6
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.22.0 version: 0.23.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -8,7 +8,7 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.36.1 crystal: 1.0.0
license: MIT license: MIT
@@ -21,7 +21,6 @@ dependencies:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b
archive: archive:
github: hkalexling/archive.cr github: hkalexling/archive.cr
ameba: ameba:
@@ -30,7 +29,6 @@ dependencies:
github: at-grandpa/clim github: at-grandpa/clim
duktape: duktape:
github: jessedoyle/duktape.cr github: jessedoyle/duktape.cr
version: ~> 0.20.0
myhtml: myhtml:
github: kostya/myhtml github: kostya/myhtml
http_proxy: http_proxy:
@@ -41,7 +39,6 @@ dependencies:
github: hkalexling/koa github: hkalexling/koa
tallboy: tallboy:
github: epoch/tallboy github: epoch/tallboy
branch: master
mg: mg:
github: hkalexling/mg github: hkalexling/mg
mangadex:
github: hkalexling/mangadex
-1
View File
@@ -53,7 +53,6 @@ class Entry
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "title_id", @book.id json.field "title_id", @book.id
json.field "title_title", @book.title
json.field "pages" { json.number @pages } json.field "pages" { json.number @pages }
end end
end end
+5 -1
View File
@@ -34,7 +34,11 @@ class Logger
end end
@backend.formatter = Log::Formatter.new &format_proc @backend.formatter = Log::Formatter.new &format_proc
Log.setup @@severity, @backend
Log.setup do |c|
c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend
end
end end
def self.get_severity(level = "") : Log::Severity def self.get_severity(level = "") : Log::Severity
-172
View File
@@ -1,172 +0,0 @@
require "mangadex"
require "compress/zip"
require "../rename"
require "./ext"
module MangaDex
class PageJob
property success = false
property url : String
property filename : String
property writer : Compress::Zip::Writer
property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
end
end
class Downloader < Queue::Downloader
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
.to_i32
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
use_default
def initialize
@client = Client.from_config
super
end
def pop : Queue::Job?
job = nil
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id not like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
end
end
job
end
private def download(job : Queue::Job)
@downloading = true
@queue.set_status Queue::JobStatus::Downloading, job
begin
chapter = @client.chapter job.id
# We must put the `.pages` call in a rescue block to handle external
# chapters.
pages = chapter.pages
rescue e
Logger.error e
@queue.set_status Queue::JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
return
end
@queue.set_pages pages.size, job
lib_dir = @library_path
rename_rule = Rename::Rule.new \
Config.current.mangadex["manga_rename_rule"].to_s
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
# Find the number of digits needed to store the number of pages
len = Math.log10(pages.size).to_i + 1
writer = Compress::Zip::Writer.new zip_path
# Create a buffered channel. It works as an FIFO queue
channel = Channel(PageJob).new pages.size
spawn do
pages.each_with_index do |url, i|
fn = Path.new(URI.parse(url).path).basename
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
Logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
Logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job
break unless @queue.exists? job
end
end
spawn do
page_jobs = [] of PageJob
pages.size.times do
page_job = channel.receive
break unless @queue.exists? job
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job
if page_job.success
@queue.add_success job
else
@queue.add_fail job
msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job
Logger.error msg
end
end
unless @queue.exists? job
Logger.debug "Download cancelled"
@downloading = false
next
end
fail_count = page_jobs.count { |j| !j.success }
Logger.debug "Download completed. " \
"#{fail_count}/#{page_jobs.size} failed"
writer.close
filename = File.join File.dirname(zip_path), File.basename(zip_path,
".part")
File.rename zip_path, filename
Logger.debug "cbz File created at #{filename}"
zip_exception = validate_archive filename
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status Queue::JobStatus::Error, job
elsif fail_count > 0
@queue.set_status Queue::JobStatus::MissingPages, job
else
@queue.set_status Queue::JobStatus::Completed, job
end
@downloading = false
end
end
private def download_page(job : PageJob)
Logger.debug "downloading #{job.url}"
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
begin
HTTP::Client.get job.url, headers do |res|
unless res.success?
raise "Failed to download page #{job.url}. " \
"[#{res.status_code}] #{res.status_message}"
end
job.writer.add job.filename, res.body_io
end
job.success = true
rescue e
Logger.error e
job.success = false
end
end
end
end
-60
View File
@@ -1,60 +0,0 @@
private macro properties_to_hash(names)
{
{% for name in names %}
"{{name.id}}" => {{name.id}}.to_s,
{% end %}
}
end
# Monkey-patch the structures in the `mangadex` shard to suit our needs
module MangaDex
struct Client
@@group_cache = {} of String => Group
def self.from_config : Client
self.new base_url: Config.current.mangadex["base_url"].to_s,
api_url: Config.current.mangadex["api_url"].to_s
end
end
struct Manga
def rename(rule : Rename::Rule)
rule.render properties_to_hash %w(id title author artist)
end
def to_info_json
hash = JSON.parse(to_json).as_h
_chapters = chapters.map do |c|
JSON.parse c.to_info_json
end
hash["chapters"] = JSON::Any.new _chapters
hash.to_json
end
end
struct Chapter
def rename(rule : Rename::Rule)
hash = properties_to_hash %w(id title volume chapter lang_code language)
hash["groups"] = groups.join(",", &.name)
rule.render hash
end
def full_title
rule = Rename::Rule.new \
Config.current.mangadex["chapter_rename_rule"].to_s
rename rule
end
def to_info_json
hash = JSON.parse(to_json).as_h
hash["language"] = JSON::Any.new language
_groups = {} of String => JSON::Any
groups.each do |g|
_groups[g.name] = JSON::Any.new g.id
end
hash["groups"] = JSON::Any.new _groups
hash["full_title"] = JSON::Any.new full_title
hash.to_json
end
end
end
+1 -3
View File
@@ -2,13 +2,12 @@ require "./config"
require "./queue" require "./queue"
require "./server" require "./server"
require "./main_fiber" require "./main_fiber"
require "./mangadex/*"
require "./plugin/*" require "./plugin/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
require "tallboy" require "tallboy"
MANGO_VERSION = "0.22.0" MANGO_VERSION = "0.23.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{
@@ -59,7 +58,6 @@ class CLI < Clim
Storage.default Storage.default
Queue.default Queue.default
Library.default Library.default
MangaDex::Downloader.default
Plugin::Downloader.default Plugin::Downloader.default
spawn do spawn do
+17 -139
View File
@@ -2,8 +2,6 @@ require "duktape/runtime"
require "myhtml" require "myhtml"
require "xml" require "xml"
require "./subscriptions"
class Plugin class Plugin
class Error < ::Exception class Error < ::Exception
end end
@@ -18,19 +16,12 @@ class Plugin
end end
struct Info struct Info
include JSON::Serializable
{% for name in ["id", "title", "placeholder"] %} {% for name in ["id", "title", "placeholder"] %}
getter {{name.id}} = "" getter {{name.id}} = ""
{% end %} {% end %}
getter wait_seconds = 0u64 getter wait_seconds : UInt64 = 0
getter version = 0u64
getter settings = {} of String => String?
getter dir : String getter dir : String
@[JSON::Field(ignore: true)]
@json : JSON::Any
def initialize(@dir) def initialize(@dir)
info_path = File.join @dir, "info.json" info_path = File.join @dir, "info.json"
@@ -46,16 +37,6 @@ class Plugin
@{{name.id}} = @json[{{name}}].as_s @{{name.id}} = @json[{{name}}].as_s
{% end %} {% end %}
@wait_seconds = @json["wait_seconds"].as_i.to_u64 @wait_seconds = @json["wait_seconds"].as_i.to_u64
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
settings_hash.each do |k, v|
unless str_value = v.as_s?
raise "The settings object can only contain strings or null"
end
@settings[k] = str_value
end
end
unless @id.alphanumeric_underscore? unless @id.alphanumeric_underscore?
raise "Plugin ID can only contain alphanumeric characters and " \ raise "Plugin ID can only contain alphanumeric characters and " \
@@ -133,22 +114,6 @@ class Plugin
@info.not_nil! @info.not_nil!
end end
def subscribe(subscription : Subscription)
list = SubscriptionList.new info.dir
list << subscription
list.save
end
def list_subscriptions
SubscriptionList.new(info.dir).ary
end
def unsubscribe(id : String)
list = SubscriptionList.new info.dir
list.reject &.id.== id
list.save
end
def initialize(id : String) def initialize(id : String)
Plugin.build_info_ary Plugin.build_info_ary
@@ -173,12 +138,6 @@ class Plugin
sbx.push_string path sbx.push_string path
sbx.put_prop_string -2, "storage_path" sbx.put_prop_string -2, "storage_path"
sbx.push_pointer info.dir.as(Void*)
path = sbx.require_pointer(-1).as String
sbx.pop
sbx.push_string path
sbx.put_prop_string -2, "info_dir"
def_helper_functions sbx def_helper_functions sbx
end end
@@ -193,67 +152,23 @@ class Plugin
{% end %} {% end %}
end end
def assert_manga_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s
rescue e
raise Error.new "Missing required fields in the Manga type"
end
def assert_chapter_type(obj : JSON::Any)
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
obj["manga_title"].as_s
rescue e
raise Error.new "Missing required fields in the Chapter type"
end
def assert_page_type(obj : JSON::Any)
obj["url"].as_s && obj["filename"].as_s
rescue e
raise Error.new "Missing required fields in the Page type"
end
def search_manga(query : String)
if info.version == 1
raise Error.new "Manga searching is only available for plugins targeting API " \
"v2 or above"
end
json = eval_json "searchManga('#{query}')"
begin
json.as_a.each do |obj|
assert_manga_type obj
end
rescue e
raise Error.new e.message
end
json
end
def list_chapters(query : String) def list_chapters(query : String)
json = eval_json "listChapters('#{query}')" json = eval_json "listChapters('#{query}')"
begin begin
if info.version > 1 check_fields ["title", "chapters"]
# Since v2, listChapters returns an array
json.as_a.each do |obj| ary = json["chapters"].as_a
assert_chapter_type obj ary.each do |obj|
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
end end
else
check_fields ["title", "chapters"]
ary = json["chapters"].as_a title = obj["title"]?
ary.each do |obj| raise "Field `title` missing from `listChapters` outputs" if title.nil?
id = obj["id"]?
raise "Field `id` missing from `listChapters` outputs" if id.nil?
unless id.to_s.alphanumeric_underscore?
raise "The `id` field can only contain alphanumeric characters " \
"and underscores"
end
title = obj["title"]?
if title.nil?
raise "Field `title` missing from `listChapters` outputs"
end
end
end end
rescue e rescue e
raise Error.new e.message raise Error.new e.message
@@ -264,14 +179,10 @@ class Plugin
def select_chapter(id : String) def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')" json = eval_json "selectChapter('#{id}')"
begin begin
if info.version > 1 check_fields ["title", "pages"]
assert_chapter_type json
else
check_fields ["title", "pages"]
if json["title"].to_s.empty? if json["title"].to_s.empty?
raise "The `title` field of the chapter can not be empty" raise "The `title` field of the chapter can not be empty"
end
end end
rescue e rescue e
raise Error.new e.message raise Error.new e.message
@@ -283,19 +194,7 @@ class Plugin
json = eval_json "nextPage()" json = eval_json "nextPage()"
return if json.size == 0 return if json.size == 0
begin begin
assert_page_type json check_fields ["filename", "url"]
rescue e
raise Error.new e.message
end
json
end
def new_chapters(manga_id : String, after : Int64)
json = eval_json "newChapters('#{manga_id}', #{after})"
begin
json.as_a.each do |obj|
assert_chapter_type obj
end
rescue e rescue e
raise Error.new e.message raise Error.new e.message
end end
@@ -480,27 +379,6 @@ class Plugin
end end
sbx.put_prop_string -2, "storage" sbx.put_prop_string -2, "storage"
if info.version > 1
sbx.push_proc 1 do |ptr|
env = Duktape::Sandbox.new ptr
key = env.require_string 0
env.get_global_string "info_dir"
info_dir = env.require_string -1
env.pop
info = Info.new info_dir
if value = info.settings[key]?
env.push_string value
else
env.push_undefined
end
env.call_success
end
sbx.put_prop_string -2, "settings"
end
sbx.put_prop_string -2, "mango" sbx.put_prop_string -2, "mango"
end end
end end
-87
View File
@@ -1,87 +0,0 @@
require "uuid"
enum FilterType
String
NumMin
NumMax
DateMin
DateMax
Array
def self.from_string(str)
case str
when "string"
String
when "number-min"
NumMin
when "number-max"
NumMax
when "date-min"
DateMin
when "date-max"
DateMax
when "array"
Array
else
raise "Unknown filter type with string #{str}"
end
end
end
struct Filter
include JSON::Serializable
property key : String
property value : String | Int32 | Int64 | Float32 | Nil
property type : FilterType
def initialize(@key, @value, @type)
end
def self.from_json(str) : Filter
json = JSON.parse str
key = json["key"].as_s
type = FilterType.from_string json["type"].as_s
_value = json["value"]
value = _value.as_s? || _value.as_i32? || _value.as_i64? ||
_value.as_f32? || nil
self.new key, value, type
end
end
struct Subscription
include JSON::Serializable
property id : String
property plugin_id : String
property name : String
property created_at : Int64
property last_checked : Int64
property filters = [] of Filter
def initialize(@plugin_id, @name)
@id = UUID.random.to_s
@created_at = Time.utc.to_unix
@last_checked = Time.utc.to_unix
end
end
struct SubscriptionList
@dir : String
@path : String
getter ary = [] of Subscription
forward_missing_to @ary
def initialize(@dir)
@path = Path[@dir, "subscriptions.json"]
if File.exists? @path
@ary = Array(Subscription).from_json File.read @path
end
end
def save
File.write @path, @ary.to_json
end
end
-4
View File
@@ -73,9 +73,5 @@ struct AdminRouter
get "/admin/missing" do |env| get "/admin/missing" do |env|
layout "missing-items" layout "missing-items"
end end
get "/admin/mangadex" do |env|
layout "mangadex"
end
end end
end end
+42 -283
View File
@@ -1,6 +1,6 @@
require "../mangadex/*"
require "../upload" require "../upload"
require "koa" require "koa"
require "digest"
struct APIRouter struct APIRouter
@@api_json : String? @@api_json : String?
@@ -56,31 +56,20 @@ struct APIRouter
"error" => String?, "error" => String?,
} }
Koa.schema("mdChapter", {
"id" => Int64,
"group" => {} of String => String,
}.merge(s %w(title volume chapter language full_title time
manga_title manga_id)),
desc: "A MangaDex chapter")
Koa.schema "mdManga", {
"id" => Int64,
"chapters" => ["mdChapter"],
}.merge(s %w(title description author artist cover_url)),
desc: "A MangaDex manga"
Koa.describe "Returns a page in a manga entry" Koa.describe "Returns a page in a manga entry"
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)" Koa.path "page", schema: Int32, desc: "The page number to return (starts from 1)"
Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
Koa.tag "reader" Koa.tag "reader"
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
@@ -90,7 +79,15 @@ struct APIRouter
raise "Failed to load page #{page} of " \ raise "Failed to load page #{page} of " \
"`#{title.title}/#{entry.title}`" if img.nil? "`#{title.title}/#{entry.title}`" if img.nil?
send_img env, img e_tag = Digest::SHA1.hexdigest img.data
if prev_e_tag == e_tag
env.response.status_code = 304
""
else
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = "public, max-age=86400"
send_img env, img
end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
@@ -102,12 +99,14 @@ struct APIRouter
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.path "eid", desc: "Entry ID" Koa.path "eid", desc: "Entry ID"
Koa.response 200, schema: Bytes, media_type: "image/*" Koa.response 200, schema: Bytes, media_type: "image/*"
Koa.response 304, "Page not modified (only available when `If-None-Match` is set)"
Koa.response 500, "Page not found or not readable" Koa.response 500, "Page not found or not readable"
Koa.tag "library" Koa.tag "library"
get "/api/cover/:tid/:eid" do |env| get "/api/cover/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
@@ -118,7 +117,14 @@ struct APIRouter
raise "Failed to get cover of `#{title.title}/#{entry.title}`" \ raise "Failed to get cover of `#{title.title}/#{entry.title}`" \
if img.nil? if img.nil?
send_img env, img e_tag = Digest::SHA1.hexdigest img.data
if prev_e_tag == e_tag
env.response.status_code = 304
""
else
env.response.headers["ETag"] = e_tag
send_img env, img
end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
@@ -323,58 +329,6 @@ struct APIRouter
end end
end end
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tags ["admin", "mangadex"]
Koa.path "id", desc: "A MangaDex manga ID"
Koa.response 200, schema: "mdManga"
get "/api/admin/mangadex/manga/:id" do |env|
begin
id = env.params.url["id"]
manga = MangaDex::Client.from_config.manga id
send_json env, manga.to_info_json
rescue e
Logger.error e
send_json env, {"error" => e.message}.to_json
end
end
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
On error, returns a JSON that contains the error message in the `error` field.
MD
Koa.tags ["admin", "mangadex", "downloader"]
Koa.body schema: {
"chapters" => ["mdChapter"],
}
Koa.response 200, schema: {
"success" => Int32,
"fail" => Int32,
}
post "/api/admin/mangadex/download" do |env|
begin
chapters = env.params.json["chapters"].as(Array).map &.as_h
jobs = chapters.map { |chapter|
Queue::Job.new(
chapter["id"].as_i64.to_s,
chapter["mangaId"].as_i64.to_s,
chapter["full_title"].as_s,
chapter["mangaTitle"].as_s,
Queue::JobStatus::Pending,
Time.unix chapter["timestamp"].as_i64
)
}
inserted_count = Queue.default.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
Logger.error e
send_json env, {"error" => e.message}.to_json
end
end
ws "/api/admin/mangadex/queue" do |socket, env| ws "/api/admin/mangadex/queue" do |socket, env|
interval_raw = env.params.query["interval"]? interval_raw = env.params.query["interval"]?
interval = (interval_raw.to_i? if interval_raw) || 5 interval = (interval_raw.to_i? if interval_raw) || 5
@@ -539,97 +493,6 @@ struct APIRouter
end end
end end
Koa.describe "Returns a list of available plugins"
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"plugins" => [{
"id" => String,
"title" => String,
}],
}
get "/api/admin/plugin" do |env|
begin
send_json env, {
"success" => true,
"plugins" => Plugin.list,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the metadata of a plugin"
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"info" => {
"dir" => String,
"id" => String,
"title" => String,
"placeholder" => String,
"wait_seconds" => Int32,
"version" => Int32,
"settings" => {} of String => String,
},
}
get "/api/admin/plugin/info" do |env|
begin
plugin = Plugin.new env.params.query["plugin"].as String
send_json env, {
"success" => true,
"info" => plugin.info,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
Only available for plugins targeting API v2 or above.
MD
Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String
Koa.query "query", schema: String
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"manga" => [{
"id" => String,
"title" => String,
}],
}
get "/api/admin/plugin/search" do |env|
begin
query = env.params.query["query"].as String
plugin = Plugin.new env.params.query["plugin"].as String
manga_ary = plugin.search_manga(query).as_a
send_json env, {
"success" => true,
"manga" => manga_ary,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Lists the chapters in a title from a plugin" Koa.describe "Lists the chapters in a title from a plugin"
Koa.tags ["admin", "downloader"] Koa.tags ["admin", "downloader"]
Koa.query "plugin", schema: String Koa.query "plugin", schema: String
@@ -638,8 +501,8 @@ struct APIRouter
"success" => Bool, "success" => Bool,
"error" => String?, "error" => String?,
"chapters?" => [{ "chapters?" => [{
"id" => String, "id" => String,
"title?" => String, "title" => String,
}], }],
"title" => String?, "title" => String?,
} }
@@ -649,14 +512,8 @@ struct APIRouter
plugin = Plugin.new env.params.query["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query json = plugin.list_chapters query
chapters = json["chapters"]
if plugin.info.version == 1 title = json["title"]
chapters = json["chapters"]
title = json["title"]
else
chapters = json
title = nil
end
send_json env, { send_json env, {
"success" => true, "success" => true,
@@ -728,21 +585,32 @@ struct APIRouter
"height" => Int32, "height" => Int32,
}], }],
} }
Koa.response 304, "Not modified (only available when `If-None-Match` is set)"
get "/api/dimensions/:tid/:eid" do |env| get "/api/dimensions/:tid/:eid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
prev_e_tag = env.request.headers["If-None-Match"]?
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
sizes = entry.page_dimensions file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
send_json env, { e_tag = "W/#{file_hash}"
"success" => true, if e_tag == prev_e_tag
"dimensions" => sizes, env.response.status_code = 304
}.to_json ""
else
sizes = entry.page_dimensions
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = "public, max-age=86400"
send_json env, {
"success" => true,
"dimensions" => sizes,
}.to_json
end
rescue e rescue e
Logger.error e Logger.error e
send_json env, { send_json env, {
@@ -1001,115 +869,6 @@ struct APIRouter
end end
end end
Koa.describe "Logs the current user into their MangaDex account", <<-MD
If successful, returns the expiration date (as a unix timestamp) of the newly created token.
MD
Koa.body schema: {
"username" => String,
"password" => String,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
post "/api/admin/mangadex/login" do |env|
begin
username = env.params.json["username"].as String
password = env.params.json["password"].as String
mango_username = get_username env
client = MangaDex::Client.from_config
client.auth username, password
Storage.default.save_md_token mango_username, client.token.not_nil!,
client.token_expires
send_json env, {
"success" => true,
"error" => nil,
"expires" => client.token_expires.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Returns the expiration date (as a unix timestamp) of the mangadex token if it exists"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"expires" => Int64?,
}
Koa.tags ["admin", "mangadex", "users"]
get "/api/admin/mangadex/expires" do |env|
begin
username = get_username env
_, expires = Storage.default.get_md_token username
send_json env, {
"success" => true,
"error" => nil,
"expires" => expires.try &.to_unix,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
Koa.describe "Searches MangaDex for manga matching `query`", <<-MD
Returns an empty list if the current user hasn't logged in to MangaDex.
MD
Koa.query "query"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"manga?" => [{
"id" => Int64,
"title" => String,
"description" => String,
"mainCover" => String,
}],
}
Koa.tags ["admin", "mangadex"]
get "/api/admin/mangadex/search" do |env|
begin
username = get_username env
token, expires = Storage.default.get_md_token username
unless expires && token
raise "No token found for user #{username}"
end
client = MangaDex::Client.from_config
client.token = token
client.token_expires = expires
query = env.params.query["query"]
send_json env, {
"success" => true,
"error" => nil,
"manga" => client.partial_search query,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
doc = Koa.generate doc = Koa.generate
@@api_json = doc.to_json if doc @@api_json = doc.to_json if doc
+10 -5
View File
@@ -72,13 +72,18 @@ struct MainRouter
end end
end end
get "/download" do |env|
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/download/plugins" do |env| get "/download/plugins" do |env|
begin begin
id = env.params.query["plugin"]?
plugins = Plugin.list
plugin = nil
if id
plugin = Plugin.new id
elsif !plugins.empty?
plugin = Plugin.new plugins[0][:id]
end
layout "plugin-download" layout "plugin-download"
rescue e rescue e
Logger.error e Logger.error e
-1
View File
@@ -33,7 +33,6 @@
<option>System</option> <option>System</option>
</select> </select>
</li> </li>
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
</ul> </ul>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
+54 -56
View File
@@ -5,63 +5,61 @@
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button> <button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button> <button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
</div> </div>
<div class="uk-overflow-auto"> <table class="uk-table uk-table-striped uk-overflow-auto">
<table class="uk-table uk-table-striped"> <thead>
<thead> <tr>
<tr> <th>Chapter</th>
<th>Chapter</th> <th>Manga</th>
<th>Manga</th> <th>Progress</th>
<th>Progress</th> <th>Time</th>
<th>Time</th> <th>Status</th>
<th>Status</th> <th>Plugin</th>
<th>Plugin</th> <th>Actions</th>
<th>Actions</th> </tr>
</thead>
<tbody>
<template x-for="job in jobs" :key="job">
<tr :id="`chapter-${job.id}`">
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
</template>
</td>
</tr> </tr>
</thead> </template>
<tbody> </tbody>
<template x-for="job in jobs" :key="job"> </table>
<tr :id="`chapter-${job.id}`"> </div>
<template x-if="job.plugin_id">
<td x-text="job.title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
</template>
<template x-if="job.plugin_id">
<td x-text="job.manga_title"></td>
</template>
<template x-if="!job.plugin_id">
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
</template>
<td x-text="`${job.success_count}/${job.pages}`"></td>
<td x-text="`${moment(job.time).fromNow()}`"></td>
<td>
<span :class="statusClass(job.status)" x-text="job.status"></span>
<template x-if="job.status_message.length > 0">
<div class="uk-inline">
<span uk-icon="info"></span>
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
</div>
</template>
</td>
<td x-text="`${job.plugin_id || ''}`"></td>
<td>
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
<template x-if="job.status_message.length > 0">
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
<% content_for "script" do %> <% content_for "script" do %>
+77 -79
View File
@@ -1,89 +1,87 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<%= render_component "head" %> <%= render_component "head" %>
<body> <body>
<div class="uk-offcanvas-content"> <div class="uk-offcanvas-content">
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
<div id="mobile-nav" uk-offcanvas="overlay: true"> <div id="mobile-nav" uk-offcanvas="overlay: true">
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav> <ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li> <li><a href="<%= base_url %>library">Library</a></li>
<li><a href="<%= base_url %>tags">Tags</a></li> <li><a href="<%= base_url %>tags">Tags</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li class="uk-parent"> <li class="uk-parent">
<a href="#">Download</a> <a href="#">Download</a>
<ul class="uk-nav-sub"> <ul class="uk-nav-sub">
<li><a href="<%= base_url %>download">MangaDex</a></li> <li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li> <li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li> </ul>
</ul> </li>
</li> <% end %>
<% end %> <hr uk-divider>
<hr uk-divider> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <li><a href="<%= base_url %>logout">Logout</a></li>
<li><a href="<%= base_url %>logout">Logout</a></li> </ul>
</ul> </div>
</div>
</div>
</div>
</div> </div>
<div class="uk-position-top"> </div>
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar"> </div>
<div class="uk-navbar-left uk-hidden@s"> <div class="uk-position-top">
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div> <div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
</div> <div class="uk-navbar-left uk-hidden@s">
<div class="uk-navbar-left uk-visible@s"> <div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
<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 class="uk-section uk-section-small"> <div class="uk-navbar-left uk-visible@s">
</div> <a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
<div class="uk-section uk-section-small" style="position:relative;"> <ul class="uk-navbar-nav">
<div class="uk-container uk-container-small"> <li><a href="<%= base_url %>">Home</a></li>
<div id="alert"></div> <li><a href="<%= base_url %>library">Library</a></li>
<%= content %> <li><a href="<%= base_url %>tags">Tags</a></li>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()"> <% if is_admin %>
<a href="#" uk-totop uk-scroll></a> <li><a href="<%= base_url %>admin">Admin</a></li>
<li>
<a href="#">Download</a>
<div class="uk-navbar-dropdown">
<ul class="uk-nav uk-navbar-dropdown-nav">
<li class="uk-nav-header">Source</li>
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
<li class="uk-nav-divider"></li>
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
</ul>
</div> </div>
</div> </li>
<% end %>
</ul>
</div> </div>
<script> <div class="uk-navbar-right uk-visible@s">
setTheme(); <ul class="uk-navbar-nav">
const base_url = "<%= base_url %>"; <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
</script> <li><a href="<%= base_url %>logout">Logout</a></li>
<%= render_component "uikit" %> </ul>
<%= yield_content "script" %> </div>
</body> </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>
</div>
</div>
<script>
setTheme();
const base_url = "<%= base_url %>";
</script>
<%= render_component "uikit" %>
<%= yield_content "script" %>
</body>
</html> </html>
+27 -29
View File
@@ -3,36 +3,34 @@
<div x-show="!empty"> <div x-show="!empty">
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p> <p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button> <button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
<div class="uk-overflow-auto"> <table class="uk-table uk-table-striped uk-overflow-auto">
<table class="uk-table uk-table-striped"> <thead>
<thead> <tr>
<tr> <th>Type</th>
<th>Type</th> <th>Relative Path</th>
<th>Relative Path</th> <th>ID</th>
<th>ID</th> <th>Actions</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> </tr>
</thead> </template>
<tbody> <template x-for="entry in entries" :key="entry">
<template x-for="title in titles" :key="title"> <tr :id="`entry-${entry.id}`">
<tr :id="`title-${title.id}`"> <td>Entry</td>
<td>Title</td> <td x-text="entry.path"></td>
<td x-text="title.path"></td> <td x-text="entry.id"></td>
<td x-text="title.id"></td> <td><a @click="rm($event)" uk-icon="trash"></a></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td> </tr>
</tr> </template>
</template> </tbody>
<template x-for="entry in entries" :key="entry"> </table>
<tr :id="`entry-${entry.id}`">
<td>Entry</td>
<td x-text="entry.path"></td>
<td x-text="entry.id"></td>
<td><a @click="rm($event)" uk-icon="trash"></a></td>
</tr>
</template>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
+65 -168
View File
@@ -1,180 +1,77 @@
<div x-data="component()" x-init="init()" x-cloak> <% if plugins.empty? %>
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;"> <div class="uk-container uk-text-center">
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%"> <h2>No Plugins Found</h2>
<h2>No Plugins Found</h2> <p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p> <p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p> </div>
<% else %>
<h2 class=uk-title>Download with Plugins</h2>
<div id="controls" class="uk-grid-small" uk-grid hidden>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<label class="uk-form-label" for="search-input">&nbsp;</label>
<div class="uk-form-controls">
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
</div>
</div>
</div> </div>
<div class="uk-width-expand">
<div x-show="plugins.length > 0" style="width:100%"> <div class="uk-margin">
<h2 class=uk-title>Download with Plugins <label class="uk-form-label" for="plugin-select">Choose a plugin</label>
<span x-show="searching" uk-spinner class="uk-margin-left"></span> <div class="uk-form-controls">
</h2> <select id="plugin-select" class="uk-select">
<% plugins.each do |p| %>
<template x-if="info !== undefined"> <option value="<%= p[:id] %>"><%= p[:title] %></option>
<div> <% end %>
<div class="uk-grid-small" uk-grid> </select>
<div class="uk-width-3-4@m uk-child-width-1-1">
<div class="uk-margin">
<div class="uk-form-controls">
<label class="uk-form-label">&nbsp;</label>
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
</div>
</div>
</div>
<div class="uk-width-expand">
<div class="uk-margin">
<label class="uk-form-label">Choose a plugin</label>
<div class="uk-form-controls">
<select class="uk-select" x-model="pid" @change="pluginChanged()">
<template x-for="p in plugins" :key="p">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div>
</div>
<div class="uk-width-auto">
<div class="uk-margin">
<label class="uk-form-label">&nbsp;</label>
<div class="uk-form-controls" style="padding-top: 10px;">
<span uk-icon="info" uk-toggle="target: #toggle"></span>
</div>
</div>
</div>
</div>
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
<dl class="uk-description-list" id="toggle" hidden>
<dt x-text="entry[0]"></dt>
<dd x-text="entry[1]"></dd>
</dl>
</template>
</div> </div>
</template> </div>
</div>
<template x-if="manga"> <div class="uk-width-auto">
<div class="uk-margin"> <div class="uk-margin">
<p x-show="manga.length === 0">No matching manga found.</p> <label class="uk-form-label" for="search-input">&nbsp;</label>
<p x-show="manga.length > 0"> <div class="uk-form-controls" style="padding-top: 10px;">
<span x-text="`${manga.length} manga found`"></span> <span uk-icon="info" uk-toggle="target: #toggle"></span>
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
</p>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
<template x-for="m in manga" :key="m.id">
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top uk-inline">
<img uk-img :data-src="m.cover_url">
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<div class="uk-margin-large-top" x-show="chapters !== undefined">
<h3 x-text="mangaTitle"></h3>
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
<div class="uk-margin">
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<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()">Download Selected</button>
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
</div>
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
</div>
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
<template x-for="field in filters">
<div class="uk-margin">
<label class="uk-form-label">
<span x-text="field.key"></span>
<template x-if="field.type === 'number'">
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
</template>
</label>
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
</div>
</div>
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
</div>
<div class="uk-width-1-2@s">
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
</div>
</div>
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
<option value="all">All</option>
<template x-for="v in field.values" :key="v">
<option x-text="v" :value="v"></option>
</template>
</select>
</div>
</template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
</form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
<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 class="uk-overflow-auto">
<table class="uk-table uk-table-striped">
<thead>
<tr>
<template x-for="(k, idx) in chapterKeys" :key="k">
<th :id="`th-${idx}`" @click="thClicked($event)">
<span x-text="k"></span>
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
</th>
</template>
</tr>
</thead>
<tbody id="selectable">
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
<template x-for="ch in chapters" :key="ch">
<tr class="ui-widget-content" :id="ch.id">
<template x-for="k in chapterKeys" :key="k">
<td x-text="ch[k]"></td>
</template>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<dl class="uk-description-list" id="toggle" hidden>
<% plugin.not_nil!.info.each do |k, v| %>
<dt><%= k %></dt>
<dd><%= v.to_s %></dd>
<% end %>
</dl>
<div id="table" class="uk-margin-large-top" hidden>
<h3 id="title-text"></h3>
<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 class="uk-overflow-auto">
<table class="uk-table uk-table-striped tablesorter">
</table>
</div>
</div>
<% end %>
<% content_for "script" do %> <% content_for "script" do %>
<% if plugin %>
<script>
var pid = "<%= plugin.not_nil!.info.id %>";
</script>
<% end %>
<%= render_component "jquery-ui" %> <%= render_component "jquery-ui" %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script> <script src="<%= base_url %>js/plugin-download.js"></script>
<% end %> <% end %>
+17 -1
View File
@@ -21,7 +21,7 @@
<div <div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}"> :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak> <div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-for="item in items"> <template x-if="!loading && mode === 'continuous'" x-for="item in items">
<img <img
uk-img uk-img
:class="{'uk-align-center': true, 'spine': item.width < 50}" :class="{'uk-align-center': true, 'spine': item.width < 50}"
@@ -50,6 +50,9 @@
width:${mode === 'width' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
max-width:100%;
max-height:100%;
object-fit: contain;
`" /> `" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div> <div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
@@ -98,6 +101,19 @@
</div> </div>
</div> </div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="enable-flip-animation">Enable Flip Animation</label>
<div class="uk-form-controls">
<input id="enable-flip-animation" class="uk-checkbox" type="checkbox" x-model="enableFlipAnimation" @change="enableFlipAnimationChanged()">
</div>
</div>
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="preload-lookahead" x-text="`Preload Image: ${preloadLookahead} page(s)`"></label>
<div class="uk-form-controls">
<input id="preload-lookahead" class="uk-range" type="range" min="0" max="5" step="1" x-model.number="preloadLookahead" @change="preloadLookaheadChanged()">
</div>
</div>
<hr class="uk-divider-icon"> <hr class="uk-divider-icon">
<div class="uk-margin"> <div class="uk-margin">