mirror of
https://github.com/hkalexling/Mango.git
synced 2026-05-01 00:00:55 -04:00
Compare commits
32 Commits
v0.21.0
...
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 | |||
| 0b3e78bcb7 | |||
| 6a275286ea | |||
| d3f26ecbc9 |
@@ -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.21.0
|
Mango - Manga Server and Web Reader. Version 0.22.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@ mangadex:
|
|||||||
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
|
||||||
|
|||||||
@@ -260,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}`);
|
||||||
|
|||||||
+317
-132
@@ -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: '',
|
||||||
|
searching: false,
|
||||||
|
adding: false,
|
||||||
|
sortOptions: [],
|
||||||
|
showFilters: false,
|
||||||
|
appliedFilters: [],
|
||||||
|
chaptersLimit: 500,
|
||||||
|
listManga: false,
|
||||||
|
|
||||||
$(() => {
|
init() {
|
||||||
var storedID = localStorage.getItem('plugin');
|
const tableObserver = new MutationObserver(() => {
|
||||||
if (storedID && storedID !== pid) {
|
console.log('table mutated');
|
||||||
loadPlugin(storedID);
|
$('#selectable').selectable({
|
||||||
} else {
|
filter: 'tr'
|
||||||
$('#controls').removeAttr('hidden');
|
});
|
||||||
}
|
});
|
||||||
|
tableObserver.observe($('table').get(0), {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
fetch(`${base_url}api/admin/plugin`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
this.plugins = data.plugins;
|
||||||
|
|
||||||
$('#search-input').keypress(event => {
|
const pid = localStorage.getItem('plugin');
|
||||||
if (event.which === 13) {
|
if (pid && this.plugins.map(p => p.id).includes(pid))
|
||||||
search();
|
return this.loadPlugin(pid);
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#plugin-select').val(pid);
|
|
||||||
$('#plugin-select').change(() => {
|
|
||||||
const id = $('#plugin-select').val();
|
|
||||||
loadPlugin(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mangaTitle = "";
|
if (this.plugins.length > 0)
|
||||||
let searching = false;
|
this.loadPlugin(this.plugins[0].id);
|
||||||
const search = () => {
|
})
|
||||||
if (searching)
|
.catch(e => {
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const query = $.param({
|
this.allChapters = data.chapters;
|
||||||
query: $('#search-input').val(),
|
this.chapters = data.chapters;
|
||||||
plugin: pid
|
})
|
||||||
});
|
.catch(e => {
|
||||||
$.ajax({
|
alert('danger', `Failed to list chapters. Error: ${e}`);
|
||||||
type: 'GET',
|
})
|
||||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
.finally(() => {
|
||||||
contentType: "application/json",
|
this.searching = false;
|
||||||
dataType: 'json'
|
});
|
||||||
})
|
},
|
||||||
.done(data => {
|
searchManga() {
|
||||||
console.log(data);
|
this.searching = true;
|
||||||
if (data.error) {
|
this.allChapters = [];
|
||||||
alert('danger', `Search failed. Error: ${data.error}`);
|
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) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
download() {
|
||||||
|
const selected = $('tbody > tr.ui-selected').get();
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
const ids = selected.map(e => e.id);
|
||||||
|
const chapters = this.chapters.filter(c => ids.includes(c.id));
|
||||||
|
console.log(chapters);
|
||||||
|
this.adding = true;
|
||||||
|
fetch(`${base_url}api/admin/plugin/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapters,
|
||||||
|
plugin: this.pid,
|
||||||
|
title: this.mangaTitle
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.adding = false;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
},
|
||||||
|
thClicked(event) {
|
||||||
|
const idx = parseInt(event.currentTarget.id.split('-')[1]);
|
||||||
|
if (idx === undefined || isNaN(idx)) return;
|
||||||
|
const curOption = this.sortOptions[idx];
|
||||||
|
let option;
|
||||||
|
this.sortOptions = [];
|
||||||
|
switch (curOption) {
|
||||||
|
case 1:
|
||||||
|
option = -1;
|
||||||
|
break;
|
||||||
|
case -1:
|
||||||
|
option = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
option = 1;
|
||||||
|
}
|
||||||
|
this.sortOptions[idx] = option;
|
||||||
|
this.sort(this.chapterKeys[idx], option)
|
||||||
|
},
|
||||||
|
// Returns an array of filtered but unsorted chapters. Useful when
|
||||||
|
// reseting the sort options.
|
||||||
|
get filteredChapters() {
|
||||||
|
let ary = this.allChapters.slice();
|
||||||
|
|
||||||
|
console.log('initial size:', ary.length);
|
||||||
|
for (let filter of this.appliedFilters) {
|
||||||
|
if (!filter.value) continue;
|
||||||
|
if (filter.type === 'array' && filter.value === 'all') continue;
|
||||||
|
|
||||||
|
console.log('applying filter:', filter);
|
||||||
|
|
||||||
|
if (filter.type === 'string') {
|
||||||
|
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (filter.type === 'number-min') {
|
||||||
|
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'number-max') {
|
||||||
|
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'date-min') {
|
||||||
|
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'date-max') {
|
||||||
|
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'array') {
|
||||||
|
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('filtered size:', ary.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ary;
|
||||||
|
},
|
||||||
|
// option:
|
||||||
|
// - 1: asending
|
||||||
|
// - -1: desending
|
||||||
|
// - 0: unsorted
|
||||||
|
sort(key, option) {
|
||||||
|
if (option === 0) {
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mangaTitle = data.title;
|
|
||||||
$('#title-text').text(data.title);
|
|
||||||
buildTable(data.chapters);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTable = (chapters) => {
|
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||||
$('#table').attr('hidden', '');
|
const comp = this.compare(a[key], b[key]);
|
||||||
$('table').empty();
|
return option < 0 ? comp * -1 : comp;
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
$('#selectable').selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
|
||||||
$('#table').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
|
||||||
id: $(e).attr('data-id'),
|
|
||||||
title: $(e).attr('data-title')
|
|
||||||
}
|
|
||||||
}).get();
|
|
||||||
console.log(chapters);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/plugin/download',
|
|
||||||
data: JSON.stringify({
|
|
||||||
plugin: pid,
|
|
||||||
chapters: chapters,
|
|
||||||
title: mangaTitle
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-1
@@ -10,6 +10,7 @@ const readerComponent = () => {
|
|||||||
longPages: false,
|
longPages: false,
|
||||||
lastSavedPage: page,
|
lastSavedPage: page,
|
||||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
|
margin: 30,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@@ -27,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;`
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,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}`;
|
||||||
@@ -277,6 +282,11 @@ const readerComponent = () => {
|
|||||||
entryChanged() {
|
entryChanged() {
|
||||||
const id = $('#entry-select').val();
|
const id = $('#entry-select').val();
|
||||||
this.redirect(`${base_url}reader/${tid}/${id}`);
|
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}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+4
-4
@@ -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
|
||||||
@@ -54,7 +54,7 @@ shards:
|
|||||||
|
|
||||||
mangadex:
|
mangadex:
|
||||||
git: https://github.com/hkalexling/mangadex.git
|
git: https://github.com/hkalexling/mangadex.git
|
||||||
version: 0.8.0+git.commit.24e6fb51afd043721139355854e305b43bf98c43
|
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.21.0
|
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
|
||||||
|
|||||||
@@ -20,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 = ""
|
||||||
|
|||||||
+14
-1
@@ -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
|
||||||
|
|||||||
+25
-10
@@ -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,7 +129,7 @@ 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|
|
||||||
@@ -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.21.0"
|
MANGO_VERSION = "0.22.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
|
|||||||
+140
-18
@@ -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,23 +193,67 @@ 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
|
||||||
check_fields ["title", "chapters"]
|
if info.version > 1
|
||||||
|
# Since v2, listChapters returns an array
|
||||||
ary = json["chapters"].as_a
|
json.as_a.each do |obj|
|
||||||
ary.each do |obj|
|
assert_chapter_type obj
|
||||||
id = obj["id"]?
|
|
||||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
||||||
|
|
||||||
unless id.to_s.alphanumeric_underscore?
|
|
||||||
raise "The `id` field can only contain alphanumeric characters " \
|
|
||||||
"and underscores"
|
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
check_fields ["title", "chapters"]
|
||||||
|
|
||||||
title = obj["title"]?
|
ary = json["chapters"].as_a
|
||||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
ary.each do |obj|
|
||||||
|
id = obj["id"]?
|
||||||
|
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||||
|
|
||||||
|
unless id.to_s.alphanumeric_underscore?
|
||||||
|
raise "The `id` field can only contain alphanumeric characters " \
|
||||||
|
"and underscores"
|
||||||
|
end
|
||||||
|
|
||||||
|
title = obj["title"]?
|
||||||
|
if title.nil?
|
||||||
|
raise "Field `title` missing from `listChapters` outputs"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -179,10 +264,14 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
check_fields ["title", "pages"]
|
if info.version > 1
|
||||||
|
assert_chapter_type json
|
||||||
|
else
|
||||||
|
check_fields ["title", "pages"]
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
if json["title"].to_s.empty?
|
||||||
raise "The `title` field of the chapter can not be empty"
|
raise "The `title` field of the chapter can not be empty"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+134
-14
@@ -126,8 +126,11 @@ 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.query "slim"
|
||||||
Koa.response 200, schema: "title"
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
@@ -137,7 +140,11 @@ struct APIRouter
|
|||||||
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?
|
||||||
|
|
||||||
send_json env, title.to_json
|
if env.params.query["slim"]?
|
||||||
|
send_json env, title.to_slim_json
|
||||||
|
else
|
||||||
|
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
|
||||||
@@ -145,14 +152,21 @@ 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
|
||||||
|
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: {
|
Koa.response 200, schema: {
|
||||||
"dir" => String,
|
"dir" => String,
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
}
|
}
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
send_json env, Library.default.to_json
|
if env.params.query["slim"]?
|
||||||
|
send_json env, Library.default.to_slim_json
|
||||||
|
else
|
||||||
|
send_json env, Library.default.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@@ -339,7 +353,7 @@ struct APIRouter
|
|||||||
}
|
}
|
||||||
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,
|
||||||
@@ -366,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
|
||||||
@@ -390,13 +404,13 @@ struct APIRouter
|
|||||||
}
|
}
|
||||||
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,
|
||||||
@@ -444,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,
|
||||||
@@ -516,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,
|
||||||
@@ -531,8 +638,8 @@ struct APIRouter
|
|||||||
"success" => Bool,
|
"success" => Bool,
|
||||||
"error" => String?,
|
"error" => String?,
|
||||||
"chapters?" => [{
|
"chapters?" => [{
|
||||||
"id" => String,
|
"id" => String,
|
||||||
"title" => String,
|
"title?" => String,
|
||||||
}],
|
}],
|
||||||
"title" => String?,
|
"title" => String?,
|
||||||
}
|
}
|
||||||
@@ -542,8 +649,14 @@ struct APIRouter
|
|||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
json = plugin.list_chapters query
|
||||||
chapters = json["chapters"]
|
|
||||||
title = json["title"]
|
if plugin.info.version == 1
|
||||||
|
chapters = json["chapters"]
|
||||||
|
title = json["title"]
|
||||||
|
else
|
||||||
|
chapters = json
|
||||||
|
title = nil
|
||||||
|
end
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@@ -551,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,
|
||||||
@@ -594,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,
|
||||||
@@ -612,7 +727,6 @@ struct APIRouter
|
|||||||
"width" => Int32,
|
"width" => Int32,
|
||||||
"height" => Int32,
|
"height" => Int32,
|
||||||
}],
|
}],
|
||||||
"margin" => Int32?,
|
|
||||||
}
|
}
|
||||||
get "/api/dimensions/:tid/:eid" do |env|
|
get "/api/dimensions/:tid/:eid" do |env|
|
||||||
begin
|
begin
|
||||||
@@ -628,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,
|
||||||
@@ -770,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,
|
||||||
@@ -796,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,
|
||||||
@@ -814,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,
|
||||||
@@ -832,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,
|
||||||
@@ -853,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,
|
||||||
@@ -874,6 +993,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,
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -5,61 +5,63 @@
|
|||||||
<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">
|
||||||
<thead>
|
<table class="uk-table uk-table-striped">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Chapter</th>
|
<tr>
|
||||||
<th>Manga</th>
|
<th>Chapter</th>
|
||||||
<th>Progress</th>
|
<th>Manga</th>
|
||||||
<th>Time</th>
|
<th>Progress</th>
|
||||||
<th>Status</th>
|
<th>Time</th>
|
||||||
<th>Plugin</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Plugin</th>
|
||||||
</tr>
|
<th>Actions</th>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="job in jobs" :key="job">
|
|
||||||
<tr :id="`chapter-${job.id}`">
|
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
|
||||||
<td x-text="job.title"></td>
|
|
||||||
</template>
|
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
|
||||||
<td x-text="job.manga_title"></td>
|
|
||||||
</template>
|
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
|
||||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
|
||||||
<template x-if="job.status_message.length > 0">
|
|
||||||
<div class="uk-inline">
|
|
||||||
<span uk-icon="info"></span>
|
|
||||||
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
|
||||||
<template x-if="job.status_message.length > 0">
|
|
||||||
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<template x-for="job in jobs" :key="job">
|
||||||
|
<tr :id="`chapter-${job.id}`">
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="job.plugin_id">
|
||||||
|
<td x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!job.plugin_id">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
|
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span :class="statusClass(job.status)" x-text="job.status"></span>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<div class="uk-inline">
|
||||||
|
<span uk-icon="info"></span>
|
||||||
|
<div uk-dropdown x-text="job.status_message" style="white-space: pre-line;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||||
|
<template x-if="job.status_message.length > 0">
|
||||||
|
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
|||||||
@@ -3,34 +3,36 @@
|
|||||||
<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">
|
||||||
<thead>
|
<table class="uk-table uk-table-striped">
|
||||||
<tr>
|
<thead>
|
||||||
<th>Type</th>
|
<tr>
|
||||||
<th>Relative Path</th>
|
<th>Type</th>
|
||||||
<th>ID</th>
|
<th>Relative Path</th>
|
||||||
<th>Actions</th>
|
<th>ID</th>
|
||||||
</tr>
|
<th>Actions</th>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="title in titles" :key="title">
|
|
||||||
<tr :id="`title-${title.id}`">
|
|
||||||
<td>Title</td>
|
|
||||||
<td x-text="title.path"></td>
|
|
||||||
<td x-text="title.id"></td>
|
|
||||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</thead>
|
||||||
<template x-for="entry in entries" :key="entry">
|
<tbody>
|
||||||
<tr :id="`entry-${entry.id}`">
|
<template x-for="title in titles" :key="title">
|
||||||
<td>Entry</td>
|
<tr :id="`title-${title.id}`">
|
||||||
<td x-text="entry.path"></td>
|
<td>Title</td>
|
||||||
<td x-text="entry.id"></td>
|
<td x-text="title.path"></td>
|
||||||
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
<td x-text="title.id"></td>
|
||||||
</tr>
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
</template>
|
</tr>
|
||||||
</tbody>
|
</template>
|
||||||
</table>
|
<template x-for="entry in entries" :key="entry">
|
||||||
|
<tr :id="`entry-${entry.id}`">
|
||||||
|
<td>Entry</td>
|
||||||
|
<td x-text="entry.path"></td>
|
||||||
|
<td x-text="entry.id"></td>
|
||||||
|
<td><a @click="rm($event)" uk-icon="trash"></a></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,180 @@
|
|||||||
<% 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;">
|
||||||
<h2>No Plugins Found</h2>
|
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
<h2>No Plugins Found</h2>
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||||
</div>
|
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||||
|
|
||||||
<% else %>
|
|
||||||
<h2 class=uk-title>Download with Plugins</h2>
|
|
||||||
|
|
||||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
|
||||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-expand">
|
|
||||||
<div class="uk-margin">
|
<div x-show="plugins.length > 0" style="width:100%">
|
||||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
<h2 class=uk-title>Download with Plugins
|
||||||
<div class="uk-form-controls">
|
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||||
<select id="plugin-select" class="uk-select">
|
</h2>
|
||||||
<% plugins.each do |p| %>
|
|
||||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
<template x-if="info !== undefined">
|
||||||
<% end %>
|
<div>
|
||||||
</select>
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<label class="uk-form-label"> </label>
|
||||||
|
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-expand">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">Choose a plugin</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||||
|
<template x-for="p in plugins" :key="p">
|
||||||
|
<option :value="p.id" x-text="p.title"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-auto">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label"> </label>
|
||||||
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
|
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
||||||
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
|
<dt x-text="entry[0]"></dt>
|
||||||
|
<dd x-text="entry[1]"></dd>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
<div class="uk-width-auto">
|
<template x-if="manga">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
<p x-show="manga.length > 0">
|
||||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
<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 x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
|
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
|
||||||
|
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
|
||||||
|
</div>
|
||||||
|
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
|
||||||
|
<template x-for="field in filters">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">
|
||||||
|
<span x-text="field.key"></span>
|
||||||
|
<template x-if="field.type === 'number'">
|
||||||
|
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
|
||||||
|
</template>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
|
||||||
|
|
||||||
|
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<template x-for="v in field.values" :key="v">
|
||||||
|
<option x-text="v" :value="v"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
|
||||||
|
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
|
||||||
|
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
|
||||||
|
|
||||||
|
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<template x-for="(k, idx) in chapterKeys" :key="k">
|
||||||
|
<th :id="`th-${idx}`" @click="thClicked($event)">
|
||||||
|
<span x-text="k"></span>
|
||||||
|
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
|
||||||
|
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
|
||||||
|
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="selectable">
|
||||||
|
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
|
||||||
|
<template x-for="ch in chapters" :key="ch">
|
||||||
|
<tr class="ui-widget-content" :id="ch.id">
|
||||||
|
<template x-for="k in chapterKeys" :key="k">
|
||||||
|
<td x-text="ch[k]"></td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<dl class="uk-description-list" id="toggle" hidden>
|
|
||||||
<% plugin.not_nil!.info.each do |k, v| %>
|
|
||||||
<dt><%= k %></dt>
|
|
||||||
<dd><%= v.to_s %></dd>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div id="table" class="uk-margin-large-top" hidden>
|
|
||||||
<h3 id="title-text"></h3>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
|
||||||
</div>
|
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto tablesorter">
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<% if plugin %>
|
|
||||||
<script>
|
|
||||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
|
||||||
</script>
|
|
||||||
<% end %>
|
|
||||||
<%= render_component "jquery-ui" %>
|
<%= render_component "jquery-ui" %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
<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>
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
</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">
|
||||||
@@ -90,6 +91,13 @@
|
|||||||
</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">
|
<hr class="uk-divider-icon">
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@@ -110,12 +118,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-footer uk-text-right">
|
<div class="uk-modal-footer uk-text-right">
|
||||||
<% if previous_entry_url %>
|
<% if previous_entry_url %>
|
||||||
<a class="uk-button uk-button-default uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= previous_entry_url %>">Previous Entry</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if next_entry_url %>
|
<% if next_entry_url %>
|
||||||
<a class="uk-button uk-button-default uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
<a class="uk-button uk-button-default uk-margin-small-bottom uk-margin-small-right" href="<%= next_entry_url %>">Next Entry</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
<a class="uk-button uk-button-danger" href="<%= exit_url %>">Exit Reader</a>
|
<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 %>
|
||||||
Reference in New Issue
Block a user