mirror of
https://github.com/hkalexling/Mango.git
synced 2026-05-01 00:00:55 -04:00
Compare commits
52 Commits
v0.20.1
...
3b19883dde
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b19883dde | |||
| 6844860065 | |||
| 9eb699ea3b | |||
| 59bcb4db3b | |||
| 87c479bf42 | |||
| e0713ccde8 | |||
| a571d21cba | |||
| 23541f457e | |||
| cd8944ed2d | |||
| 7f0c256fe6 | |||
| 46e6e41bfe | |||
| c9f55e7a8e | |||
| 741c3a4e20 | |||
| f6da20321d | |||
| 2764e955b2 | |||
| 00c15014a1 | |||
| c6fdbfd9fd | |||
| e03bf32358 | |||
| bbf1520c73 | |||
| 8950c3a1ed | |||
| 17837d8a29 | |||
| b4a69425c8 | |||
| a612500b0f | |||
| 9bb7144479 | |||
| ee52c52f46 | |||
| daec2bdac6 | |||
| e9a490676b | |||
| 757f7c8214 | |||
| eed1a9717e | |||
| 8829d2e237 | |||
| eec6ec60bf | |||
| 3a82effa40 | |||
| 0b3e78bcb7 | |||
| cb4e4437a6 | |||
| 6a275286ea | |||
| 2743868438 | |||
| d3f26ecbc9 | |||
| f62344806a | |||
| b7b7e6f718 | |||
| 05b4e77fa9 | |||
| 8aab113aab | |||
| 371c8056e7 | |||
| a9a2c9faa8 | |||
| 011768ed1f | |||
| c36d2608e8 | |||
| 1b25a1fa47 | |||
| df7e2270a4 | |||
| 3c3549a489 | |||
| 8160b0a18e | |||
| a7eff772be | |||
| bf3900f9a2 | |||
| 6fa575cf4f |
@@ -12,12 +12,12 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:0.35.1-alpine
|
image: crystallang/crystal:0.36.1-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
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
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make static || make static
|
run: make static || make static
|
||||||
- name: Linter
|
- name: Linter
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM crystallang/crystal:0.35.1-alpine AS builder
|
FROM crystallang/crystal:0.36.1-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ 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.35.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ 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.35.1 && make deps && cd ..
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.36.1 && make deps && cd ..
|
||||||
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
|
||||||
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
|
||||||
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.20.1
|
Mango - Manga Server and Web Reader. Version 0.22.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -75,6 +75,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
|
host: 0.0.0.0
|
||||||
port: 9000
|
port: 9000
|
||||||
base_url: /
|
base_url: /
|
||||||
session_secret: mango-session-secret
|
session_secret: mango-session-secret
|
||||||
@@ -89,14 +90,16 @@ download_timeout_seconds: 30
|
|||||||
page_margin: 30
|
page_margin: 30
|
||||||
disable_login: false
|
disable_login: false
|
||||||
default_username: ""
|
default_username: ""
|
||||||
|
auth_proxy_header_name: ""
|
||||||
mangadex:
|
mangadex:
|
||||||
base_url: https://mangadex.org
|
base_url: https://mangadex.org
|
||||||
api_url: https://mangadex.org/api
|
api_url: https://api.mangadex.org/v2
|
||||||
download_wait_seconds: 5
|
download_wait_seconds: 5
|
||||||
download_retries: 4
|
download_retries: 4
|
||||||
download_queue_db_path: ~/mango/queue.db
|
download_queue_db_path: ~/mango/queue.db
|
||||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
||||||
manga_rename_rule: '{title}'
|
manga_rename_rule: '{title}'
|
||||||
|
subscription_update_interval_hours: 24
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateMangaDexAccount < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE md_account (
|
||||||
|
username TEXT NOT NULL PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expire INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users (username)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE md_account;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -34,9 +34,11 @@
|
|||||||
.uk-card-body {
|
.uk-card-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
.uk-card-title {
|
.uk-card-title {
|
||||||
max-height: 3em;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
.uk-card-title:not(.free-height) {
|
||||||
|
max-height: 3em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,22 @@
|
|||||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||||
|
|
||||||
|
.hook-card-default() {
|
||||||
|
.uk-light & {
|
||||||
|
background: @card-secondary-background;
|
||||||
|
color: @card-secondary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-card-default-title() {
|
||||||
|
.uk-light & {
|
||||||
|
color: @card-secondary-title-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-card-default-hover() {
|
||||||
|
.uk-light & {
|
||||||
|
background-color: @card-secondary-hover-background;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,14 +117,10 @@ const setTheme = (theme) => {
|
|||||||
if (theme === 'dark') {
|
if (theme === 'dark') {
|
||||||
$('html').css('background', 'rgb(20, 20, 20)');
|
$('html').css('background', 'rgb(20, 20, 20)');
|
||||||
$('body').addClass('uk-light');
|
$('body').addClass('uk-light');
|
||||||
$('.uk-card').addClass('uk-card-secondary');
|
|
||||||
$('.uk-card').removeClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').addClass('dark');
|
$('.ui-widget-content').addClass('dark');
|
||||||
} else {
|
} else {
|
||||||
$('html').css('background', '');
|
$('html').css('background', '');
|
||||||
$('body').removeClass('uk-light');
|
$('body').removeClass('uk-light');
|
||||||
$('.uk-card').removeClass('uk-card-secondary');
|
|
||||||
$('.uk-card').addClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').removeClass('dark');
|
$('.ui-widget-content').removeClass('dark');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+61
-9
@@ -3,9 +3,12 @@ const downloadComponent = () => {
|
|||||||
chaptersLimit: 1000,
|
chaptersLimit: 1000,
|
||||||
loading: false,
|
loading: false,
|
||||||
addingToDownload: false,
|
addingToDownload: false,
|
||||||
|
searchAvailable: false,
|
||||||
searchInput: '',
|
searchInput: '',
|
||||||
data: {},
|
data: {},
|
||||||
chapters: [],
|
chapters: [],
|
||||||
|
mangaAry: undefined, // undefined: not searching; []: searched but no result
|
||||||
|
candidateManga: {},
|
||||||
langChoice: 'All',
|
langChoice: 'All',
|
||||||
groupChoice: 'All',
|
groupChoice: 'All',
|
||||||
chapterRange: '',
|
chapterRange: '',
|
||||||
@@ -48,7 +51,21 @@ const downloadComponent = () => {
|
|||||||
childList: true,
|
childList: true,
|
||||||
subtree: 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() {
|
filtersUpdated() {
|
||||||
if (!this.data.chapters)
|
if (!this.data.chapters)
|
||||||
this.chapters = [];
|
this.chapters = [];
|
||||||
@@ -90,10 +107,11 @@ const downloadComponent = () => {
|
|||||||
console.log('filtered chapters:', _chapters);
|
console.log('filtered chapters:', _chapters);
|
||||||
this.chapters = _chapters;
|
this.chapters = _chapters;
|
||||||
},
|
},
|
||||||
|
|
||||||
search() {
|
search() {
|
||||||
if (this.loading || this.searchInput === '') return;
|
if (this.loading || this.searchInput === '') return;
|
||||||
this.loading = true;
|
|
||||||
this.data = {};
|
this.data = {};
|
||||||
|
this.mangaAry = undefined;
|
||||||
|
|
||||||
var int_id = -1;
|
var int_id = -1;
|
||||||
try {
|
try {
|
||||||
@@ -103,12 +121,10 @@ const downloadComponent = () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
int_id = parseInt(this.searchInput);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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}`)
|
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||||
.done((data) => {
|
.done((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -118,6 +134,7 @@ const downloadComponent = () => {
|
|||||||
|
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.chapters = data.chapters;
|
this.chapters = data.chapters;
|
||||||
|
this.mangaAry = undefined;
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
@@ -125,7 +142,33 @@ const downloadComponent = () => {
|
|||||||
.always(() => {
|
.always(() => {
|
||||||
this.loading = false;
|
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) {
|
parseRange(str) {
|
||||||
@@ -217,9 +260,7 @@ const downloadComponent = () => {
|
|||||||
}
|
}
|
||||||
const successCount = parseInt(data.success);
|
const successCount = parseInt(data.success);
|
||||||
const failCount = parseInt(data.fail);
|
const failCount = parseInt(data.fail);
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
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>.`);
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
@@ -228,6 +269,17 @@ const downloadComponent = () => {
|
|||||||
this.addingToDownload = false;
|
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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
expires: undefined,
|
||||||
|
loading: true,
|
||||||
|
loggingIn: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loading = true;
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: `${base_url}api/admin/mangadex/expires`,
|
||||||
|
contentType: "application/json",
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to retrieve MangaDex token status. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.expires = data.expires;
|
||||||
|
this.loading = false;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to retrieve MangaDex token status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
login() {
|
||||||
|
if (!(this.username && this.password)) return;
|
||||||
|
this.loggingIn = true;
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: `${base_url}api/admin/mangadex/login`,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json',
|
||||||
|
data: JSON.stringify({
|
||||||
|
username: this.username,
|
||||||
|
password: this.password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to log in. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.expires = data.expires;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to log in. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.loggingIn = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
get expired() {
|
||||||
|
return this.expires && moment().diff(moment.unix(this.expires)) > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+303
-118
@@ -1,141 +1,326 @@
|
|||||||
const loadPlugin = id => {
|
const component = () => {
|
||||||
localStorage.setItem('plugin', id);
|
return {
|
||||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
plugins: [],
|
||||||
const newURL = `${url}?${$.param({
|
info: undefined,
|
||||||
plugin: id
|
pid: undefined,
|
||||||
})}`;
|
chapters: undefined, // undefined: not searched yet, []: empty
|
||||||
window.location.href = newURL;
|
manga: undefined, // undefined: not searched yet, []: empty
|
||||||
};
|
allChapters: [],
|
||||||
|
query: '',
|
||||||
$(() => {
|
mangaTitle: '',
|
||||||
var storedID = localStorage.getItem('plugin');
|
searching: false,
|
||||||
if (storedID && storedID !== pid) {
|
adding: false,
|
||||||
loadPlugin(storedID);
|
sortOptions: [],
|
||||||
} else {
|
showFilters: false,
|
||||||
$('#controls').removeAttr('hidden');
|
appliedFilters: [],
|
||||||
}
|
chaptersLimit: 500,
|
||||||
|
listManga: false,
|
||||||
$('#search-input').keypress(event => {
|
|
||||||
if (event.which === 13) {
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#plugin-select').val(pid);
|
|
||||||
$('#plugin-select').change(() => {
|
|
||||||
const id = $('#plugin-select').val();
|
|
||||||
loadPlugin(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mangaTitle = "";
|
|
||||||
let searching = false;
|
|
||||||
const search = () => {
|
|
||||||
if (searching)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const query = $.param({
|
|
||||||
query: $('#search-input').val(),
|
|
||||||
plugin: pid
|
|
||||||
});
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Search failed. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mangaTitle = data.title;
|
|
||||||
$('#title-text').text(data.title);
|
|
||||||
buildTable(data.chapters);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTable = (chapters) => {
|
|
||||||
$('#table').attr('hidden', '');
|
|
||||||
$('table').empty();
|
|
||||||
|
|
||||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
|
||||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
|
||||||
$('table').append(thead);
|
|
||||||
|
|
||||||
const rows = chapters.map(ch => {
|
|
||||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
|
||||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
|
||||||
});
|
|
||||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
|
||||||
$('table').append(tbody);
|
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const tableObserver = new MutationObserver(() => {
|
||||||
|
console.log('table mutated');
|
||||||
$('#selectable').selectable({
|
$('#selectable').selectable({
|
||||||
filter: 'tr'
|
filter: 'tr'
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
const pid = localStorage.getItem('plugin');
|
||||||
$('#table').removeAttr('hidden');
|
if (pid && this.plugins.map(p => p.id).includes(pid))
|
||||||
};
|
return this.loadPlugin(pid);
|
||||||
|
|
||||||
const selectAll = () => {
|
if (this.plugins.length > 0)
|
||||||
|
this.loadPlugin(this.plugins[0].id);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to list the available plugins. Error: ${e}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
this.chapters = data.chapters;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to list chapters. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.searching = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchManga() {
|
||||||
|
this.searching = true;
|
||||||
|
this.allChapters = [];
|
||||||
|
this.chapters = undefined;
|
||||||
|
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) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).addClass('ui-selected');
|
$(e).addClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
clearSelection() {
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).removeClass('ui-selected');
|
$(e).removeClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
download() {
|
||||||
const download = () => {
|
const selected = $('tbody > tr.ui-selected').get();
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
$('#download-btn').attr('hidden', '');
|
const ids = selected.map(e => e.id);
|
||||||
$('#download-spinner').removeAttr('hidden');
|
const chapters = this.chapters.filter(c => ids.includes(c.id));
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
|
||||||
id: $(e).attr('data-id'),
|
|
||||||
title: $(e).attr('data-title')
|
|
||||||
}
|
|
||||||
}).get();
|
|
||||||
console.log(chapters);
|
console.log(chapters);
|
||||||
$.ajax({
|
this.adding = true;
|
||||||
type: 'POST',
|
fetch(`${base_url}api/admin/plugin/download`, {
|
||||||
url: base_url + 'api/admin/plugin/download',
|
method: 'POST',
|
||||||
data: JSON.stringify({
|
body: JSON.stringify({
|
||||||
plugin: pid,
|
chapters,
|
||||||
chapters: chapters,
|
plugin: this.pid,
|
||||||
title: mangaTitle
|
title: this.mangaTitle
|
||||||
}),
|
}),
|
||||||
contentType: "application/json",
|
headers: {
|
||||||
dataType: 'json'
|
"Content-Type": "application/json"
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
const successCount = parseInt(data.success);
|
const successCount = parseInt(data.success);
|
||||||
const failCount = parseInt(data.fail);
|
const failCount = parseInt(data.fail);
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
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>.`);
|
||||||
window.location.href = base_url + 'admin/downloads';
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.adding = false;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
},
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
thClicked(event) {
|
||||||
})
|
const idx = parseInt(event.currentTarget.id.split('-')[1]);
|
||||||
.always(() => {
|
if (idx === undefined || isNaN(idx)) return;
|
||||||
$('#download-spinner').attr('hidden', '');
|
const curOption = this.sortOptions[idx];
|
||||||
$('#download-btn').removeAttr('hidden');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||||
|
const comp = this.compare(a[key], b[key]);
|
||||||
|
return option < 0 ? comp * -1 : comp;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
compare(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
|
||||||
|
// try numbers
|
||||||
|
// this must come before the date checks, because any integer would
|
||||||
|
// also be parsed as a date.
|
||||||
|
if (!isNaN(a) && !isNaN(b))
|
||||||
|
return Number(a) - Number(b);
|
||||||
|
|
||||||
|
// try dates
|
||||||
|
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
|
||||||
|
return this.parseDate(a) - this.parseDate(b);
|
||||||
|
|
||||||
|
const preprocessString = (val) => {
|
||||||
|
if (typeof val !== 'string') return val;
|
||||||
|
return val.toLowerCase().replace(/\s\s/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||||
|
},
|
||||||
|
fieldType(values) {
|
||||||
|
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
|
||||||
|
return 'string'; // display input for string searching.
|
||||||
|
// for the last two, if the number of options is small enough (say < 50), display a multi-select2
|
||||||
|
},
|
||||||
|
get filters() {
|
||||||
|
if (this.allChapters.length < 1) return [];
|
||||||
|
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k));
|
||||||
|
return keys.map(k => {
|
||||||
|
let values = this.allChapters.map(c => c[k]);
|
||||||
|
const type = this.fieldType(values);
|
||||||
|
|
||||||
|
if (type === 'array') {
|
||||||
|
// if the type is an array, return the list of available elements
|
||||||
|
// example: an array of groups or authors
|
||||||
|
values = Array.from(new Set(values.flat().map(v => {
|
||||||
|
if (typeof v === 'string') return v.toLowerCase();
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: k,
|
||||||
|
type: type,
|
||||||
|
values: values
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyFilters() {
|
||||||
|
const values = $('#filter-form input, #filter-form select')
|
||||||
|
.get()
|
||||||
|
.map(i => ({
|
||||||
|
key: i.getAttribute('data-filter-key'),
|
||||||
|
value: i.value.trim(),
|
||||||
|
type: i.getAttribute('data-filter-type')
|
||||||
|
}));
|
||||||
|
this.appliedFilters = values;
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
},
|
||||||
|
clearFilters() {
|
||||||
|
$('#filter-form input').get().forEach(i => i.value = '');
|
||||||
|
this.appliedFilters = [];
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
},
|
||||||
|
mangaSelected(event) {
|
||||||
|
const mid = event.currentTarget.getAttribute('data-id');
|
||||||
|
this.searchChapters(mid);
|
||||||
|
},
|
||||||
|
parseDate(str) {
|
||||||
|
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
|
||||||
|
// Basic sanity check to make sure it's an actual date.
|
||||||
|
// We need this because Date.parse thinks 'Chapter 1' is a date.
|
||||||
|
if (!regex.test(str))
|
||||||
|
return NaN;
|
||||||
|
return Date.parse(str);
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-12
@@ -9,6 +9,8 @@ const readerComponent = () => {
|
|||||||
flipAnimation: null,
|
flipAnimation: null,
|
||||||
longPages: false,
|
longPages: false,
|
||||||
lastSavedPage: page,
|
lastSavedPage: page,
|
||||||
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
|
margin: 30,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@@ -26,7 +28,6 @@ const readerComponent = () => {
|
|||||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
width: d.width,
|
width: d.width,
|
||||||
height: d.height,
|
height: d.height,
|
||||||
style: `margin-top: ${data.margin}px; margin-bottom: ${data.margin}px;`
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ const readerComponent = () => {
|
|||||||
const mode = this.mode;
|
const mode = this.mode;
|
||||||
this.updateMode(this.mode, page, nextTick);
|
this.updateMode(this.mode, page, nextTick);
|
||||||
$('#mode-select').val(mode);
|
$('#mode-select').val(mode);
|
||||||
|
|
||||||
|
const savedMargin = localStorage.getItem('margin');
|
||||||
|
if (savedMargin) {
|
||||||
|
this.margin = savedMargin;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
@@ -221,10 +227,7 @@ const readerComponent = () => {
|
|||||||
*/
|
*/
|
||||||
showControl(event) {
|
showControl(event) {
|
||||||
const idx = event.currentTarget.id;
|
const idx = event.currentTarget.id;
|
||||||
const pageCount = this.items.length;
|
this.selectedIndex = idx;
|
||||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
|
||||||
$('#progress-label').text(progressText);
|
|
||||||
$('#page-select').val(idx);
|
|
||||||
UIkit.modal($('#modal-sections')).show();
|
UIkit.modal($('#modal-sections')).show();
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -263,19 +266,27 @@ const readerComponent = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Exits the reader, and optionally sets the reading progress tp 100%
|
* Exits the reader, and sets the reading progress tp 100%
|
||||||
*
|
*
|
||||||
* @param {string} exitUrl - The Exit URL
|
* @param {string} exitUrl - The Exit URL
|
||||||
* @param {boolean} [markCompleted] - Whether we should mark the
|
|
||||||
* reading progress to 100%
|
|
||||||
*/
|
*/
|
||||||
exitReader(exitUrl, markCompleted = false) {
|
exitReader(exitUrl) {
|
||||||
if (!markCompleted) {
|
|
||||||
return this.redirect(exitUrl);
|
|
||||||
}
|
|
||||||
this.saveProgress(this.items.length, () => {
|
this.saveProgress(this.items.length, () => {
|
||||||
this.redirect(exitUrl);
|
this.redirect(exitUrl);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the entry selector
|
||||||
|
*/
|
||||||
|
entryChanged() {
|
||||||
|
const id = $('#entry-select').val();
|
||||||
|
this.redirect(`${base_url}reader/${tid}/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
marginChanged() {
|
||||||
|
localStorage.setItem('margin', this.margin);
|
||||||
|
this.toPage(this.selectedIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
available: undefined,
|
||||||
|
subscriptions: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
if (this.available) this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubscriptions() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subscriptions = data.subscriptions;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
rm(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
check(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRange(min, max) {
|
||||||
|
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
||||||
|
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
||||||
|
if (isNaN(min) && isNaN(max)) return 'All';
|
||||||
|
|
||||||
|
if (min === max) return `= ${min}`;
|
||||||
|
return `${min} - ${max}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+5
-5
@@ -2,7 +2,7 @@ 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.12.1
|
version: 0.14.0
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
git: https://github.com/hkalexling/archive.cr.git
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
@@ -30,7 +30,7 @@ shards:
|
|||||||
|
|
||||||
http_proxy:
|
http_proxy:
|
||||||
git: https://github.com/mamantoha/http_proxy.git
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
version: 0.7.1
|
version: 0.8.0
|
||||||
|
|
||||||
image_size:
|
image_size:
|
||||||
git: https://github.com/hkalexling/image_size.cr.git
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
@@ -42,7 +42,7 @@ shards:
|
|||||||
|
|
||||||
kemal-session:
|
kemal-session:
|
||||||
git: https://github.com/kemalcr/kemal-session.git
|
git: https://github.com/kemalcr/kemal-session.git
|
||||||
version: 0.12.1
|
version: 0.13.0
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
git: https://github.com/jeromegn/kilt.git
|
git: https://github.com/jeromegn/kilt.git
|
||||||
@@ -50,11 +50,11 @@ shards:
|
|||||||
|
|
||||||
koa:
|
koa:
|
||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.5.0
|
version: 0.7.0
|
||||||
|
|
||||||
mangadex:
|
mangadex:
|
||||||
git: https://github.com/hkalexling/mangadex.git
|
git: https://github.com/hkalexling/mangadex.git
|
||||||
version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb
|
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.20.1
|
version: 0.22.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -8,7 +8,7 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 0.35.1
|
crystal: 0.36.1
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ describe Storage do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "deletes user" do
|
it "deletes user" do
|
||||||
with_storage do |storage|
|
with_storage &.delete_user "admin"
|
||||||
storage.delete_user "admin"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates new user" do
|
it "creates new user" do
|
||||||
|
|||||||
+3
-3
@@ -21,7 +21,7 @@ describe "compare_numerically" do
|
|||||||
it "sorts like the stack exchange post" do
|
it "sorts like the stack exchange post" do
|
||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
@@ -29,7 +29,7 @@ describe "compare_numerically" do
|
|||||||
# https://github.com/hkalexling/Mango/issues/22
|
# https://github.com/hkalexling/Mango/issues/22
|
||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
@@ -56,7 +56,7 @@ describe "chapter_sort" do
|
|||||||
it "sorts correctly" do
|
it "sorts correctly" do
|
||||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
sorter = ChapterSorter.new ary
|
sorter = ChapterSorter.new ary
|
||||||
ary.reverse.sort do |a, b|
|
ary.reverse.sort! do |a, b|
|
||||||
sorter.compare a, b
|
sorter.compare a, b
|
||||||
end.should eq ary
|
end.should eq ary
|
||||||
end
|
end
|
||||||
|
|||||||
+14
-6
@@ -5,6 +5,7 @@ class Config
|
|||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
property path : String = ""
|
property path : String = ""
|
||||||
|
property host : String = "0.0.0.0"
|
||||||
property port : Int32 = 9000
|
property port : Int32 = 9000
|
||||||
property base_url : String = "/"
|
property base_url : String = "/"
|
||||||
property session_secret : String = "mango-session-secret"
|
property session_secret : String = "mango-session-secret"
|
||||||
@@ -19,7 +20,6 @@ class Config
|
|||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||||
home: true
|
home: true
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
property page_margin : Int32 = 30
|
|
||||||
property disable_login = false
|
property disable_login = false
|
||||||
property default_username = ""
|
property default_username = ""
|
||||||
property auth_proxy_header_name = ""
|
property auth_proxy_header_name = ""
|
||||||
@@ -28,7 +28,7 @@ class Config
|
|||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
"base_url" => "https://mangadex.org",
|
"base_url" => "https://mangadex.org",
|
||||||
"api_url" => "https://mangadex.org/api/v2",
|
"api_url" => "https://api.mangadex.org/v2",
|
||||||
"download_wait_seconds" => 5,
|
"download_wait_seconds" => 5,
|
||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
@@ -92,15 +92,23 @@ class Config
|
|||||||
raise "Login is disabled, but default username is not set. " \
|
raise "Login is disabled, but default username is not set. " \
|
||||||
"Please set a default username"
|
"Please set a default username"
|
||||||
end
|
end
|
||||||
unless mangadex["api_url"] =~ /\/v2/
|
|
||||||
# `Logger.default` is not available yet
|
# `Logger.default` is not available yet
|
||||||
Log.setup :debug
|
Log.setup :debug
|
||||||
|
unless mangadex["api_url"] =~ /\/v2/
|
||||||
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
||||||
"v1 in your config file. Please update it to either " \
|
"v1 in your config file. Please update it to " \
|
||||||
"https://mangadex.org/api/v2 or " \
|
|
||||||
"https://api.mangadex.org/v2 to suppress this warning." }
|
"https://api.mangadex.org/v2 to suppress this warning." }
|
||||||
mangadex["api_url"] = "https://mangadex.org/api/v2"
|
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
||||||
end
|
end
|
||||||
|
if mangadex["api_url"] =~ /\/api\/v2/
|
||||||
|
Log.warn { "It looks like you are using the outdated MangaDex API " \
|
||||||
|
"url (mangadex.org/api/v2) in your config file. Please " \
|
||||||
|
"update it to https://api.mangadex.org/v2 to suppress this " \
|
||||||
|
"warning." }
|
||||||
|
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
||||||
|
end
|
||||||
|
|
||||||
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
||||||
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
||||||
end
|
end
|
||||||
|
|||||||
+18
-4
@@ -46,6 +46,19 @@ class Entry
|
|||||||
file.close
|
file.close
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "title_id", @book.id
|
||||||
|
json.field "title_title", @book.title
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
@@ -86,7 +99,7 @@ class Entry
|
|||||||
SUPPORTED_IMG_TYPES.includes? \
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
MIME.from_filename? e.filename
|
MIME.from_filename? e.filename
|
||||||
}
|
}
|
||||||
.sort { |a, b|
|
.sort! { |a, b|
|
||||||
compare_numerically a.filename, b.filename
|
compare_numerically a.filename, b.filename
|
||||||
}
|
}
|
||||||
yield file, entries
|
yield file, entries
|
||||||
@@ -134,10 +147,11 @@ class Entry
|
|||||||
entries[idx + 1]
|
entries[idx + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
def previous_entry
|
def previous_entry(username)
|
||||||
idx = @book.entries.index self
|
entries = @book.sorted_entries username
|
||||||
|
idx = entries.index self
|
||||||
return nil if idx.nil? || idx == 0
|
return nil if idx.nil? || idx == 0
|
||||||
@book.entries[idx - 1]
|
entries[idx - 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
def date_added
|
def date_added
|
||||||
|
|||||||
+26
-11
@@ -63,7 +63,22 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
titles + titles.flat_map &.deep_titles
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "dir", @dir
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
self.titles.each do |title|
|
||||||
|
json.raw title.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
@@ -98,7 +113,7 @@ class Library
|
|||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "" }
|
.map { |path| Title.new path, "" }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort! { |a, b| a.title <=> b.title }
|
||||||
.tap { |_| @title_ids.clear }
|
.tap { |_| @title_ids.clear }
|
||||||
.each do |title|
|
.each do |title|
|
||||||
@title_hash[title.id] = title
|
@title_hash[title.id] = title
|
||||||
@@ -114,14 +129,14 @@ class Library
|
|||||||
|
|
||||||
def get_continue_reading_entries(username)
|
def get_continue_reading_entries(username)
|
||||||
cr_entries = deep_titles
|
cr_entries = deep_titles
|
||||||
.map { |t| t.get_last_read_entry username }
|
.map(&.get_last_read_entry username)
|
||||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
.map { |e|
|
.map { |e|
|
||||||
# Get the last read time of the entry. If it hasn't been started, get
|
# Get the last read time of the entry. If it hasn't been started, get
|
||||||
# the last read time of the previous entry
|
# the last read time of the previous entry
|
||||||
last_read = e.load_last_read username
|
last_read = e.load_last_read username
|
||||||
pe = e.previous_entry
|
pe = e.previous_entry username
|
||||||
if last_read.nil? && pe
|
if last_read.nil? && pe
|
||||||
last_read = pe.load_last_read username
|
last_read = pe.load_last_read username
|
||||||
end
|
end
|
||||||
@@ -150,14 +165,14 @@ class Library
|
|||||||
recently_added = [] of RA
|
recently_added = [] of RA
|
||||||
last_date_added = nil
|
last_date_added = nil
|
||||||
|
|
||||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
titles.flat_map(&.deep_entries_with_date_added)
|
||||||
.select { |e| e[:date_added] > 1.month.ago }
|
.select(&.[:date_added].> 1.month.ago)
|
||||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
|
||||||
.each do |e|
|
.each do |e|
|
||||||
break if recently_added.size > 12
|
break if recently_added.size > 12
|
||||||
last = recently_added.last?
|
last = recently_added.last?
|
||||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
|
||||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||||
last_hash = last.to_h
|
last_hash = last.to_h
|
||||||
count = last_hash[:grouped_count].as(Int32)
|
count = last_hash[:grouped_count].as(Int32)
|
||||||
@@ -188,9 +203,9 @@ class Library
|
|||||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||||
# when the user hasn't started `Vol. 1` yet
|
# when the user hasn't started `Vol. 1` yet
|
||||||
titles
|
titles
|
||||||
.select { |t| t.load_percentage(username) == 0 }
|
.select(&.load_percentage(username).== 0)
|
||||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||||
.shuffle
|
.shuffle!
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_generation_progress
|
def thumbnail_generation_progress
|
||||||
@@ -205,7 +220,7 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
Logger.info "Starting thumbnail generation"
|
Logger.info "Starting thumbnail generation"
|
||||||
entries = deep_titles.map(&.deep_entries).flatten.reject &.err_msg
|
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||||
@entries_count = entries.size
|
@entries_count = entries.size
|
||||||
@thumbnails_count = 0
|
@thumbnails_count = 0
|
||||||
|
|
||||||
|
|||||||
+50
-21
@@ -44,19 +44,54 @@ class Title
|
|||||||
|
|
||||||
mtimes = [@mtime]
|
mtimes = [@mtime]
|
||||||
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
mtimes += @entries.map { |e| e.mtime }
|
mtimes += @entries.map &.mtime
|
||||||
@mtime = mtimes.max
|
@mtime = mtimes.max
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
@title_ids.sort! do |a, b|
|
||||||
compare_numerically Library.default.title_hash[a].title,
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
Library.default.title_hash[b].title
|
Library.default.title_hash[b].title
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
sorter.compare a.title, b.title
|
sorter.compare a.title, b.title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_slim_json : String
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in ["dir", "title", "id"] %}
|
||||||
|
json.field {{str}}, @{{str.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "signature" { json.number @signature }
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
self.titles.each do |title|
|
||||||
|
json.raw title.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "entries" do
|
||||||
|
json.array do
|
||||||
|
@entries.each do |entry|
|
||||||
|
json.raw entry.to_slim_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "parents" do
|
||||||
|
json.array do
|
||||||
|
self.parents.each do |title|
|
||||||
|
json.object do
|
||||||
|
json.field "title", title.title
|
||||||
|
json.field "id", title.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
@@ -92,12 +127,12 @@ class Title
|
|||||||
# Get all entries, including entries in nested titles
|
# Get all entries, including entries in nested titles
|
||||||
def deep_entries
|
def deep_entries
|
||||||
return @entries if title_ids.empty?
|
return @entries if title_ids.empty?
|
||||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
@entries + titles.flat_map &.deep_entries
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
return [] of Title if titles.empty?
|
return [] of Title if titles.empty?
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
titles + titles.flat_map &.deep_titles
|
||||||
end
|
end
|
||||||
|
|
||||||
def parents
|
def parents
|
||||||
@@ -138,7 +173,7 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@entries.find { |e| e.id == eid }
|
@entries.find &.id.== eid
|
||||||
end
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
@@ -217,29 +252,23 @@ class Title
|
|||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
e.save_progress username, e.pages
|
e.save_progress username, e.pages
|
||||||
end
|
end
|
||||||
titles.each do |t|
|
titles.each &.read_all username
|
||||||
t.read_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 0%
|
# Set the reading progress of all entries and nested libraries to 0%
|
||||||
def unread_all(username)
|
def unread_all(username)
|
||||||
@entries.each do |e|
|
@entries.each &.save_progress(username, 0)
|
||||||
e.save_progress username, 0
|
titles.each &.unread_all username
|
||||||
end
|
|
||||||
titles.each do |t|
|
|
||||||
t.unread_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
def deep_read_page_count(username) : Int32
|
||||||
load_progress_for_all_entries(username).sum +
|
load_progress_for_all_entries(username).sum +
|
||||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
titles.flat_map(&.deep_read_page_count username).sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
def deep_total_page_count : Int32
|
||||||
entries.map { |e| e.pages }.sum +
|
entries.sum(&.pages) +
|
||||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
titles.flat_map(&.deep_total_page_count).sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percentage(username)
|
def load_percentage(username)
|
||||||
@@ -311,13 +340,13 @@ class Title
|
|||||||
ary = @entries.zip(percentage_ary)
|
ary = @entries.zip(percentage_ary)
|
||||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||||
.map { |tp| tp[0] }
|
.map &.[0]
|
||||||
else
|
else
|
||||||
unless opt.method.auto?
|
unless opt.method.auto?
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
"Auto instead"
|
"Auto instead"
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
ary = @entries.sort do |a, b|
|
ary = @entries.sort do |a, b|
|
||||||
sorter.compare(a.title, b.title).or \
|
sorter.compare(a.title, b.title).or \
|
||||||
compare_numerically a.title, b.title
|
compare_numerically a.title, b.title
|
||||||
@@ -383,13 +412,13 @@ class Title
|
|||||||
{entry: e, date_added: da_ary[i]}
|
{entry: e, date_added: da_ary[i]}
|
||||||
end
|
end
|
||||||
return zip if title_ids.empty?
|
return zip if title_ids.empty?
|
||||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
zip + titles.flat_map &.deep_entries_with_date_added
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_progress(action, ids : Array(String), username)
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
selected_entries = ids
|
selected_entries = ids
|
||||||
.map { |id|
|
.map { |id|
|
||||||
@entries.find { |e| e.id == id }
|
@entries.find &.id.==(id)
|
||||||
}
|
}
|
||||||
.select(Entry)
|
.select(Entry)
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ module MangaDex
|
|||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
@queue.set_status Queue::JobStatus::Downloading, job
|
||||||
begin
|
begin
|
||||||
chapter = @client.chapter job.id
|
chapter = @client.chapter job.id
|
||||||
|
# We must put the `.pages` call in a rescue block to handle external
|
||||||
|
# chapters.
|
||||||
|
pages = chapter.pages
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
@queue.set_status Queue::JobStatus::Error, job
|
||||||
@@ -58,7 +61,7 @@ module MangaDex
|
|||||||
@downloading = false
|
@downloading = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@queue.set_pages chapter.pages.size, job
|
@queue.set_pages pages.size, job
|
||||||
lib_dir = @library_path
|
lib_dir = @library_path
|
||||||
rename_rule = Rename::Rule.new \
|
rename_rule = Rename::Rule.new \
|
||||||
Config.current.mangadex["manga_rename_rule"].to_s
|
Config.current.mangadex["manga_rename_rule"].to_s
|
||||||
@@ -69,13 +72,13 @@ module MangaDex
|
|||||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
||||||
|
|
||||||
# Find the number of digits needed to store the number of pages
|
# Find the number of digits needed to store the number of pages
|
||||||
len = Math.log10(chapter.pages.size).to_i + 1
|
len = Math.log10(pages.size).to_i + 1
|
||||||
|
|
||||||
writer = Compress::Zip::Writer.new zip_path
|
writer = Compress::Zip::Writer.new zip_path
|
||||||
# Create a buffered channel. It works as an FIFO queue
|
# Create a buffered channel. It works as an FIFO queue
|
||||||
channel = Channel(PageJob).new chapter.pages.size
|
channel = Channel(PageJob).new pages.size
|
||||||
spawn do
|
spawn do
|
||||||
chapter.pages.each_with_index do |url, i|
|
pages.each_with_index do |url, i|
|
||||||
fn = Path.new(URI.parse(url).path).basename
|
fn = Path.new(URI.parse(url).path).basename
|
||||||
ext = File.extname fn
|
ext = File.extname fn
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||||
@@ -99,7 +102,7 @@ module MangaDex
|
|||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
page_jobs = [] of PageJob
|
page_jobs = [] of PageJob
|
||||||
chapter.pages.size.times do
|
pages.size.times do
|
||||||
page_job = channel.receive
|
page_job = channel.receive
|
||||||
|
|
||||||
break unless @queue.exists? job
|
break unless @queue.exists? job
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ module MangaDex
|
|||||||
struct Chapter
|
struct Chapter
|
||||||
def rename(rule : Rename::Rule)
|
def rename(rule : Rename::Rule)
|
||||||
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
||||||
hash["groups"] = groups.map(&.name).join ","
|
hash["groups"] = groups.join(",", &.name)
|
||||||
rule.render hash
|
rule.render hash
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.20.1"
|
MANGO_VERSION = "0.22.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
|
|||||||
+126
-4
@@ -2,6 +2,8 @@ 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
|
||||||
@@ -16,12 +18,19 @@ 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 : UInt64 = 0
|
getter wait_seconds = 0u64
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -37,6 +46,16 @@ 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 " \
|
||||||
@@ -114,10 +133,26 @@ 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
|
||||||
|
|
||||||
@info = @@info_ary.find { |i| i.id == id }
|
@info = @@info_ary.find &.id.== id
|
||||||
if @info.nil?
|
if @info.nil?
|
||||||
raise Error.new "Plugin with ID #{id} not found"
|
raise Error.new "Plugin with ID #{id} not found"
|
||||||
end
|
end
|
||||||
@@ -138,6 +173,12 @@ 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
|
||||||
|
|
||||||
@@ -152,9 +193,50 @@ 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
|
||||||
|
# Since v2, listChapters returns an array
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_chapter_type obj
|
||||||
|
end
|
||||||
|
else
|
||||||
check_fields ["title", "chapters"]
|
check_fields ["title", "chapters"]
|
||||||
|
|
||||||
ary = json["chapters"].as_a
|
ary = json["chapters"].as_a
|
||||||
@@ -168,7 +250,10 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
title = obj["title"]?
|
title = obj["title"]?
|
||||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
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
|
||||||
@@ -179,11 +264,15 @@ 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
|
||||||
|
assert_chapter_type json
|
||||||
|
else
|
||||||
check_fields ["title", "pages"]
|
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
|
||||||
end
|
end
|
||||||
@@ -194,7 +283,19 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
check_fields ["filename", "url"]
|
assert_page_type json
|
||||||
|
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
|
||||||
@@ -379,6 +480,27 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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
|
||||||
+2
-2
@@ -303,12 +303,12 @@ class Queue
|
|||||||
end
|
end
|
||||||
|
|
||||||
def pause
|
def pause
|
||||||
@downloaders.each { |d| d.stopped = true }
|
@downloaders.each &.stopped=(true)
|
||||||
@paused = true
|
@paused = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def resume
|
def resume
|
||||||
@downloaders.each { |d| d.stopped = false }
|
@downloaders.each &.stopped=(false)
|
||||||
@paused = false
|
@paused = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -35,15 +35,15 @@ module Rename
|
|||||||
|
|
||||||
class Group < Base(Pattern | String)
|
class Group < Base(Pattern | String)
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
return "" if @ary.select(&.is_a? Pattern)
|
return "" if @ary.select(Pattern)
|
||||||
.any? &.as(Pattern).render(hash).empty?
|
.any? &.as(Pattern).render(hash).empty?
|
||||||
@ary.map do |e|
|
@ary.join do |e|
|
||||||
if e.is_a? Pattern
|
if e.is_a? Pattern
|
||||||
e.render hash
|
e.render hash
|
||||||
else
|
else
|
||||||
e
|
e
|
||||||
end
|
end
|
||||||
end.join
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -129,13 +129,13 @@ module Rename
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
str = @ary.map do |e|
|
str = @ary.join do |e|
|
||||||
if e.is_a? String
|
if e.is_a? String
|
||||||
e
|
e
|
||||||
else
|
else
|
||||||
e.render hash
|
e.render hash
|
||||||
end
|
end
|
||||||
end.join.strip
|
end.strip
|
||||||
post_process str
|
post_process str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -73,5 +73,9 @@ 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
|
||||||
|
|||||||
+405
-200
@@ -10,7 +10,7 @@ struct APIRouter
|
|||||||
macro s(fields)
|
macro s(fields)
|
||||||
{
|
{
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
{{field}} => "string",
|
{{field}} => String,
|
||||||
{% end %}
|
{% end %}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -33,160 +33,49 @@ struct APIRouter
|
|||||||
MD
|
MD
|
||||||
|
|
||||||
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
|
Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}"
|
||||||
Koa.global_tag "admin", desc: <<-MD
|
Koa.define_tag "admin", desc: <<-MD
|
||||||
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
|
These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints.
|
||||||
MD
|
MD
|
||||||
|
|
||||||
Koa.binary "binary", desc: "A binary file"
|
Koa.schema "entry", {
|
||||||
Koa.array "entryAry", "$entry", desc: "An array of entries"
|
"pages" => Int32,
|
||||||
Koa.array "titleAry", "$title", desc: "An array of titles"
|
"mtime" => Int64,
|
||||||
Koa.array "strAry", "string", desc: "An array of strings"
|
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
||||||
|
desc: "An entry in a book"
|
||||||
|
|
||||||
entry_schema = {
|
Koa.schema "title", {
|
||||||
"pages" => "integer",
|
"mtime" => Int64,
|
||||||
"mtime" => "integer",
|
"entries" => ["entry"],
|
||||||
}.merge s %w(zip_path title size id title_id display_name cover_url)
|
"titles" => ["title"],
|
||||||
Koa.object "entry", entry_schema, desc: "An entry in a book"
|
"parents" => [String],
|
||||||
|
}.merge(s %w(dir title id display_name cover_url)),
|
||||||
title_schema = {
|
|
||||||
"mtime" => "integer",
|
|
||||||
"entries" => "$entryAry",
|
|
||||||
"titles" => "$titleAry",
|
|
||||||
"parents" => "$strAry",
|
|
||||||
}.merge s %w(dir title id display_name cover_url)
|
|
||||||
Koa.object "title", title_schema,
|
|
||||||
desc: "A manga title (a collection of entries and sub-titles)"
|
desc: "A manga title (a collection of entries and sub-titles)"
|
||||||
|
|
||||||
Koa.object "library", {
|
Koa.schema "result", {
|
||||||
"dir" => "string",
|
"success" => Bool,
|
||||||
"titles" => "$titleAry",
|
"error" => String?,
|
||||||
}, desc: "A library containing a list of top-level titles"
|
|
||||||
|
|
||||||
Koa.object "scanResult", {
|
|
||||||
"milliseconds" => "integer",
|
|
||||||
"titles" => "integer",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Koa.object "progressResult", {
|
Koa.schema("mdChapter", {
|
||||||
"progress" => "number",
|
"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.object "result", {
|
Koa.schema "mdManga", {
|
||||||
"success" => "boolean",
|
"id" => Int64,
|
||||||
"error" => "string?",
|
"chapters" => ["mdChapter"],
|
||||||
}
|
}.merge(s %w(title description author artist cover_url)),
|
||||||
|
desc: "A MangaDex manga"
|
||||||
mc_schema = {
|
|
||||||
"groups" => "object",
|
|
||||||
}.merge s %w(id title volume chapter language full_title time manga_title manga_id)
|
|
||||||
Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter"
|
|
||||||
|
|
||||||
Koa.array "chapterAry", "$mangadexChapter"
|
|
||||||
|
|
||||||
mm_schema = {
|
|
||||||
"chapers" => "$chapterAry",
|
|
||||||
}.merge s %w(id title description author artist cover_url)
|
|
||||||
Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga"
|
|
||||||
|
|
||||||
Koa.object "chaptersObj", {
|
|
||||||
"chapters" => "$chapterAry",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "successFailCount", {
|
|
||||||
"success" => "integer",
|
|
||||||
"fail" => "integer",
|
|
||||||
}
|
|
||||||
|
|
||||||
job_schema = {
|
|
||||||
"pages" => "integer",
|
|
||||||
"success_count" => "integer",
|
|
||||||
"fail_count" => "integer",
|
|
||||||
"time" => "integer",
|
|
||||||
}.merge s %w(id manga_id title manga_title status_message status)
|
|
||||||
Koa.object "job", job_schema, desc: "A download job in the queue"
|
|
||||||
|
|
||||||
Koa.array "jobAry", "$job"
|
|
||||||
|
|
||||||
Koa.object "jobs", {
|
|
||||||
"success" => "boolean",
|
|
||||||
"paused" => "boolean",
|
|
||||||
"jobs" => "$jobAry",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "binaryUpload", {
|
|
||||||
"file" => "$binary",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "pluginListBody", {
|
|
||||||
"plugin" => "string",
|
|
||||||
"query" => "string",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "pluginChapter", {
|
|
||||||
"id" => "string",
|
|
||||||
"title" => "string",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.array "pluginChapterAry", "$pluginChapter"
|
|
||||||
|
|
||||||
Koa.object "pluginList", {
|
|
||||||
"success" => "boolean",
|
|
||||||
"chapters" => "$pluginChapterAry?",
|
|
||||||
"title" => "string?",
|
|
||||||
"error" => "string?",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "pluginDownload", {
|
|
||||||
"plugin" => "string",
|
|
||||||
"title" => "string",
|
|
||||||
"chapters" => "$pluginChapterAry",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "dimension", {
|
|
||||||
"width" => "integer",
|
|
||||||
"height" => "integer",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.array "dimensionAry", "$dimension"
|
|
||||||
|
|
||||||
Koa.object "dimensionResult", {
|
|
||||||
"success" => "boolean",
|
|
||||||
"dimensions" => "$dimensionAry?",
|
|
||||||
"margin" => "number",
|
|
||||||
"error" => "string?",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "ids", {
|
|
||||||
"ids" => "$strAry",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "tagsResult", {
|
|
||||||
"success" => "boolean",
|
|
||||||
"tags" => "$strAry?",
|
|
||||||
"error" => "string?",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.object "missing", {
|
|
||||||
"path" => "string",
|
|
||||||
"id" => "string",
|
|
||||||
"signature" => "string",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.array "missingAry", "$missing"
|
|
||||||
|
|
||||||
Koa.object "missingResult", {
|
|
||||||
"success" => "boolean",
|
|
||||||
"error" => "string?",
|
|
||||||
"entries" => "$missingAry?",
|
|
||||||
"titles" => "$missingAry?",
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.describe "Returns a page in a manga entry"
|
Koa.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", type: "integer", 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, ref: "$binary", 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.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"]
|
||||||
@@ -212,8 +101,9 @@ struct APIRouter
|
|||||||
Koa.describe "Returns the cover image of a manga entry"
|
Koa.describe "Returns the cover image of 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.response 200, ref: "$binary", 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.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"]
|
||||||
@@ -236,17 +126,25 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`"
|
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||||
|
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
MD
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.response 200, ref: "$title"
|
Koa.query "slim"
|
||||||
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
|
Koa.tag "library"
|
||||||
get "/api/book/:tid" do |env|
|
get "/api/book/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
|
if env.params.query["slim"]?
|
||||||
|
send_json env, title.to_slim_json
|
||||||
|
else
|
||||||
send_json env, title.to_json
|
send_json env, title.to_json
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
@@ -254,15 +152,29 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries"
|
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||||
Koa.response 200, ref: "$library"
|
Supply the `tid` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
|
MD
|
||||||
|
Koa.query "slim"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"dir" => String,
|
||||||
|
"titles" => ["title"],
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
|
if env.params.query["slim"]?
|
||||||
|
send_json env, Library.default.to_slim_json
|
||||||
|
else
|
||||||
send_json env, Library.default.to_json
|
send_json env, Library.default.to_json
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
Koa.response 200, ref: "$scanResult"
|
Koa.response 200, schema: {
|
||||||
|
"milliseconds" => Float64,
|
||||||
|
"titles" => Int32,
|
||||||
|
}
|
||||||
post "/api/admin/scan" do |env|
|
post "/api/admin/scan" do |env|
|
||||||
start = Time.utc
|
start = Time.utc
|
||||||
Library.default.scan
|
Library.default.scan
|
||||||
@@ -274,8 +186,10 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
Koa.describe "Returns the thumbnail generation progress between 0 and 1"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
Koa.response 200, ref: "$progressResult"
|
Koa.response 200, schema: {
|
||||||
|
"progress" => Float64,
|
||||||
|
}
|
||||||
get "/api/admin/thumbnail_progress" do |env|
|
get "/api/admin/thumbnail_progress" do |env|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"progress" => Library.default.thumbnail_generation_progress,
|
"progress" => Library.default.thumbnail_generation_progress,
|
||||||
@@ -283,7 +197,7 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a thumbnail generation"
|
Koa.describe "Triggers a thumbnail generation"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
post "/api/admin/generate_thumbnails" do |env|
|
post "/api/admin/generate_thumbnails" do |env|
|
||||||
spawn do
|
spawn do
|
||||||
Library.default.generate_thumbnails
|
Library.default.generate_thumbnails
|
||||||
@@ -291,8 +205,8 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Deletes a user with `username`"
|
Koa.describe "Deletes a user with `username`"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "users"]
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
delete "/api/admin/user/delete/:username" do |env|
|
delete "/api/admin/user/delete/:username" do |env|
|
||||||
begin
|
begin
|
||||||
username = env.params.url["username"]
|
username = env.params.url["username"]
|
||||||
@@ -319,7 +233,8 @@ struct APIRouter
|
|||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.query "eid", desc: "Entry ID", required: false
|
Koa.query "eid", desc: "Entry ID", required: false
|
||||||
Koa.path "page", desc: "The new page number indicating the progress"
|
Koa.path "page", desc: "The new page number indicating the progress"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
|
Koa.tag "progress"
|
||||||
put "/api/progress/:tid/:page" do |env|
|
put "/api/progress/:tid/:page" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
@@ -350,8 +265,11 @@ struct APIRouter
|
|||||||
Koa.describe "Updates the reading progress of multiple entries in a title"
|
Koa.describe "Updates the reading progress of multiple entries in a title"
|
||||||
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
|
Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`"
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.body ref: "$ids", desc: "An array of entry IDs"
|
Koa.body schema: {
|
||||||
Koa.response 200, ref: "$result"
|
"ids" => [String],
|
||||||
|
}, desc: "An array of entry IDs"
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
|
Koa.tag "progress"
|
||||||
put "/api/bulk_progress/:action/:tid" do |env|
|
put "/api/bulk_progress/:action/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
@@ -377,11 +295,11 @@ struct APIRouter
|
|||||||
Koa.describe "Sets the display name of a title or an entry", <<-MD
|
Koa.describe "Sets the display name of a title or an entry", <<-MD
|
||||||
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
|
When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.query "eid", desc: "Entry ID", required: false
|
Koa.query "eid", desc: "Entry ID", required: false
|
||||||
Koa.path "name", desc: "The new display name"
|
Koa.path "name", desc: "The new display name"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
put "/api/admin/display_name/:tid/:name" do |env|
|
put "/api/admin/display_name/:tid/:name" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["tid"])
|
title = (Library.default.get_title env.params.url["tid"])
|
||||||
@@ -408,9 +326,9 @@ struct APIRouter
|
|||||||
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "mangadex"]
|
||||||
Koa.path "id", desc: "A MangaDex manga ID"
|
Koa.path "id", desc: "A MangaDex manga ID"
|
||||||
Koa.response 200, ref: "$mangadexManga"
|
Koa.response 200, schema: "mdManga"
|
||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
@@ -425,12 +343,17 @@ struct APIRouter
|
|||||||
Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD
|
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.
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "mangadex", "downloader"]
|
||||||
Koa.body ref: "$chaptersObj"
|
Koa.body schema: {
|
||||||
Koa.response 200, ref: "$successFailCount"
|
"chapters" => ["mdChapter"],
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Int32,
|
||||||
|
"fail" => Int32,
|
||||||
|
}
|
||||||
post "/api/admin/mangadex/download" do |env|
|
post "/api/admin/mangadex/download" do |env|
|
||||||
begin
|
begin
|
||||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
chapters = env.params.json["chapters"].as(Array).map &.as_h
|
||||||
jobs = chapters.map { |chapter|
|
jobs = chapters.map { |chapter|
|
||||||
Queue::Job.new(
|
Queue::Job.new(
|
||||||
chapter["id"].as_i64.to_s,
|
chapter["id"].as_i64.to_s,
|
||||||
@@ -457,7 +380,7 @@ struct APIRouter
|
|||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
loop do
|
loop do
|
||||||
socket.send({
|
socket.send({
|
||||||
"jobs" => Queue.default.get_all,
|
"jobs" => Queue.default.get_all.reverse,
|
||||||
"paused" => Queue.default.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
}.to_json)
|
}.to_json)
|
||||||
sleep interval.seconds
|
sleep interval.seconds
|
||||||
@@ -467,17 +390,27 @@ struct APIRouter
|
|||||||
Koa.describe "Returns the current download queue", <<-MD
|
Koa.describe "Returns the current download queue", <<-MD
|
||||||
On error, returns a JSON that contains the error message in the `error` field.
|
On error, returns a JSON that contains the error message in the `error` field.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.response 200, ref: "$jobs"
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"paused" => Bool?,
|
||||||
|
"jobs?" => [{
|
||||||
|
"pages" => Int32,
|
||||||
|
"success_count" => Int32,
|
||||||
|
"fail_count" => Int32,
|
||||||
|
"time" => Int64,
|
||||||
|
}.merge(s %w(id manga_id title manga_title status_message status))],
|
||||||
|
}
|
||||||
get "/api/admin/mangadex/queue" do |env|
|
get "/api/admin/mangadex/queue" do |env|
|
||||||
begin
|
begin
|
||||||
jobs = Queue.default.get_all
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"jobs" => jobs,
|
"jobs" => Queue.default.get_all.reverse,
|
||||||
"paused" => Queue.default.paused?,
|
"paused" => Queue.default.paused?,
|
||||||
"success" => true,
|
"success" => true,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -494,10 +427,10 @@ struct APIRouter
|
|||||||
|
|
||||||
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
|
When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
|
Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`."
|
||||||
Koa.query "id", required: false, desc: "A job ID"
|
Koa.query "id", required: false, desc: "A job ID"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
post "/api/admin/mangadex/queue/:action" do |env|
|
post "/api/admin/mangadex/queue/:action" do |env|
|
||||||
begin
|
begin
|
||||||
action = env.params.url["action"]
|
action = env.params.url["action"]
|
||||||
@@ -525,6 +458,7 @@ struct APIRouter
|
|||||||
|
|
||||||
send_json env, {"success" => true}.to_json
|
send_json env, {"success" => true}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -546,8 +480,10 @@ struct APIRouter
|
|||||||
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
|
When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry.
|
||||||
MD
|
MD
|
||||||
Koa.tag "admin"
|
Koa.tag "admin"
|
||||||
Koa.body type: "multipart/form-data", ref: "$binaryUpload"
|
Koa.body media_type: "multipart/form-data", schema: {
|
||||||
Koa.response 200, ref: "$result"
|
"file" => Bytes,
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
post "/api/admin/upload/:target" do |env|
|
post "/api/admin/upload/:target" do |env|
|
||||||
begin
|
begin
|
||||||
target = env.params.url["target"]
|
target = env.params.url["target"]
|
||||||
@@ -595,6 +531,98 @@ struct APIRouter
|
|||||||
|
|
||||||
raise "No part with name `file` found"
|
raise "No part with name `file` found"
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
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, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -603,17 +631,32 @@ struct APIRouter
|
|||||||
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.tag "admin"
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.body ref: "$pluginListBody"
|
Koa.query "plugin", schema: String
|
||||||
Koa.response 200, ref: "$pluginList"
|
Koa.query "query", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"chapters?" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title?" => String,
|
||||||
|
}],
|
||||||
|
"title" => String?,
|
||||||
|
}
|
||||||
get "/api/admin/plugin/list" do |env|
|
get "/api/admin/plugin/list" do |env|
|
||||||
begin
|
begin
|
||||||
query = env.params.query["query"].as String
|
query = env.params.query["query"].as String
|
||||||
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
|
||||||
|
|
||||||
|
if plugin.info.version == 1
|
||||||
chapters = json["chapters"]
|
chapters = json["chapters"]
|
||||||
title = json["title"]
|
title = json["title"]
|
||||||
|
else
|
||||||
|
chapters = json
|
||||||
|
title = nil
|
||||||
|
end
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@@ -621,6 +664,7 @@ struct APIRouter
|
|||||||
"title" => title,
|
"title" => title,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -629,9 +673,19 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Adds a list of chapters from a plugin to the download queue"
|
Koa.describe "Adds a list of chapters from a plugin to the download queue"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.body ref: "$pluginDownload"
|
Koa.body schema: {
|
||||||
Koa.response 200, ref: "$successFailCount"
|
"plugin" => String,
|
||||||
|
"title" => String,
|
||||||
|
"chapters" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Int32,
|
||||||
|
"fail" => Int32,
|
||||||
|
}
|
||||||
post "/api/admin/plugin/download" do |env|
|
post "/api/admin/plugin/download" do |env|
|
||||||
begin
|
begin
|
||||||
plugin = Plugin.new env.params.json["plugin"].as String
|
plugin = Plugin.new env.params.json["plugin"].as String
|
||||||
@@ -654,6 +708,7 @@ struct APIRouter
|
|||||||
"fail": jobs.size - inserted_count,
|
"fail": jobs.size - inserted_count,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -664,7 +719,15 @@ struct APIRouter
|
|||||||
Koa.describe "Returns the image dimensions of all pages in an entry"
|
Koa.describe "Returns the image dimensions of all pages in an entry"
|
||||||
Koa.path "tid", desc: "A title ID"
|
Koa.path "tid", desc: "A title ID"
|
||||||
Koa.path "eid", desc: "An entry ID"
|
Koa.path "eid", desc: "An entry ID"
|
||||||
Koa.response 200, ref: "$dimensionResult"
|
Koa.tag "reader"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"dimensions?" => [{
|
||||||
|
"width" => Int32,
|
||||||
|
"height" => Int32,
|
||||||
|
}],
|
||||||
|
}
|
||||||
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"]
|
||||||
@@ -679,9 +742,9 @@ struct APIRouter
|
|||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
"dimensions" => sizes,
|
"dimensions" => sizes,
|
||||||
"margin" => Config.current.page_margin,
|
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -692,8 +755,9 @@ struct APIRouter
|
|||||||
Koa.describe "Downloads an entry"
|
Koa.describe "Downloads an entry"
|
||||||
Koa.path "tid", desc: "A title ID"
|
Koa.path "tid", desc: "A title ID"
|
||||||
Koa.path "eid", desc: "An entry ID"
|
Koa.path "eid", desc: "An entry ID"
|
||||||
Koa.response 200, ref: "$binary"
|
Koa.response 200, schema: Bytes
|
||||||
Koa.response 404, "Entry not found"
|
Koa.response 404, "Entry not found"
|
||||||
|
Koa.tags ["library", "reader"]
|
||||||
get "/api/download/:tid/:eid" do |env|
|
get "/api/download/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
@@ -708,7 +772,12 @@ struct APIRouter
|
|||||||
|
|
||||||
Koa.describe "Gets the tags of a title"
|
Koa.describe "Gets the tags of a title"
|
||||||
Koa.path "tid", desc: "A title ID"
|
Koa.path "tid", desc: "A title ID"
|
||||||
Koa.response 200, ref: "$tagsResult"
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"tags" => [String?],
|
||||||
|
}
|
||||||
|
Koa.tags ["library", "tags"]
|
||||||
get "/api/tags/:tid" do |env|
|
get "/api/tags/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
@@ -728,7 +797,12 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns all tags"
|
Koa.describe "Returns all tags"
|
||||||
Koa.response 200, ref: "$tagsResult"
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"tags" => [String?],
|
||||||
|
}
|
||||||
|
Koa.tags ["library", "tags"]
|
||||||
get "/api/tags" do |env|
|
get "/api/tags" do |env|
|
||||||
begin
|
begin
|
||||||
tags = Storage.default.list_tags
|
tags = Storage.default.list_tags
|
||||||
@@ -747,8 +821,8 @@ struct APIRouter
|
|||||||
|
|
||||||
Koa.describe "Adds a new tag to a title"
|
Koa.describe "Adds a new tag to a title"
|
||||||
Koa.path "tid", desc: "A title ID"
|
Koa.path "tid", desc: "A title ID"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library", "tags"]
|
||||||
put "/api/admin/tags/:tid/:tag" do |env|
|
put "/api/admin/tags/:tid/:tag" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
@@ -770,8 +844,8 @@ struct APIRouter
|
|||||||
|
|
||||||
Koa.describe "Deletes a tag from a title"
|
Koa.describe "Deletes a tag from a title"
|
||||||
Koa.path "tid", desc: "A title ID"
|
Koa.path "tid", desc: "A title ID"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library", "tags"]
|
||||||
delete "/api/admin/tags/:tid/:tag" do |env|
|
delete "/api/admin/tags/:tid/:tag" do |env|
|
||||||
begin
|
begin
|
||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
@@ -792,8 +866,16 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Lists all missing titles"
|
Koa.describe "Lists all missing titles"
|
||||||
Koa.response 200, ref: "$missingResult"
|
Koa.response 200, schema: {
|
||||||
Koa.tag "admin"
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"titles?" => [{
|
||||||
|
"path" => String,
|
||||||
|
"id" => String,
|
||||||
|
"signature" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
Koa.tags ["admin", "library"]
|
||||||
get "/api/admin/titles/missing" do |env|
|
get "/api/admin/titles/missing" do |env|
|
||||||
begin
|
begin
|
||||||
send_json env, {
|
send_json env, {
|
||||||
@@ -802,6 +884,7 @@ struct APIRouter
|
|||||||
"titles" => Storage.default.missing_titles,
|
"titles" => Storage.default.missing_titles,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -810,8 +893,16 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Lists all missing entries"
|
Koa.describe "Lists all missing entries"
|
||||||
Koa.response 200, ref: "$missingResult"
|
Koa.response 200, schema: {
|
||||||
Koa.tag "admin"
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"entries?" => [{
|
||||||
|
"path" => String,
|
||||||
|
"id" => String,
|
||||||
|
"signature" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
Koa.tags ["admin", "library"]
|
||||||
get "/api/admin/entries/missing" do |env|
|
get "/api/admin/entries/missing" do |env|
|
||||||
begin
|
begin
|
||||||
send_json env, {
|
send_json env, {
|
||||||
@@ -820,6 +911,7 @@ struct APIRouter
|
|||||||
"entries" => Storage.default.missing_entries,
|
"entries" => Storage.default.missing_entries,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -828,8 +920,8 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Deletes all missing titles"
|
Koa.describe "Deletes all missing titles"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
delete "/api/admin/titles/missing" do |env|
|
delete "/api/admin/titles/missing" do |env|
|
||||||
begin
|
begin
|
||||||
Storage.default.delete_missing_title
|
Storage.default.delete_missing_title
|
||||||
@@ -838,6 +930,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -846,8 +939,8 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Deletes all missing entries"
|
Koa.describe "Deletes all missing entries"
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
delete "/api/admin/entries/missing" do |env|
|
delete "/api/admin/entries/missing" do |env|
|
||||||
begin
|
begin
|
||||||
Storage.default.delete_missing_entry
|
Storage.default.delete_missing_entry
|
||||||
@@ -856,6 +949,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -866,8 +960,8 @@ struct APIRouter
|
|||||||
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
|
Koa.describe "Deletes a missing title identified by `tid`", <<-MD
|
||||||
Does nothing if the given `tid` is not found or if the title is not missing.
|
Does nothing if the given `tid` is not found or if the title is not missing.
|
||||||
MD
|
MD
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
delete "/api/admin/titles/missing/:tid" do |env|
|
delete "/api/admin/titles/missing/:tid" do |env|
|
||||||
begin
|
begin
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
@@ -877,6 +971,7 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
@@ -887,8 +982,8 @@ struct APIRouter
|
|||||||
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
|
Koa.describe "Deletes a missing entry identified by `eid`", <<-MD
|
||||||
Does nothing if the given `eid` is not found or if the entry is not missing.
|
Does nothing if the given `eid` is not found or if the entry is not missing.
|
||||||
MD
|
MD
|
||||||
Koa.response 200, ref: "$result"
|
Koa.response 200, schema: "result"
|
||||||
Koa.tag "admin"
|
Koa.tags ["admin", "library"]
|
||||||
delete "/api/admin/entries/missing/:eid" do |env|
|
delete "/api/admin/entries/missing/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
eid = env.params.url["eid"]
|
eid = env.params.url["eid"]
|
||||||
@@ -898,6 +993,116 @@ struct APIRouter
|
|||||||
"error" => nil,
|
"error" => nil,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
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, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message,
|
"error" => e.message,
|
||||||
|
|||||||
+3
-12
@@ -30,7 +30,8 @@ struct MainRouter
|
|||||||
else
|
else
|
||||||
redirect env, "/"
|
redirect env, "/"
|
||||||
end
|
end
|
||||||
rescue
|
rescue e
|
||||||
|
Logger.error e
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -78,16 +79,6 @@ struct MainRouter
|
|||||||
|
|
||||||
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
|
||||||
@@ -103,7 +94,7 @@ struct MainRouter
|
|||||||
recently_added = Library.default.get_recently_added_entries username
|
recently_added = Library.default.get_recently_added_entries username
|
||||||
start_reading = Library.default.get_start_reading_titles username
|
start_reading = Library.default.get_start_reading_titles username
|
||||||
titles = Library.default.titles
|
titles = Library.default.titles
|
||||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
new_user = !titles.any? &.load_percentage(username).> 0
|
||||||
empty_library = titles.size == 0
|
empty_library = titles.size == 0
|
||||||
layout "home"
|
layout "home"
|
||||||
rescue e
|
rescue e
|
||||||
|
|||||||
+11
-4
@@ -30,6 +30,11 @@ struct ReaderRouter
|
|||||||
|
|
||||||
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
|
||||||
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
|
get_sort_opt
|
||||||
|
entries = title.sorted_entries username, sort_opt
|
||||||
|
|
||||||
page_idx = env.params.url["page"].to_i
|
page_idx = env.params.url["page"].to_i
|
||||||
if page_idx > entry.pages || page_idx <= 0
|
if page_idx > entry.pages || page_idx <= 0
|
||||||
raise "Page #{page_idx} not found."
|
raise "Page #{page_idx} not found."
|
||||||
@@ -37,10 +42,12 @@ struct ReaderRouter
|
|||||||
|
|
||||||
exit_url = "#{base_url}book/#{title.id}"
|
exit_url = "#{base_url}book/#{title.id}"
|
||||||
|
|
||||||
next_entry_url = nil
|
next_entry_url = entry.next_entry(username).try do |e|
|
||||||
next_entry = entry.next_entry username
|
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||||
unless next_entry.nil?
|
end
|
||||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
|
||||||
|
previous_entry_url = entry.previous_entry(username).try do |e|
|
||||||
|
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
render "src/views/reader.html.ecr"
|
render "src/views/reader.html.ecr"
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class Server
|
|||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
Kemal.config.env = "production"
|
Kemal.config.env = "production"
|
||||||
{% end %}
|
{% end %}
|
||||||
|
Kemal.config.host_binding = Config.current.host
|
||||||
Kemal.config.port = Config.current.port
|
Kemal.config.port = Config.current.port
|
||||||
Kemal.run
|
Kemal.run
|
||||||
end
|
end
|
||||||
|
|||||||
+34
-3
@@ -34,7 +34,7 @@ class Storage
|
|||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
Logger.info "The DB directory #{dir} does not exist. " \
|
Logger.info "The DB directory #{dir} does not exist. " \
|
||||||
"Attepmting to create it"
|
"Attempting to create it"
|
||||||
Dir.mkdir_p dir
|
Dir.mkdir_p dir
|
||||||
end
|
end
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
@@ -445,7 +445,7 @@ class Storage
|
|||||||
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||||
end
|
end
|
||||||
db.exec "update ids set unavailable = 1 where id in " \
|
db.exec "update ids set unavailable = 1 where id in " \
|
||||||
"(#{trash_ids.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
||||||
|
|
||||||
# Detect dangling title IDs
|
# Detect dangling title IDs
|
||||||
trash_titles = [] of String
|
trash_titles = [] of String
|
||||||
@@ -461,7 +461,7 @@ class Storage
|
|||||||
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||||
end
|
end
|
||||||
db.exec "update titles set unavailable = 1 where id in " \
|
db.exec "update titles set unavailable = 1 where id in " \
|
||||||
"(#{trash_titles.map { |i| "'#{i}'" }.join ","})"
|
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -514,6 +514,37 @@ class Storage
|
|||||||
delete_missing "titles", id
|
delete_missing "titles", id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def save_md_token(username : String, token : String, expire : Time)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
count = db.query_one "select count(*) from md_account where " \
|
||||||
|
"username = (?)", username, as: Int64
|
||||||
|
if count == 0
|
||||||
|
db.exec "insert into md_account values (?, ?, ?)", username, token,
|
||||||
|
expire.to_unix
|
||||||
|
else
|
||||||
|
db.exec "update md_account set token = (?), expire = (?) " \
|
||||||
|
"where username = (?)", token, expire.to_unix, username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_md_token(username) : Tuple(String?, Time?)
|
||||||
|
token = nil
|
||||||
|
expires = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query_one? "select token, expire from md_account where " \
|
||||||
|
"username = (?)", username do |res|
|
||||||
|
token = res.read String
|
||||||
|
expires = Time.unix res.read Int64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
{token, expires}
|
||||||
|
end
|
||||||
|
|
||||||
def close
|
def close
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
unless @db.nil?
|
unless @db.nil?
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
require "db"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
struct Subscription
|
||||||
|
include DB::Serializable
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
getter id : Int64 = 0
|
||||||
|
getter username : String
|
||||||
|
getter manga_id : Int64
|
||||||
|
property language : String?
|
||||||
|
property group_id : Int64?
|
||||||
|
property min_volume : Int64?
|
||||||
|
property max_volume : Int64?
|
||||||
|
property min_chapter : Int64?
|
||||||
|
property max_chapter : Int64?
|
||||||
|
@[DB::Field(key: "last_checked")]
|
||||||
|
@[JSON::Field(key: "last_checked")]
|
||||||
|
@raw_last_checked : Int64
|
||||||
|
@[DB::Field(key: "created_at")]
|
||||||
|
@[JSON::Field(key: "created_at")]
|
||||||
|
@raw_created_at : Int64
|
||||||
|
|
||||||
|
def last_checked : Time
|
||||||
|
Time.unix @raw_last_checked
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_at : Time
|
||||||
|
Time.unix @raw_created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@manga_id, @username)
|
||||||
|
@raw_created_at = Time.utc.to_unix
|
||||||
|
@raw_last_checked = Time.utc.to_unix
|
||||||
|
end
|
||||||
|
|
||||||
|
private def in_range?(value : String, lowerbound : Int64?,
|
||||||
|
upperbound : Int64?) : Bool
|
||||||
|
lb = lowerbound.try &.to_f64
|
||||||
|
ub = upperbound.try &.to_f64
|
||||||
|
|
||||||
|
return true if lb.nil? && ub.nil?
|
||||||
|
|
||||||
|
v = value.to_f64?
|
||||||
|
return false unless v
|
||||||
|
|
||||||
|
if lb.nil?
|
||||||
|
v <= ub.not_nil!
|
||||||
|
elsif ub.nil?
|
||||||
|
v >= lb.not_nil!
|
||||||
|
else
|
||||||
|
v >= lb.not_nil! && v <= ub.not_nil!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match?(chapter : MangaDex::Chapter) : Bool
|
||||||
|
if chapter.manga_id != manga_id ||
|
||||||
|
(language && chapter.language != language) ||
|
||||||
|
(group_id && !chapter.groups.map(&.id).includes? group_id)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
in_range?(chapter.volume, min_volume, max_volume) &&
|
||||||
|
in_range?(chapter.chapter, min_chapter, max_chapter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_for_updates : Int32
|
||||||
|
Logger.debug "Checking updates for subscription with ID #{id}"
|
||||||
|
jobs = [] of Queue::Job
|
||||||
|
get_client(username).user.updates_after last_checked do |chapter|
|
||||||
|
next unless match? chapter
|
||||||
|
jobs << chapter.to_job
|
||||||
|
end
|
||||||
|
Storage.default.update_subscription_last_checked id
|
||||||
|
count = Queue.default.push jobs
|
||||||
|
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
|
||||||
|
count
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error occurred when checking updates for " \
|
||||||
|
"subscription with ID #{id}. #{e}"
|
||||||
|
0
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -73,7 +73,7 @@ class ChapterSorter
|
|||||||
.select do |key|
|
.select do |key|
|
||||||
keys[key].count >= str_ary.size / 2
|
keys[key].count >= str_ary.size / 2
|
||||||
end
|
end
|
||||||
.sort do |a_key, b_key|
|
.sort! do |a_key, b_key|
|
||||||
a = keys[a_key]
|
a = keys[a_key]
|
||||||
b = keys[b_key]
|
b = keys[b_key]
|
||||||
# Sort keys by the number of times they appear
|
# Sort keys by the number of times they appear
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ end
|
|||||||
def split_by_alphanumeric(str)
|
def split_by_alphanumeric(str)
|
||||||
arr = [] of String
|
arr = [] of String
|
||||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||||
arr += match.captures.select { |s| s != "" }
|
arr += match.captures.select &.!= ""
|
||||||
end
|
end
|
||||||
arr
|
arr
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ class String
|
|||||||
def components_similarity(other : String) : Float64
|
def components_similarity(other : String) : Float64
|
||||||
s, l = [self, other]
|
s, l = [self, other]
|
||||||
.map { |str| Path.new(str).parts }
|
.map { |str| Path.new(str).parts }
|
||||||
.sort_by &.size
|
.sort_by! &.size
|
||||||
|
|
||||||
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
||||||
match / s.size
|
match / s.size
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ def redirect(env, path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hash_to_query(hash)
|
def hash_to_query(hash)
|
||||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
hash.join "&" { |k, v| "#{k}=#{v}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_path_startswith(env, ary)
|
def request_path_startswith(env, ary)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<option>System</option>
|
<option>System</option>
|
||||||
</select>
|
</select>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a class="uk-link-reset" href="<%= base_url %>admin/mangadex">Connect to MangaDex</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
<button class="uk-button uk-button-default" @click="load()" :disabled="loading">Refresh Queue</button>
|
||||||
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
<button class="uk-button uk-button-default" x-show="paused !== undefined" x-text="paused ? 'Resume Download' : 'Pause Download'" @click="toggle()" :disabled="toggling"></button>
|
||||||
</div>
|
</div>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Chapter</th>
|
<th>Chapter</th>
|
||||||
@@ -61,9 +62,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
<%= render_component "moment" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/download-manager.js"></script>
|
<script src="<%= base_url %>js/download-manager.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
<h2 class=uk-title>Download from MangaDex</h2>
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
<div x-data="downloadComponent()" x-init="init()">
|
<div x-data="downloadComponent()" x-init="init()">
|
||||||
<div class="uk-grid-small" uk-grid>
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
<div class="uk-width-3-4">
|
<div class="uk-width-expand">
|
||||||
<input class="uk-input" type="text" placeholder="MangaDex manga ID or URL" x-model="searchInput" @keydown.enter.debounce="search()">
|
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4">
|
<div class="uk-width-auto">
|
||||||
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
||||||
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-if="mangaAry">
|
||||||
|
<div>
|
||||||
|
<p x-show="mangaAry.length === 0">No matching manga found.</p>
|
||||||
|
|
||||||
|
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<template x-for="manga in mangaAry" :key="manga.id">
|
||||||
|
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
|
||||||
|
<div class="uk-card uk-card-default">
|
||||||
|
<div class="uk-card-media-top uk-inline">
|
||||||
|
<img uk-img :data-src="manga.mainCover">
|
||||||
|
</div>
|
||||||
|
<div class="uk-card-body">
|
||||||
|
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
|
||||||
|
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div x-show="data && data.chapters" x-cloak>
|
<div x-show="data && data.chapters" x-cloak>
|
||||||
<div class"uk-grid-small" uk-grid style="margin-top:40px">
|
<div class"uk-grid-small" uk-grid>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<img :src="data.mainCover">
|
<img :src="data.mainCover">
|
||||||
</div>
|
</div>
|
||||||
@@ -107,11 +129,34 @@
|
|||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="modal" class="uk-flex-top" uk-modal="container: false">
|
||||||
|
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
|
<div class="uk-modal-header">
|
||||||
|
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-body">
|
||||||
|
<div class="uk-grid">
|
||||||
|
<div class="uk-width-1-3@s">
|
||||||
|
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
|
||||||
|
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-2-3@s" uk-overflow-auto>
|
||||||
|
<p x-text="candidateManga.description"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-footer">
|
||||||
|
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
<%= render_component "moment" %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
<%= render_component "jquery-ui" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/download.js"></script>
|
<script src="<%= base_url %>js/download.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<%- end -%>
|
<%- end -%>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "dots-scripts" %>
|
<%= render_component "dots" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/title.js"></script>
|
<script src="<%= base_url %>js/title.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "dots-scripts" %>
|
<%= render_component "dots" %>
|
||||||
<script src="<%= base_url %>js/search.js"></script>
|
<script src="<%= base_url %>js/search.js"></script>
|
||||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div x-data="component()" x-init="init()">
|
||||||
|
<h2 class="uk-title">Connect to MangaDex</h2>
|
||||||
|
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
|
||||||
|
<div class="uk-width-1-2@s" x-show="!expires">
|
||||||
|
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Search MangaDex by search terms in addition to manga IDs</li>
|
||||||
|
<li>Automatically download new chapters when they are available (coming soon)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-width-1-2@s" x-show="expires">
|
||||||
|
<p>
|
||||||
|
<span x-show="!expired">You have logged in to MangaDex!</span>
|
||||||
|
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
|
||||||
|
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
|
||||||
|
<span x-show="!expired">If the integration is not working, you</span>
|
||||||
|
<span x-show="expired">You</span>
|
||||||
|
can log in again and the token will be updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/mangadex.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
<div x-show="!empty">
|
<div x-show="!empty">
|
||||||
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
<p>The following items were present in your library, but now we can't find them anymore. If you deleted them mistakenly, try to recover the files or folders, put them back to where they were, and rescan the library. Otherwise, you can safely delete them and the associated metadata using the buttons below to free up database space.</p>
|
||||||
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
<button class="uk-button uk-button-danger" @click="rmAll()">Delete All</button>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
<% if plugins.empty? %>
|
<div x-data="component()" x-init="init()" x-cloak>
|
||||||
<div class="uk-container uk-text-center">
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
|
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||||
<h2>No Plugins Found</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<% else %>
|
<div x-show="plugins.length > 0" style="width:100%">
|
||||||
<h2 class=uk-title>Download with Plugins</h2>
|
<h2 class=uk-title>Download with Plugins
|
||||||
|
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
<template x-if="info !== undefined">
|
||||||
|
<div>
|
||||||
|
<div class="uk-grid-small" uk-grid>
|
||||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
<label class="uk-form-label"> </label>
|
||||||
|
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-expand">
|
<div class="uk-width-expand">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
<label class="uk-form-label">Choose a plugin</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select id="plugin-select" class="uk-select">
|
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||||
<% plugins.each do |p| %>
|
<template x-for="p in plugins" :key="p">
|
||||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
<option :value="p.id" x-text="p.title"></option>
|
||||||
<% end %>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-auto">
|
<div class="uk-width-auto">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
<label class="uk-form-label"> </label>
|
||||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,37 +44,137 @@
|
|||||||
</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>
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
<% plugin.not_nil!.info.each do |k, v| %>
|
<dt x-text="entry[0]"></dt>
|
||||||
<dt><%= k %></dt>
|
<dd x-text="entry[1]"></dd>
|
||||||
<dd><%= v.to_s %></dd>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div id="table" class="uk-margin-large-top" hidden>
|
<template x-if="manga">
|
||||||
<h3 id="title-text"></h3>
|
<div class="uk-margin">
|
||||||
|
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||||
|
<p x-show="manga.length > 0">
|
||||||
|
<span x-text="`${manga.length} manga found`"></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 class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<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>
|
||||||
|
<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>
|
<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>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<% if plugin %>
|
<%= render_component "jquery-ui" %>
|
||||||
<script>
|
|
||||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
|
||||||
</script>
|
|
||||||
<% end %>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
|
||||||
<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 %>
|
||||||
|
|||||||
@@ -25,18 +25,18 @@
|
|||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||||
:style="item.style"
|
|
||||||
:data-src="item.url"
|
:data-src="item.url"
|
||||||
:width="item.width"
|
:width="item.width"
|
||||||
:height="item.height"
|
:height="item.height"
|
||||||
:id="item.id"
|
:id="item.id"
|
||||||
|
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||||
@click="showControl($event)"
|
@click="showControl($event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<%- if next_entry_url -%>
|
<%- if next_entry_url -%>
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
|
||||||
<%- else -%>
|
<%- else -%>
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>', true)">Exit Reader</button>
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,18 +68,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<p id="progress-label"></p>
|
<p x-text="`Progress: ${selectedIndex}/${items.length} (${(selectedIndex/items.length * 100).toFixed(1)}%)`"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="page-select">Jump to page</label>
|
<label class="uk-form-label" for="page-select">Jump to Page</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select id="page-select" class="uk-select" @change="pageChanged()">
|
<select id="page-select" class="uk-select" @change="pageChanged()" x-model="selectedIndex">
|
||||||
<%- (1..entry.pages).each do |p| -%>
|
<%- (1..entry.pages).each do |p| -%>
|
||||||
<option value="<%= p %>"><%= p %></option>
|
<option value="<%= p %>"><%= p %></option>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="mode-select">Mode</label>
|
<label class="uk-form-label" for="mode-select">Mode</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
@@ -89,9 +90,40 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-margin" x-show="mode === 'continuous'">
|
||||||
|
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="margin-range" class="uk-range" type="range" min="0" max="50" step="5" x-model="margin" @change="marginChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="uk-divider-icon">
|
||||||
|
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="entry-select">Jump to Entry</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select id="entry-select" class="uk-select" @change="entryChanged()">
|
||||||
|
<% entries.each do |e| %>
|
||||||
|
<option value="<%= e.id %>"
|
||||||
|
<% if e.id == entry.id %>
|
||||||
|
selected
|
||||||
|
<% end %>>
|
||||||
|
<%= e.title %>
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-footer uk-text-right">
|
<div class="uk-modal-footer uk-text-right">
|
||||||
<button class="uk-button uk-button-danger" type="button" @click="exitReader('<%= exit_url %>')">Exit Reader</button>
|
<% if previous_entry_url %>
|
||||||
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
||||||
|
<% end %>
|
||||||
|
<% if next_entry_url %>
|
||||||
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
||||||
|
<% end %>
|
||||||
|
<a class="uk-button uk-button-danger uk-margin-small-bottom uk-margin-small-right" href="<%= exit_url %>">Exit Reader</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<h2 class="uk-title">MangaDex Subscription Manager</h2>
|
||||||
|
|
||||||
|
<div x-data="component()" x-init="init()">
|
||||||
|
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
|
||||||
|
|
||||||
|
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
|
||||||
|
|
||||||
|
<template x-if="subscriptions.length > 0">
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Manga ID</th>
|
||||||
|
<th>Language</th>
|
||||||
|
<th>Group ID</th>
|
||||||
|
<th>Volume Range</th>
|
||||||
|
<th>Chapter Range</th>
|
||||||
|
<th>Creator</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="sub in subscriptions" :key="sub">
|
||||||
|
<tr>
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
|
||||||
|
<td x-text="sub.language || 'All'"></td>
|
||||||
|
<td>
|
||||||
|
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
|
||||||
|
<span x-show="!sub.group_id">All</span>
|
||||||
|
</td>
|
||||||
|
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
|
||||||
|
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
|
||||||
|
<td x-text="sub.username"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
|
||||||
|
<td :data-id="sub.id">
|
||||||
|
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
|
||||||
|
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/subscription.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "dots-scripts" %>
|
<%= render_component "dots" %>
|
||||||
<script src="<%= base_url %>js/search.js"></script>
|
<script src="<%= base_url %>js/search.js"></script>
|
||||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "dots-scripts" %>
|
<%= render_component "dots" %>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
<link href="<%= base_url %>css/tags.css" rel="stylesheet" />
|
<link href="<%= base_url %>css/tags.css" rel="stylesheet" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-beta.1/dist/js/select2.min.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user