mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-24 00:03:14 -05:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586ee4f0ba | ||
|
|
53f3387e1a | ||
|
|
be5d1918aa | ||
|
|
df2cc0ffa9 | ||
|
|
b8cfc3a201 | ||
|
|
8dc60ac2ea | ||
|
|
1719335d02 | ||
|
|
0cd46abc66 | ||
|
|
e4fd7c58ee | ||
|
|
d4abee52db | ||
|
|
d29c94e898 | ||
|
|
1c19a91ee2 | ||
|
|
7eb5c253e9 | ||
|
|
22a660aabf | ||
|
|
6e9466c9d2 | ||
|
|
ab34fb260c | ||
|
|
0e9a659828 | ||
|
|
361d37d742 | ||
|
|
c6adb4ee18 | ||
|
|
8349fb68a4 | ||
|
|
0e1e4de528 | ||
|
|
b47788a85a | ||
|
|
f7004549b8 | ||
|
|
8d99400c5f | ||
|
|
ce59acae7a | ||
|
|
37c5911a23 | ||
|
|
8694b4beaf | ||
|
|
3b315ad880 | ||
|
|
33107670ce | ||
|
|
f116e2f1d0 | ||
|
|
ebf6221876 | ||
|
|
2a910335af | ||
|
|
9ea26474b4 | ||
|
|
df8a6ee6da | ||
|
|
70ea1711ce | ||
|
|
2773c1e67f | ||
|
|
dcfd1c8765 | ||
|
|
10b6047df8 | ||
|
|
8de735a2ca | ||
|
|
6c2350c9c7 | ||
|
|
a994c43857 | ||
|
|
7e4532fb14 | ||
|
|
d184d6fba5 | ||
|
|
92f5a90629 | ||
|
|
2a36804e8d | ||
|
|
87b6e79952 | ||
|
|
b75a838e14 | ||
|
|
ae7c72ab85 | ||
|
|
5cee68d76c | ||
|
|
f444496915 | ||
|
|
a812e3ed46 | ||
|
|
1be089b53e | ||
|
|
a7f4e161de | ||
|
|
ba31eb0071 | ||
|
|
192474c950 | ||
|
|
87b72fbd30 | ||
|
|
6acfa02314 | ||
|
|
bdba7bdd13 | ||
|
|
1b244c68b8 | ||
|
|
281f626e8c | ||
|
|
5be4f51d7e | ||
|
|
cd7782ba1e | ||
|
|
6d97bc083c | ||
|
|
ff4b1be9ae | ||
|
|
ba16c3db2f | ||
|
|
69b06a8352 | ||
|
|
687788767f | ||
|
|
94a1e63963 | ||
|
|
360913ee78 | ||
|
|
ea366f263a | ||
|
|
0d11cb59e9 | ||
|
|
2208f90d8e |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,4 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
open_collective: mango
|
||||
patreon: hkalexling
|
||||
ko_fi: hkalexling
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ mango
|
||||
.env
|
||||
*.md
|
||||
public/css/uikit.css
|
||||
public/img/*.svg
|
||||
public/js/*.min.js
|
||||
|
||||
4
Makefile
4
Makefile
@@ -7,6 +7,10 @@ uglify:
|
||||
yarn
|
||||
yarn uglify
|
||||
|
||||
setup: libs
|
||||
yarn
|
||||
yarn gulp dev
|
||||
|
||||
build: libs
|
||||
crystal build src/mango.cr --release --progress
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Mango is a self-hosted manga server and reader. Its features include
|
||||
- Supports nested folders in library
|
||||
- Automatically stores reading progress
|
||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||
|
||||
@@ -50,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango - Manga Server and Web Reader. Version 0.7.3
|
||||
Mango - Manga Server and Web Reader. Version 0.10.0
|
||||
|
||||
Usage:
|
||||
|
||||
|
||||
24
gulpfile.js
24
gulpfile.js
@@ -3,10 +3,16 @@ const minify = require("gulp-babel-minify");
|
||||
const minifyCss = require('gulp-minify-css');
|
||||
const less = require('gulp-less');
|
||||
|
||||
gulp.task('copy-uikit-js', () => {
|
||||
return gulp.src('node_modules/uikit/dist/js/*.min.js')
|
||||
.pipe(gulp.dest('public/js'));
|
||||
});
|
||||
|
||||
gulp.task('minify-js', () => {
|
||||
return gulp.src('public/js/*.js')
|
||||
.pipe(minify({
|
||||
removeConsole: true
|
||||
removeConsole: true,
|
||||
builtIns: false
|
||||
}))
|
||||
.pipe(gulp.dest('dist/js'));
|
||||
});
|
||||
@@ -23,6 +29,11 @@ gulp.task('minify-css', () => {
|
||||
.pipe(gulp.dest('dist/css'));
|
||||
});
|
||||
|
||||
gulp.task('copy-uikit-icons', () => {
|
||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||
.pipe(gulp.dest('public/img'));
|
||||
});
|
||||
|
||||
gulp.task('img', () => {
|
||||
return gulp.src('public/img/*')
|
||||
.pipe(gulp.dest('dist/img'));
|
||||
@@ -33,4 +44,13 @@ gulp.task('copy-files', () => {
|
||||
.pipe(gulp.dest('dist'));
|
||||
});
|
||||
|
||||
gulp.task('default', gulp.parallel('minify-js', gulp.series('less', 'minify-css'), 'img', 'copy-files'));
|
||||
gulp.task('default', gulp.parallel(
|
||||
gulp.series('copy-uikit-js', 'minify-js'),
|
||||
gulp.series('less', 'minify-css'),
|
||||
gulp.series('copy-uikit-icons', 'img'),
|
||||
'copy-files'
|
||||
));
|
||||
|
||||
gulp.task('dev', gulp.parallel(
|
||||
'copy-uikit-js', 'less', 'copy-uikit-icons'
|
||||
));
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
}
|
||||
|
||||
.uk-card-media-top {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
@@ -30,18 +31,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uk-list li {
|
||||
.uk-list li:not(.nopointer) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reader-bg {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#scan-status {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.reader-bg {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.break-word {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -63,11 +64,11 @@
|
||||
background: #F4E487;
|
||||
}
|
||||
|
||||
#selectable .ui-selecting.dark {
|
||||
.uk-light #selectable .ui-selecting {
|
||||
background: #5E5731;
|
||||
}
|
||||
|
||||
#selectable .ui-selected.dark {
|
||||
.uk-light #selectable .ui-selected {
|
||||
background: #9D9252;
|
||||
}
|
||||
|
||||
@@ -97,3 +98,40 @@ td>.uk-dropdown {
|
||||
.item .uk-card-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.grayscale {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.uk-light .uk-navbar-dropdown,
|
||||
.uk-light .uk-modal-header,
|
||||
.uk-light .uk-modal-body,
|
||||
.uk-light .uk-modal-footer {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.uk-light .uk-dropdown {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.uk-light .uk-navbar-dropdown,
|
||||
.uk-light .uk-dropdown {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.uk-light .uk-nav-header,
|
||||
.uk-light .uk-description-list>dt {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#select-bar-controls a {
|
||||
transform: scale(1.5, 1.5);
|
||||
}
|
||||
|
||||
#select-bar-controls a:hover {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,15 @@
|
||||
background-color: @global-secondary-background;
|
||||
color: @global-inverse-color;
|
||||
}
|
||||
|
||||
@internal-divider-icon-image: "../img/divider-icon.svg";
|
||||
@internal-form-select-image: "../img/form-select.svg";
|
||||
@internal-form-datalist-image: "../img/form-datalist.svg";
|
||||
@internal-form-radio-image: "../img/form-radio.svg";
|
||||
@internal-form-checkbox-image: "../img/form-checkbox.svg";
|
||||
@internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg";
|
||||
@internal-nav-parent-close-image: "../img/nav-parent-close.svg";
|
||||
@internal-nav-parent-open-image: "../img/nav-parent-open.svg";
|
||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||
|
||||
BIN
public/img/loading.gif
Normal file
BIN
public/img/loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
@@ -1,13 +1,14 @@
|
||||
var scanning = false;
|
||||
function scan() {
|
||||
let scanning = false;
|
||||
|
||||
const scan = () => {
|
||||
scanning = true;
|
||||
$('#scan-status > div').removeAttr('hidden');
|
||||
$('#scan-status > span').attr('hidden', '');
|
||||
var color = $('#scan').css('color');
|
||||
const color = $('#scan').css('color');
|
||||
$('#scan').css('color', 'gray');
|
||||
$.post(base_url + 'api/admin/scan', function (data) {
|
||||
var ms = data.milliseconds;
|
||||
var titles = data.titles;
|
||||
$.post(base_url + 'api/admin/scan', (data) => {
|
||||
const ms = data.milliseconds;
|
||||
const titles = data.titles;
|
||||
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
||||
$('#scan-status > span').removeAttr('hidden');
|
||||
$('#scan').css('color', color);
|
||||
@@ -15,11 +16,25 @@ function scan() {
|
||||
scanning = false;
|
||||
});
|
||||
}
|
||||
$(function() {
|
||||
$('li').click(function() {
|
||||
url = $(this).attr('data-url');
|
||||
|
||||
String.prototype.capitalize = function() {
|
||||
return this.charAt(0).toUpperCase() + this.slice(1);
|
||||
}
|
||||
|
||||
$(() => {
|
||||
$('li').click((e) => {
|
||||
const url = $(e.currentTarget).attr('data-url');
|
||||
if (url) {
|
||||
$(location).attr('href', url);
|
||||
}
|
||||
});
|
||||
|
||||
const setting = loadThemeSetting();
|
||||
$('#theme-select').val(setting.capitalize());
|
||||
|
||||
$('#theme-select').change((e) => {
|
||||
const newSetting = $(e.currentTarget).val().toLowerCase();
|
||||
saveThemeSetting(newSetting);
|
||||
setTheme();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,11 +119,12 @@ const load = () => {
|
||||
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
|
||||
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
|
||||
return `<tr id="chapter-${obj.id}">
|
||||
<td><a href="${baseURL}/chapter/${obj.id}">${obj.title}</a></td>
|
||||
<td><a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a></td>
|
||||
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
|
||||
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
|
||||
<td>${obj.success_count}/${obj.pages}</td>
|
||||
<td>${moment(obj.time).fromNow()}</td>
|
||||
<td>${statusSpan} ${dropdown}</td>
|
||||
<td>${obj.plugin_id || ""}</td>
|
||||
<td>
|
||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
||||
${retryBtn}
|
||||
|
||||
@@ -32,42 +32,41 @@ const download = () => {
|
||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||
console.log(ids);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/mangadex/download',
|
||||
data: JSON.stringify({chapters: chapters}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
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';
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/mangadex/download',
|
||||
data: JSON.stringify({
|
||||
chapters: chapters
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
const successCount = parseInt(data.success);
|
||||
const failCount = parseInt(data.fail);
|
||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
||||
window.location.href = base_url + 'admin/downloads';
|
||||
});
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
});
|
||||
styleModal();
|
||||
})
|
||||
.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');
|
||||
});
|
||||
});
|
||||
styleModal();
|
||||
};
|
||||
const toggleSpinner = () => {
|
||||
var attr = $('#spinner').attr('hidden');
|
||||
if (attr) {
|
||||
$('#spinner').removeAttr('hidden');
|
||||
$('#search-btn').attr('hidden', '');
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
$('#search-btn').removeAttr('hidden');
|
||||
$('#spinner').attr('hidden', '');
|
||||
}
|
||||
@@ -98,8 +97,7 @@ const search = () => {
|
||||
const path = new URL(input).pathname;
|
||||
const match = /\/title\/([0-9]+)/.exec(path);
|
||||
int_id = parseInt(match[1]);
|
||||
}
|
||||
catch(e) {
|
||||
} catch (e) {
|
||||
int_id = parseInt(input);
|
||||
}
|
||||
|
||||
@@ -139,8 +137,12 @@ const search = () => {
|
||||
const comp = (a, b) => {
|
||||
var ai;
|
||||
var bi;
|
||||
try {ai = parseFloat(a);} catch(e) {}
|
||||
try {bi = parseFloat(b);} catch(e) {}
|
||||
try {
|
||||
ai = parseFloat(a);
|
||||
} catch (e) {}
|
||||
try {
|
||||
bi = parseFloat(b);
|
||||
} catch (e) {}
|
||||
if (typeof ai === 'undefined') return -1;
|
||||
if (typeof bi === 'undefined') return 1;
|
||||
if (ai < bi) return 1;
|
||||
@@ -176,8 +178,7 @@ const parseRange = str => {
|
||||
if (!matches) {
|
||||
alert('danger', `Failed to parse filter input ${str}`);
|
||||
return [null, null];
|
||||
}
|
||||
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||
// e.g., <= 30
|
||||
num = parseInt(matches[2]);
|
||||
if (isNaN(num)) {
|
||||
@@ -194,8 +195,7 @@ const parseRange = str => {
|
||||
case '>=':
|
||||
return [num, null];
|
||||
}
|
||||
}
|
||||
else if (typeof matches[3] !== 'undefined') {
|
||||
} else if (typeof matches[3] !== 'undefined') {
|
||||
// a single number
|
||||
num = parseInt(matches[3]);
|
||||
if (isNaN(num)) {
|
||||
@@ -203,8 +203,7 @@ const parseRange = str => {
|
||||
return [null, null];
|
||||
}
|
||||
return [num, num];
|
||||
}
|
||||
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||
// e.g., 10 - 23
|
||||
num = parseInt(matches[4]);
|
||||
const n2 = parseInt(matches[5]);
|
||||
@@ -213,8 +212,7 @@ const parseRange = str => {
|
||||
return [null, null];
|
||||
}
|
||||
return [num, n2];
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// empty or space only
|
||||
return [null, null];
|
||||
}
|
||||
@@ -280,8 +278,7 @@ const buildTable = () => {
|
||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||
}).join(' | ');
|
||||
const dark = getTheme() === 'dark' ? 'dark' : '';
|
||||
return `<tr class="ui-widget-content ${dark}">
|
||||
return `<tr class="ui-widget-content">
|
||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||
<td>${chp.title}</td>
|
||||
<td>${chp.language}</td>
|
||||
@@ -302,7 +299,7 @@ const buildTable = () => {
|
||||
};
|
||||
|
||||
const unescapeHTML = (str) => {
|
||||
var elt = document.createElement("span");
|
||||
elt.innerHTML = str;
|
||||
return elt.innerText;
|
||||
var elt = document.createElement("span");
|
||||
elt.innerHTML = str;
|
||||
return elt.innerText;
|
||||
};
|
||||
|
||||
142
public/js/plugin-download.js
Normal file
142
public/js/plugin-download.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const loadPlugin = id => {
|
||||
localStorage.setItem('plugin', id);
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||
const newURL = `${url}?${$.param({
|
||||
plugin: id
|
||||
})}`;
|
||||
window.location.href = newURL;
|
||||
};
|
||||
|
||||
$(() => {
|
||||
var storedID = localStorage.getItem('plugin');
|
||||
if (storedID && storedID !== pid) {
|
||||
loadPlugin(storedID);
|
||||
} else {
|
||||
$('#controls').removeAttr('hidden');
|
||||
}
|
||||
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('#plugin-select').val(pid);
|
||||
$('#plugin-select').change(() => {
|
||||
const id = $('#plugin-select').val();
|
||||
loadPlugin(id);
|
||||
});
|
||||
});
|
||||
|
||||
let mangaTitle = "";
|
||||
let searching = false;
|
||||
const search = () => {
|
||||
if (searching)
|
||||
return;
|
||||
|
||||
const query = $('#search-input').val();
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/plugin/list',
|
||||
data: JSON.stringify({
|
||||
query: query,
|
||||
plugin: pid
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Search failed. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
mangaTitle = data.title;
|
||||
$('#title-text').text(data.title);
|
||||
buildTable(data.chapters);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {});
|
||||
};
|
||||
|
||||
const buildTable = (chapters) => {
|
||||
$('#table').attr('hidden', '');
|
||||
$('table').empty();
|
||||
|
||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||
$('table').append(thead);
|
||||
|
||||
const rows = chapters.map(ch => {
|
||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||
});
|
||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||
$('table').append(tbody);
|
||||
|
||||
$('#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');
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -3,18 +3,18 @@ $(function() {
|
||||
var controller = new ScrollMagic.Controller();
|
||||
|
||||
// replace history on scroll
|
||||
$('img').each(function(idx){
|
||||
$('img').each(function(idx) {
|
||||
var scene = new ScrollMagic.Scene({
|
||||
triggerElement: $(this).get(),
|
||||
triggerHook: 'onEnter',
|
||||
reverse: true
|
||||
})
|
||||
triggerElement: $(this).get(),
|
||||
triggerHook: 'onEnter',
|
||||
reverse: true
|
||||
})
|
||||
.addTo(controller)
|
||||
.on('enter', function(event){
|
||||
.on('enter', function(event) {
|
||||
current = $(event.target.triggerElement()).attr('id');
|
||||
replaceHistory(current);
|
||||
})
|
||||
.on('leave', function(event){
|
||||
.on('leave', function(event) {
|
||||
var prev = $(event.target.triggerElement()).prev();
|
||||
current = $(prev).attr('id');
|
||||
replaceHistory(current);
|
||||
@@ -23,12 +23,12 @@ $(function() {
|
||||
|
||||
// poor man's infinite scroll
|
||||
var scene = new ScrollMagic.Scene({
|
||||
triggerElement: $('.next-url').get(),
|
||||
triggerHook: 'onEnter',
|
||||
offset: -500
|
||||
})
|
||||
triggerElement: $('.next-url').get(),
|
||||
triggerHook: 'onEnter',
|
||||
offset: -500
|
||||
})
|
||||
.addTo(controller)
|
||||
.on('enter', function(){
|
||||
.on('enter', function() {
|
||||
var nextURL = $('.next-url').attr('href');
|
||||
$('.next-url').remove();
|
||||
if (!nextURL) {
|
||||
@@ -39,7 +39,7 @@ $(function() {
|
||||
$('#next-btn').removeAttr('hidden');
|
||||
return;
|
||||
}
|
||||
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr){
|
||||
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
|
||||
if (status === 'error') console.log(xhr.statusText);
|
||||
if (status === 'success') {
|
||||
console.log(nextURL + ' loaded');
|
||||
@@ -54,17 +54,18 @@ $(function() {
|
||||
|
||||
bind();
|
||||
});
|
||||
$('#page-select').change(function(){
|
||||
$('#page-select').change(function() {
|
||||
jumpTo(parseInt($('#page-select').val()));
|
||||
});
|
||||
|
||||
function showControl(idx) {
|
||||
const pageCount = $('#page-select > option').length;
|
||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
||||
$('#progress-label').text(progressText);
|
||||
$('#page-select').val(idx);
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
styleModal();
|
||||
}
|
||||
|
||||
function jumpTo(page) {
|
||||
var ary = window.location.pathname.split('/');
|
||||
ary[ary.length - 1] = page;
|
||||
@@ -72,10 +73,12 @@ function jumpTo(page) {
|
||||
ary.unshift(window.location.origin);
|
||||
window.location.replace(ary.join('/'));
|
||||
}
|
||||
|
||||
function replaceHistory(url) {
|
||||
history.replaceState(null, "", url);
|
||||
console.log('reading ' + url);
|
||||
}
|
||||
|
||||
function redirect(url) {
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
@@ -1,131 +1,15 @@
|
||||
$(() => {
|
||||
const titleID = $('.data').attr('data-title-id') || 'library';
|
||||
|
||||
const sortItems = () => {
|
||||
$('#sort-select').change(() => {
|
||||
const sort = $('#sort-select').find(':selected').attr('id');
|
||||
localStorage.setItem(`sort-${titleID}`, sort);
|
||||
|
||||
const ary = sort.split('-');
|
||||
const by = ary[0];
|
||||
const dir = ary[1];
|
||||
|
||||
let items = $('.item');
|
||||
items.remove();
|
||||
|
||||
const ctxAry = [];
|
||||
const keyRange = {};
|
||||
if (by === 'auto') {
|
||||
// intelligent sorting
|
||||
items.each((i, item) => {
|
||||
const name = $(item).find('.uk-card-title').text();
|
||||
const regex = /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/g;
|
||||
|
||||
const numbers = {};
|
||||
let match = regex.exec(name);
|
||||
while (match) {
|
||||
const key = match[1];
|
||||
const num = parseFloat(match[2]);
|
||||
numbers[key] = num;
|
||||
|
||||
if (!keyRange[key]) {
|
||||
keyRange[key] = [num, num, 1];
|
||||
} else {
|
||||
keyRange[key][2] += 1;
|
||||
if (num < keyRange[key][0]) {
|
||||
keyRange[key][0] = num;
|
||||
} else if (num > keyRange[key][1]) {
|
||||
keyRange[key][1] = num;
|
||||
}
|
||||
}
|
||||
|
||||
match = regex.exec(name);
|
||||
}
|
||||
ctxAry.push({
|
||||
index: i,
|
||||
numbers: numbers
|
||||
});
|
||||
});
|
||||
|
||||
console.log(keyRange);
|
||||
|
||||
const sortedKeys = Object.keys(keyRange).filter(k => {
|
||||
return keyRange[k][2] >= items.length / 2;
|
||||
});
|
||||
|
||||
sortedKeys.sort((a, b) => {
|
||||
// sort by frequency of the key first
|
||||
if (keyRange[a][2] !== keyRange[b][2]) {
|
||||
return (keyRange[a][2] < keyRange[b][2]) ? 1 : -1;
|
||||
}
|
||||
// then sort by range of the key
|
||||
return ((keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0])) ? 1 : -1;
|
||||
});
|
||||
|
||||
console.log(sortedKeys);
|
||||
|
||||
ctxAry.sort((a, b) => {
|
||||
for (let i = 0; i < sortedKeys.length; i++) {
|
||||
const key = sortedKeys[i];
|
||||
|
||||
if (a.numbers[key] === undefined && b.numbers[key] === undefined)
|
||||
continue;
|
||||
if (a.numbers[key] === undefined)
|
||||
return 1;
|
||||
if (b.numbers[key] === undefined)
|
||||
return -1;
|
||||
if (a.numbers[key] === b.numbers[key])
|
||||
continue;
|
||||
return (a.numbers[key] > b.numbers[key]) ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sortedItems = [];
|
||||
ctxAry.forEach(ctx => {
|
||||
sortedItems.push(items[ctx.index]);
|
||||
});
|
||||
items = sortedItems;
|
||||
|
||||
if (dir === 'down') {
|
||||
items.reverse();
|
||||
}
|
||||
} else {
|
||||
items.sort((a, b) => {
|
||||
var res;
|
||||
if (by === 'name')
|
||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
||||
else if (by === 'date')
|
||||
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
|
||||
else if (by === 'progress') {
|
||||
const ap = parseFloat($(a).attr('data-progress'));
|
||||
const bp = parseFloat($(b).attr('data-progress'));
|
||||
if (ap === bp)
|
||||
// if progress is the same, we compare by name
|
||||
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
|
||||
else
|
||||
res = ap > bp;
|
||||
}
|
||||
if (dir === 'up')
|
||||
return res ? 1 : -1;
|
||||
else
|
||||
return !res ? 1 : -1;
|
||||
});
|
||||
}
|
||||
$('#item-container').append(items);
|
||||
setupAcard();
|
||||
};
|
||||
|
||||
$('#sort-select').change(() => {
|
||||
sortItems();
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||
const newURL = `${url}?${$.param({
|
||||
sort: by,
|
||||
ascend: dir === 'up' ? 1 : 0
|
||||
})}`;
|
||||
window.location.href = newURL;
|
||||
});
|
||||
|
||||
const sortID = localStorage.getItem(`sort-${titleID}`);
|
||||
if (sortID)
|
||||
$(`option#${sortID}`).attr('selected', '');
|
||||
else if ($('option#auto-up').length > 0)
|
||||
$('option#auto-up').attr('selected', '');
|
||||
else
|
||||
$('option#name-up').attr('selected', '');
|
||||
|
||||
sortItems();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
const getTheme = () => {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (!theme) theme = 'light';
|
||||
return theme;
|
||||
// https://flaviocopes.com/javascript-detect-dark-mode/
|
||||
const preferDarkMode = () => {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const saveTheme = theme => {
|
||||
localStorage.setItem('theme', theme);
|
||||
const validThemeSetting = (theme) => {
|
||||
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||
};
|
||||
|
||||
// dark / light / system
|
||||
const loadThemeSetting = () => {
|
||||
let str = localStorage.getItem('theme');
|
||||
if (!str || !validThemeSetting(str)) str = 'light';
|
||||
return str;
|
||||
};
|
||||
|
||||
// dark / light
|
||||
const loadTheme = () => {
|
||||
let setting = loadThemeSetting();
|
||||
if (setting === 'system') {
|
||||
setting = preferDarkMode() ? 'dark' : 'light';
|
||||
}
|
||||
return setting;
|
||||
};
|
||||
|
||||
const saveThemeSetting = setting => {
|
||||
if (!validThemeSetting(setting)) setting = 'light';
|
||||
localStorage.setItem('theme', setting);
|
||||
};
|
||||
|
||||
// when toggled, Auto will be changed to light or dark
|
||||
const toggleTheme = () => {
|
||||
const theme = getTheme();
|
||||
const theme = loadTheme();
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
saveThemeSetting(newTheme);
|
||||
setTheme(newTheme);
|
||||
saveTheme(newTheme);
|
||||
};
|
||||
|
||||
const setTheme = themeStr => {
|
||||
if (themeStr === 'dark') {
|
||||
const setTheme = (theme) => {
|
||||
if (!theme) theme = loadTheme();
|
||||
if (theme === 'dark') {
|
||||
$('html').css('background', 'rgb(20, 20, 20)');
|
||||
$('body').addClass('uk-light');
|
||||
$('.uk-card').addClass('uk-card-secondary');
|
||||
@@ -31,18 +53,20 @@ const setTheme = themeStr => {
|
||||
}
|
||||
};
|
||||
|
||||
const styleModal = () => {
|
||||
const color = getTheme() === 'dark' ? '#222' : '';
|
||||
$('.uk-modal-header').css('background', color);
|
||||
$('.uk-modal-body').css('background', color);
|
||||
$('.uk-modal-footer').css('background', color);
|
||||
};
|
||||
|
||||
// do it before document is ready to prevent the initial flash of white on
|
||||
// most pages
|
||||
setTheme(getTheme());
|
||||
setTheme();
|
||||
|
||||
$(() => {
|
||||
// hack for the reader page
|
||||
setTheme(getTheme());
|
||||
setTheme();
|
||||
|
||||
// on system dark mode setting change
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', event => {
|
||||
if (loadThemeSetting() === 'system')
|
||||
setTheme(event.matches ? 'dark' : 'light');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,7 +58,6 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
||||
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
|
||||
|
||||
UIkit.modal($('#modal')).show();
|
||||
styleModal();
|
||||
}
|
||||
|
||||
const updateProgress = (tid, eid, page) => {
|
||||
@@ -144,7 +143,6 @@ const edit = (eid) => {
|
||||
setupUpload(eid);
|
||||
|
||||
UIkit.modal($('#edit-modal')).show();
|
||||
styleModal();
|
||||
};
|
||||
|
||||
const setupUpload = (eid) => {
|
||||
@@ -184,3 +182,63 @@ const setupUpload = (eid) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
data['selected'] = false;
|
||||
});
|
||||
$('#select-bar')[0].__x.$data['count'] = 0;
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
let count = 0;
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
if (!data['disabled']) {
|
||||
data['selected'] = true;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
$('#select-bar')[0].__x.$data['count'] = count;
|
||||
};
|
||||
|
||||
const selectedIDs = () => {
|
||||
const ary = [];
|
||||
$('.item .uk-card').each((i, e) => {
|
||||
const data = e.__x.$data;
|
||||
if (!data['disabled'] && data['selected']) {
|
||||
const item = $(e).closest('.item');
|
||||
ary.push($(item).attr('id'));
|
||||
}
|
||||
});
|
||||
return ary;
|
||||
};
|
||||
|
||||
const bulkProgress = (action, el) => {
|
||||
const tid = $(el).attr('data-id');
|
||||
const ids = selectedIDs();
|
||||
const url = `${base_url}api/bulk-progress/${action}/${tid}`;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
contentType: "application/json",
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({
|
||||
ids: ids
|
||||
})
|
||||
})
|
||||
.done(data => {
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {
|
||||
deselectAll();
|
||||
});
|
||||
};
|
||||
|
||||
10
shard.lock
10
shard.lock
@@ -6,7 +6,7 @@ shards:
|
||||
|
||||
archive:
|
||||
github: hkalexling/archive.cr
|
||||
version: 0.3.0
|
||||
version: 0.4.0
|
||||
|
||||
baked_file_system:
|
||||
github: schovi/baked_file_system
|
||||
@@ -20,6 +20,10 @@ shards:
|
||||
github: crystal-lang/crystal-db
|
||||
version: 0.9.0
|
||||
|
||||
duktape:
|
||||
github: jessedoyle/duktape.cr
|
||||
version: 0.20.0
|
||||
|
||||
exception_page:
|
||||
github: crystal-loot/exception_page
|
||||
version: 0.1.4
|
||||
@@ -36,6 +40,10 @@ shards:
|
||||
github: jeromegn/kilt
|
||||
version: 0.4.0
|
||||
|
||||
myhtml:
|
||||
github: kostya/myhtml
|
||||
version: 1.5.1
|
||||
|
||||
radix:
|
||||
github: luislavena/radix
|
||||
version: 0.3.9
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.7.3
|
||||
version: 0.10.0
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@@ -27,3 +27,8 @@ dependencies:
|
||||
github: crystal-ameba/ameba
|
||||
clim:
|
||||
github: at-grandpa/clim
|
||||
duktape:
|
||||
github: jessedoyle/duktape.cr
|
||||
version: ~> 0.20.0
|
||||
myhtml:
|
||||
github: kostya/myhtml
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
require "./spec_helper"
|
||||
|
||||
include MangaDex
|
||||
|
||||
describe Queue do
|
||||
it "creates DB at given path" do
|
||||
with_queue do |_, path|
|
||||
File.exists?(path).should be_true
|
||||
end
|
||||
end
|
||||
|
||||
it "pops nil when empty" do
|
||||
with_queue do |queue|
|
||||
queue.pop.should be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "inserts multiple jobs" do
|
||||
with_queue do |queue|
|
||||
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
|
||||
Time.utc
|
||||
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
|
||||
Time.utc
|
||||
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
|
||||
Time.utc
|
||||
j4 = Job.new "4", "4", "title", "manga_title",
|
||||
JobStatus::Downloading, Time.utc
|
||||
count = queue.push [j1, j2, j3, j4]
|
||||
count.should eq 4
|
||||
end
|
||||
end
|
||||
|
||||
it "pops pending job" do
|
||||
with_queue do |queue|
|
||||
job = queue.pop
|
||||
job.should_not be_nil
|
||||
job.not_nil!.id.should eq "3"
|
||||
end
|
||||
end
|
||||
|
||||
it "correctly counts jobs" do
|
||||
with_queue do |queue|
|
||||
queue.count.should eq 4
|
||||
end
|
||||
end
|
||||
|
||||
it "deletes job" do
|
||||
with_queue do |queue|
|
||||
queue.delete "4"
|
||||
queue.count.should eq 3
|
||||
end
|
||||
end
|
||||
|
||||
it "sets status" do
|
||||
with_queue do |queue|
|
||||
job = queue.pop.not_nil!
|
||||
queue.set_status JobStatus::Downloading, job
|
||||
job = queue.pop
|
||||
job.should_not be_nil
|
||||
job.not_nil!.status.should eq JobStatus::Downloading
|
||||
end
|
||||
end
|
||||
|
||||
it "sets number of pages" do
|
||||
with_queue do |queue|
|
||||
job = queue.pop.not_nil!
|
||||
queue.set_pages 100, job
|
||||
job = queue.pop
|
||||
job.should_not be_nil
|
||||
job.not_nil!.pages.should eq 100
|
||||
end
|
||||
end
|
||||
|
||||
it "adds fail/success counts" do
|
||||
with_queue do |queue|
|
||||
job = queue.pop.not_nil!
|
||||
queue.add_success job
|
||||
queue.add_success job
|
||||
queue.add_fail job
|
||||
job = queue.pop
|
||||
job.should_not be_nil
|
||||
job.not_nil!.success_count.should eq 2
|
||||
job.not_nil!.fail_count.should eq 1
|
||||
end
|
||||
end
|
||||
|
||||
it "appends status message" do
|
||||
with_queue do |queue|
|
||||
job = queue.pop.not_nil!
|
||||
queue.add_message "hello", job
|
||||
queue.add_message "world", job
|
||||
job = queue.pop
|
||||
job.should_not be_nil
|
||||
job.not_nil!.status_message.should eq "\nhello\nworld"
|
||||
end
|
||||
end
|
||||
|
||||
it "cleans up" do
|
||||
with_queue do
|
||||
true
|
||||
end
|
||||
State.reset
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,5 @@
|
||||
require "spec"
|
||||
require "../src/queue"
|
||||
require "../src/server"
|
||||
require "../src/config"
|
||||
|
||||
@@ -52,14 +53,3 @@ def with_storage
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_queue
|
||||
with_default_config do
|
||||
temp_queue_db = get_tempfile "mango-test-queue-db"
|
||||
queue = MangaDex::Queue.new temp_queue_db.path
|
||||
clear = yield queue, temp_queue_db.path
|
||||
if clear == true
|
||||
temp_queue_db.delete
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require "./spec_helper"
|
||||
|
||||
describe "compare_alphanumerically" do
|
||||
describe "compare_numerically" do
|
||||
it "sorts filenames with leading zeros correctly" do
|
||||
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||
ary.sort! { |a, b|
|
||||
compare_alphanumerically a, b
|
||||
compare_numerically a, b
|
||||
}
|
||||
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||
end
|
||||
@@ -12,7 +12,7 @@ describe "compare_alphanumerically" do
|
||||
it "sorts filenames without leading zeros correctly" do
|
||||
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
|
||||
ary.sort! { |a, b|
|
||||
compare_alphanumerically a, b
|
||||
compare_numerically a, b
|
||||
}
|
||||
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||
end
|
||||
@@ -22,7 +22,7 @@ describe "compare_alphanumerically" do
|
||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||
ary.reverse.sort { |a, b|
|
||||
compare_alphanumerically a, b
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
end
|
||||
|
||||
@@ -30,7 +30,17 @@ describe "compare_alphanumerically" do
|
||||
it "handles numbers larger than Int32" do
|
||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||
ary.reverse.sort { |a, b|
|
||||
compare_alphanumerically a, b
|
||||
compare_numerically a, b
|
||||
}.should eq ary
|
||||
end
|
||||
end
|
||||
|
||||
describe "chapter_sort" do
|
||||
it "sorts correctly" do
|
||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||
sorter = ChapterSorter.new ary
|
||||
ary.reverse.sort do |a, b|
|
||||
sorter.compare a, b
|
||||
end.should eq ary
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,8 @@ class Config
|
||||
property log_level : String = "info"
|
||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||
home: true
|
||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
||||
home: true
|
||||
property mangadex = Hash(String, String | Int32).new
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require "kemal"
|
||||
require "../storage"
|
||||
require "../util"
|
||||
require "../util/*"
|
||||
|
||||
class AuthHandler < Kemal::Handler
|
||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require "baked_file_system"
|
||||
require "kemal"
|
||||
require "../util"
|
||||
require "../util/*"
|
||||
|
||||
class FS
|
||||
extend BakedFileSystem
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
require "kemal"
|
||||
require "../util"
|
||||
require "../util/*"
|
||||
|
||||
class UploadHandler < Kemal::Handler
|
||||
def initialize(@upload_dir : String)
|
||||
|
||||
696
src/library.cr
696
src/library.cr
@@ -1,696 +0,0 @@
|
||||
require "mime"
|
||||
require "json"
|
||||
require "uri"
|
||||
require "./util"
|
||||
require "./archive"
|
||||
|
||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
|
||||
struct Image
|
||||
property data : Bytes
|
||||
property mime : String
|
||||
property filename : String
|
||||
property size : Int32
|
||||
|
||||
def initialize(@data, @mime, @filename, @size)
|
||||
end
|
||||
end
|
||||
|
||||
class Entry
|
||||
property zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, title_id : String,
|
||||
encoded_path : String, encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(path, @book, @title_id, storage)
|
||||
@zip_path = path
|
||||
@encoded_path = URI.encode path
|
||||
@title = File.basename path, File.extname path
|
||||
@encoded_title = URI.encode @title
|
||||
@size = (File.size path).humanize_bytes
|
||||
file = ArchiveFile.new path
|
||||
@pages = file.entries.count do |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
end
|
||||
file.close
|
||||
id = storage.get_id @zip_path, false
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
is_title: false,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@mtime = File.info(@zip_path).modification_time
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id", "title_id",
|
||||
"encoded_path", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "pages" { json.number @pages }
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
end
|
||||
|
||||
def display_name
|
||||
@book.display_name @title
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}api/page/#{@title_id}/#{@id}/1"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
img = nil
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
compare_alphanumerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
end
|
||||
end
|
||||
img
|
||||
end
|
||||
|
||||
def next_entry
|
||||
idx = @book.entries.index self
|
||||
return nil if idx.nil? || idx == @book.entries.size - 1
|
||||
@book.entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
date_added = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime @zip_path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added.not_nil! # is it ok to set not_nil! here?
|
||||
end
|
||||
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
TitleInfo.new @book.dir do |info|
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {@title => page}
|
||||
else
|
||||
info.progress[username][@title] = page
|
||||
end
|
||||
# save last_read timestamp
|
||||
if info.last_read[username]?.nil?
|
||||
info.last_read[username] = {@title => Time.utc}
|
||||
else
|
||||
info.last_read[username][@title] = Time.utc
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def load_progress(username)
|
||||
progress = 0
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.progress[username]?.nil? ||
|
||||
info.progress[username][@title]?.nil?
|
||||
progress = info.progress[username][@title]
|
||||
end
|
||||
end
|
||||
[progress, @pages].min
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
page = load_progress username
|
||||
page / @pages
|
||||
end
|
||||
|
||||
def load_last_read(username)
|
||||
last_read = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.last_read[username]?.nil? ||
|
||||
info.last_read[username][@title]?.nil?
|
||||
last_read = info.last_read[username][@title]
|
||||
end
|
||||
end
|
||||
last_read
|
||||
end
|
||||
|
||||
def finished?(username)
|
||||
load_progress(username) == @pages
|
||||
end
|
||||
|
||||
def started?(username)
|
||||
load_progress(username) > 0
|
||||
end
|
||||
end
|
||||
|
||||
class Title
|
||||
property dir : String, parent_id : String, title_ids : Array(String),
|
||||
entries : Array(Entry), title : String, id : String,
|
||||
encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(@dir : String, @parent_id, storage,
|
||||
@library : Library)
|
||||
id = storage.get_id @dir, true
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @dir,
|
||||
id: id,
|
||||
is_title: true,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@title_ids = [] of String
|
||||
@entries = [] of Entry
|
||||
@mtime = File.info(dir).modification_time
|
||||
|
||||
Dir.entries(dir).each do |fn|
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id, storage, library
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
@library.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
next
|
||||
end
|
||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||
unless File.readable? path
|
||||
Logger.warn "File #{path} is not readable. Please make sure the " \
|
||||
"file permission is configured correctly."
|
||||
next
|
||||
end
|
||||
archive_exception = validate_archive path
|
||||
unless archive_exception.nil?
|
||||
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
||||
"Archive error: #{archive_exception}"
|
||||
next
|
||||
end
|
||||
entry = Entry.new path, self, @id, storage
|
||||
@entries << entry if entry.pages > 0
|
||||
end
|
||||
end
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_alphanumerically @library.title_hash[a].title,
|
||||
@library.title_hash[b].title
|
||||
end
|
||||
@entries.sort! do |a, b|
|
||||
compare_alphanumerically a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
end
|
||||
json.field "entries" do
|
||||
json.raw @entries.to_json
|
||||
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
|
||||
|
||||
def titles
|
||||
@title_ids.map { |tid| @library.get_title! tid }
|
||||
end
|
||||
|
||||
# Get all entries, including entries in nested titles
|
||||
def deep_entries
|
||||
return @entries if title_ids.empty?
|
||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
end
|
||||
|
||||
def parents
|
||||
ary = [] of Title
|
||||
tid = @parent_id
|
||||
while !tid.empty?
|
||||
title = @library.get_title! tid
|
||||
ary << title
|
||||
tid = title.parent_id
|
||||
end
|
||||
ary.reverse
|
||||
end
|
||||
|
||||
def size
|
||||
@entries.size + @title_ids.size
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
end
|
||||
|
||||
def display_name
|
||||
dn = @title
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.display_name
|
||||
dn = info_dn unless info_dn.empty?
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def display_name(entry_name)
|
||||
dn = entry_name
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.entry_display_name[entry_name]?
|
||||
unless info_dn.nil? || info_dn.empty?
|
||||
dn = info_dn
|
||||
end
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def set_display_name(dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.display_name = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_name(entry_name : String, dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_display_name[entry_name] = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
if @entries.size > 0
|
||||
url = @entries[0].cover_url
|
||||
end
|
||||
TitleInfo.new @dir do |info|
|
||||
info_url = info.cover_url
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def set_cover_url(url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.cover_url = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_cover_url(entry_name : String, url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_cover_url[entry_name] = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 100%
|
||||
def read_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 0%
|
||||
def unread_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, 0
|
||||
end
|
||||
titles.each do |t|
|
||||
t.unread_all username
|
||||
end
|
||||
end
|
||||
|
||||
def deep_read_page_count(username) : Int32
|
||||
load_progress_for_all_entries(username).sum +
|
||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
deep_read_page_count(username) / deep_total_page_count
|
||||
end
|
||||
|
||||
def get_continue_reading_entry(username)
|
||||
in_progress_entries = @entries.select do |e|
|
||||
load_progress(username, e.title) > 0
|
||||
end
|
||||
return nil if in_progress_entries.empty?
|
||||
|
||||
latest_read_entry = in_progress_entries[-1]
|
||||
if load_progress(username, latest_read_entry.title) ==
|
||||
latest_read_entry.pages
|
||||
next_entry latest_read_entry
|
||||
else
|
||||
latest_read_entry
|
||||
end
|
||||
end
|
||||
|
||||
def load_progress_for_all_entries(username)
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
|
||||
@entries.map do |e|
|
||||
info_progress = 0
|
||||
if progress && progress.has_key? e.title
|
||||
info_progress = [progress[e.title], e.pages].min
|
||||
end
|
||||
info_progress
|
||||
end
|
||||
end
|
||||
|
||||
def load_percentage_for_all_entries(username)
|
||||
progress = load_progress_for_all_entries username
|
||||
@entries.map_with_index do |e, i|
|
||||
progress[i] / e.pages
|
||||
end
|
||||
end
|
||||
|
||||
# === helper methods ===
|
||||
|
||||
# Gets the last read entry in the title. If the entry has been completed,
|
||||
# returns the next entry. Returns nil when no entry has been read yet,
|
||||
# or when all entries are completed
|
||||
def get_last_read_entry(username) : Entry?
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
return if progress.nil?
|
||||
|
||||
last_read_entry = nil
|
||||
|
||||
@entries.reverse_each do |e|
|
||||
if progress.has_key?(e.title) && progress[e.title] > 0
|
||||
last_read_entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if last_read_entry && last_read_entry.finished? username
|
||||
last_read_entry = last_read_entry.next_entry
|
||||
end
|
||||
|
||||
last_read_entry
|
||||
end
|
||||
|
||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
||||
def get_date_added_for_all_entries
|
||||
da = {} of String => Time
|
||||
TitleInfo.new @dir do |info|
|
||||
da = info.date_added
|
||||
end
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
info.date_added = da
|
||||
info.save
|
||||
end
|
||||
|
||||
@entries.map { |e| da[e.title] }
|
||||
end
|
||||
|
||||
def deep_entries_with_date_added
|
||||
da_ary = get_date_added_for_all_entries
|
||||
zip = @entries.map_with_index do |e, i|
|
||||
{entry: e, date_added: da_ary[i]}
|
||||
end
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
end
|
||||
end
|
||||
|
||||
class TitleInfo
|
||||
include JSON::Serializable
|
||||
|
||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||
property progress = {} of String => Hash(String, Int32)
|
||||
property display_name = ""
|
||||
property entry_display_name = {} of String => String
|
||||
property cover_url = ""
|
||||
property entry_cover_url = {} of String => String
|
||||
property last_read = {} of String => Hash(String, Time)
|
||||
property date_added = {} of String => Time
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
property dir : String = ""
|
||||
|
||||
@@mutex_hash = {} of String => Mutex
|
||||
|
||||
def self.new(dir, &)
|
||||
if @@mutex_hash[dir]?
|
||||
mutex = @@mutex_hash[dir]
|
||||
else
|
||||
mutex = Mutex.new
|
||||
@@mutex_hash[dir] = mutex
|
||||
end
|
||||
mutex.synchronize do
|
||||
instance = TitleInfo.allocate
|
||||
json_path = File.join dir, "info.json"
|
||||
if File.exists? json_path
|
||||
instance = TitleInfo.from_json File.read json_path
|
||||
end
|
||||
instance.dir = dir
|
||||
yield instance
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
json_path = File.join @dir, "info.json"
|
||||
File.write json_path, self.to_pretty_json
|
||||
end
|
||||
end
|
||||
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
register_mime_types
|
||||
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
return scan if @scan_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
sleep @scan_interval * 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def titles
|
||||
@title_ids.map { |tid| self.get_title!(tid) }
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_title(tid)
|
||||
@title_hash[tid]?
|
||||
end
|
||||
|
||||
def get_title!(tid)
|
||||
@title_hash[tid]
|
||||
end
|
||||
|
||||
def scan
|
||||
unless Dir.exists? @dir
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
(Dir.entries @dir)
|
||||
.select { |fn| !fn.starts_with? "." }
|
||||
.map { |fn| File.join @dir, fn }
|
||||
.select { |path| File.directory? path }
|
||||
.map { |path| Title.new path, "", storage, self }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
end
|
||||
|
||||
storage.bulk_insert_ids
|
||||
storage.close
|
||||
|
||||
Logger.debug "Scan completed"
|
||||
end
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0..11]
|
||||
.map { |e|
|
||||
# Get the last read time of the entry. If it hasn't been started, get
|
||||
# the last read time of the previous entry
|
||||
last_read = e.load_last_read username
|
||||
pe = e.previous_entry
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
{
|
||||
entry: e,
|
||||
percentage: e.load_percentage(username),
|
||||
last_read: last_read,
|
||||
}
|
||||
}
|
||||
|
||||
# Sort by by last_read, most recent first (nils at the end)
|
||||
cr_entries.sort { |a, b|
|
||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||
next 1 if a[:last_read].nil?
|
||||
next -1 if b[:last_read].nil?
|
||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||
}
|
||||
end
|
||||
|
||||
alias RA = NamedTuple(
|
||||
entry: Entry,
|
||||
percentage: Float64,
|
||||
grouped_count: Int32)
|
||||
|
||||
def get_recently_added_entries(username)
|
||||
recently_added = [] of RA
|
||||
last_date_added = nil
|
||||
|
||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
.select { |e| e[:date_added] > 1.month.ago }
|
||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
.each do |e|
|
||||
break if recently_added.size > 12
|
||||
last = recently_added.last?
|
||||
if last && e[:entry].title_id == last[:entry].title_id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||
last_hash = last.to_h
|
||||
count = last_hash[:grouped_count].as(Int32)
|
||||
last_hash[:grouped_count] = count + 1
|
||||
# Setting the percentage to a negative value will hide the
|
||||
# percentage badge on the card
|
||||
last_hash[:percentage] = -1.0
|
||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
||||
else
|
||||
last_date_added = e[:date_added]
|
||||
recently_added << {
|
||||
entry: e[:entry],
|
||||
percentage: e[:entry].load_percentage(username),
|
||||
grouped_count: 1,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
recently_added[0..11]
|
||||
end
|
||||
end
|
||||
182
src/library/entry.cr
Normal file
182
src/library/entry.cr
Normal file
@@ -0,0 +1,182 @@
|
||||
class Entry
|
||||
property zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, encoded_path : String,
|
||||
encoded_title : String, mtime : Time, err_msg : String?
|
||||
|
||||
def initialize(@zip_path, @book, storage)
|
||||
@encoded_path = URI.encode @zip_path
|
||||
@title = File.basename @zip_path, File.extname @zip_path
|
||||
@encoded_title = URI.encode @title
|
||||
@size = (File.size @zip_path).humanize_bytes
|
||||
id = storage.get_id @zip_path, false
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @zip_path,
|
||||
id: id,
|
||||
is_title: false,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@mtime = File.info(@zip_path).modification_time
|
||||
|
||||
unless File.readable? @zip_path
|
||||
@err_msg = "File #{@zip_path} is not readable."
|
||||
Logger.warn "#{@err_msg} Please make sure the " \
|
||||
"file permission is configured correctly."
|
||||
return
|
||||
end
|
||||
|
||||
archive_exception = validate_archive @zip_path
|
||||
unless archive_exception.nil?
|
||||
@err_msg = "Archive error: #{archive_exception}"
|
||||
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
||||
"Ignoring it. #{@err_msg}"
|
||||
return
|
||||
end
|
||||
|
||||
file = ArchiveFile.new @zip_path
|
||||
@pages = file.entries.count do |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
end
|
||||
file.close
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id",
|
||||
"encoded_path", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "title_id", @book.id
|
||||
json.field "display_name", @book.display_name @title
|
||||
json.field "cover_url", cover_url
|
||||
json.field "pages" { json.number @pages }
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
end
|
||||
end
|
||||
|
||||
def display_name
|
||||
@book.display_name @title
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def cover_url
|
||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1"
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_url = info.entry_cover_url[@title]?
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def read_page(page_num)
|
||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||
img = nil
|
||||
ArchiveFile.open @zip_path do |file|
|
||||
page = file.entries
|
||||
.select { |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
}
|
||||
.sort { |a, b|
|
||||
compare_numerically a.filename, b.filename
|
||||
}
|
||||
.[page_num - 1]
|
||||
data = file.read_entry page
|
||||
if data
|
||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||
data.size
|
||||
end
|
||||
end
|
||||
img
|
||||
end
|
||||
|
||||
def next_entry(username)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
return nil if idx.nil? || idx == entries.size - 1
|
||||
entries[idx + 1]
|
||||
end
|
||||
|
||||
def previous_entry
|
||||
idx = @book.entries.index self
|
||||
return nil if idx.nil? || idx == 0
|
||||
@book.entries[idx - 1]
|
||||
end
|
||||
|
||||
def date_added
|
||||
date_added = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
info_da = info.date_added[@title]?
|
||||
if info_da.nil?
|
||||
date_added = info.date_added[@title] = ctime @zip_path
|
||||
info.save
|
||||
else
|
||||
date_added = info_da
|
||||
end
|
||||
end
|
||||
date_added.not_nil! # is it ok to set not_nil! here?
|
||||
end
|
||||
|
||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
TitleInfo.new @book.dir do |info|
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {@title => page}
|
||||
else
|
||||
info.progress[username][@title] = page
|
||||
end
|
||||
# save last_read timestamp
|
||||
if info.last_read[username]?.nil?
|
||||
info.last_read[username] = {@title => Time.utc}
|
||||
else
|
||||
info.last_read[username][@title] = Time.utc
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def load_progress(username)
|
||||
progress = 0
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.progress[username]?.nil? ||
|
||||
info.progress[username][@title]?.nil?
|
||||
progress = info.progress[username][@title]
|
||||
end
|
||||
end
|
||||
[progress, @pages].min
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
page = load_progress username
|
||||
page / @pages
|
||||
end
|
||||
|
||||
def load_last_read(username)
|
||||
last_read = nil
|
||||
TitleInfo.new @book.dir do |info|
|
||||
unless info.last_read[username]?.nil? ||
|
||||
info.last_read[username][@title]?.nil?
|
||||
last_read = info.last_read[username][@title]
|
||||
end
|
||||
end
|
||||
last_read
|
||||
end
|
||||
|
||||
def finished?(username)
|
||||
load_progress(username) == @pages
|
||||
end
|
||||
|
||||
def started?(username)
|
||||
load_progress(username) > 0
|
||||
end
|
||||
end
|
||||
197
src/library/library.cr
Normal file
197
src/library/library.cr
Normal file
@@ -0,0 +1,197 @@
|
||||
class Library
|
||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
||||
title_hash : Hash(String, Title)
|
||||
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
register_mime_types
|
||||
|
||||
@dir = Config.current.library_path
|
||||
@scan_interval = Config.current.scan_interval
|
||||
# explicitly initialize @titles to bypass the compiler check. it will
|
||||
# be filled with actual Titles in the `scan` call below
|
||||
@title_ids = [] of String
|
||||
@title_hash = {} of String => Title
|
||||
|
||||
return scan if @scan_interval < 1
|
||||
spawn do
|
||||
loop do
|
||||
start = Time.local
|
||||
scan
|
||||
ms = (Time.local - start).total_milliseconds
|
||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||
sleep @scan_interval * 60
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def titles
|
||||
@title_ids.map { |tid| self.get_title!(tid) }
|
||||
end
|
||||
|
||||
def sorted_titles(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# This is a hack to bypass a compiler bug
|
||||
ary = titles
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .time_modified?
|
||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
ary.sort! do |a, b|
|
||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||
compare_numerically a.title, b.title
|
||||
end
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_title(tid)
|
||||
@title_hash[tid]?
|
||||
end
|
||||
|
||||
def get_title!(tid)
|
||||
@title_hash[tid]
|
||||
end
|
||||
|
||||
def scan
|
||||
unless Dir.exists? @dir
|
||||
Logger.info "The library directory #{@dir} does not exist. " \
|
||||
"Attempting to create it"
|
||||
Dir.mkdir_p @dir
|
||||
end
|
||||
@title_ids.clear
|
||||
|
||||
storage = Storage.new auto_close: false
|
||||
|
||||
(Dir.entries @dir)
|
||||
.select { |fn| !fn.starts_with? "." }
|
||||
.map { |fn| File.join @dir, fn }
|
||||
.select { |path| File.directory? path }
|
||||
.map { |path| Title.new path, "", storage, self }
|
||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||
.sort { |a, b| a.title <=> b.title }
|
||||
.each do |title|
|
||||
@title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
end
|
||||
|
||||
storage.bulk_insert_ids
|
||||
storage.close
|
||||
|
||||
Logger.debug "Scan completed"
|
||||
end
|
||||
|
||||
def get_continue_reading_entries(username)
|
||||
cr_entries = deep_titles
|
||||
.map { |t| t.get_last_read_entry username }
|
||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||
.map { |e|
|
||||
# Get the last read time of the entry. If it hasn't been started, get
|
||||
# the last read time of the previous entry
|
||||
last_read = e.load_last_read username
|
||||
pe = e.previous_entry
|
||||
if last_read.nil? && pe
|
||||
last_read = pe.load_last_read username
|
||||
end
|
||||
{
|
||||
entry: e,
|
||||
percentage: e.load_percentage(username),
|
||||
last_read: last_read,
|
||||
}
|
||||
}
|
||||
|
||||
# Sort by by last_read, most recent first (nils at the end)
|
||||
cr_entries.sort { |a, b|
|
||||
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||
next 1 if a[:last_read].nil?
|
||||
next -1 if b[:last_read].nil?
|
||||
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||
}
|
||||
end
|
||||
|
||||
alias RA = NamedTuple(
|
||||
entry: Entry,
|
||||
percentage: Float64,
|
||||
grouped_count: Int32)
|
||||
|
||||
def get_recently_added_entries(username)
|
||||
recently_added = [] of RA
|
||||
last_date_added = nil
|
||||
|
||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
.select { |e| e[:date_added] > 1.month.ago }
|
||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
||||
.each do |e|
|
||||
break if recently_added.size > 12
|
||||
last = recently_added.last?
|
||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||
last_hash = last.to_h
|
||||
count = last_hash[:grouped_count].as(Int32)
|
||||
last_hash[:grouped_count] = count + 1
|
||||
# Setting the percentage to a negative value will hide the
|
||||
# percentage badge on the card
|
||||
last_hash[:percentage] = -1.0
|
||||
recently_added[recently_added.size - 1] = RA.from last_hash
|
||||
else
|
||||
last_date_added = e[:date_added]
|
||||
recently_added << {
|
||||
entry: e[:entry],
|
||||
percentage: e[:entry].load_percentage(username),
|
||||
grouped_count: 1,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
|
||||
end
|
||||
|
||||
def get_start_reading_titles(username)
|
||||
# Here we are not using `deep_titles` as it may cause unexpected behaviors
|
||||
# For example, consider the following nested titles:
|
||||
# - One Puch Man
|
||||
# - Vol. 1
|
||||
# - Vol. 2
|
||||
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||
# when the user hasn't started `Vol. 1` yet
|
||||
titles
|
||||
.select { |t| t.load_percentage(username) == 0 }
|
||||
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||
.shuffle
|
||||
end
|
||||
end
|
||||
378
src/library/title.cr
Normal file
378
src/library/title.cr
Normal file
@@ -0,0 +1,378 @@
|
||||
require "../archive"
|
||||
|
||||
class Title
|
||||
property dir : String, parent_id : String, title_ids : Array(String),
|
||||
entries : Array(Entry), title : String, id : String,
|
||||
encoded_title : String, mtime : Time
|
||||
|
||||
def initialize(@dir : String, @parent_id, storage,
|
||||
@library : Library)
|
||||
id = storage.get_id @dir, true
|
||||
if id.nil?
|
||||
id = random_str
|
||||
storage.insert_id({
|
||||
path: @dir,
|
||||
id: id,
|
||||
is_title: true,
|
||||
})
|
||||
end
|
||||
@id = id
|
||||
@title = File.basename dir
|
||||
@encoded_title = URI.encode @title
|
||||
@title_ids = [] of String
|
||||
@entries = [] of Entry
|
||||
@mtime = File.info(dir).modification_time
|
||||
|
||||
Dir.entries(dir).each do |fn|
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id, storage, library
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
@library.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
next
|
||||
end
|
||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||
entry = Entry.new path, self, storage
|
||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||
end
|
||||
end
|
||||
|
||||
mtimes = [@mtime]
|
||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||
mtimes += @entries.map { |e| e.mtime }
|
||||
@mtime = mtimes.max
|
||||
|
||||
@title_ids.sort! do |a, b|
|
||||
compare_numerically @library.title_hash[a].title,
|
||||
@library.title_hash[b].title
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
@entries.sort! do |a, b|
|
||||
sorter.compare a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
def to_json(json : JSON::Builder)
|
||||
json.object do
|
||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% end %}
|
||||
json.field "display_name", display_name
|
||||
json.field "cover_url", cover_url
|
||||
json.field "mtime" { json.number @mtime.to_unix }
|
||||
json.field "titles" do
|
||||
json.raw self.titles.to_json
|
||||
end
|
||||
json.field "entries" do
|
||||
json.raw @entries.to_json
|
||||
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
|
||||
|
||||
def titles
|
||||
@title_ids.map { |tid| @library.get_title! tid }
|
||||
end
|
||||
|
||||
# Get all entries, including entries in nested titles
|
||||
def deep_entries
|
||||
return @entries if title_ids.empty?
|
||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
||||
end
|
||||
|
||||
def deep_titles
|
||||
return [] of Title if titles.empty?
|
||||
titles + titles.map { |t| t.deep_titles }.flatten
|
||||
end
|
||||
|
||||
def parents
|
||||
ary = [] of Title
|
||||
tid = @parent_id
|
||||
while !tid.empty?
|
||||
title = @library.get_title! tid
|
||||
ary << title
|
||||
tid = title.parent_id
|
||||
end
|
||||
ary.reverse
|
||||
end
|
||||
|
||||
def size
|
||||
@entries.size + @title_ids.size
|
||||
end
|
||||
|
||||
def get_entry(eid)
|
||||
@entries.find { |e| e.id == eid }
|
||||
end
|
||||
|
||||
def display_name
|
||||
dn = @title
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.display_name
|
||||
dn = info_dn unless info_dn.empty?
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def encoded_display_name
|
||||
URI.encode display_name
|
||||
end
|
||||
|
||||
def display_name(entry_name)
|
||||
dn = entry_name
|
||||
TitleInfo.new @dir do |info|
|
||||
info_dn = info.entry_display_name[entry_name]?
|
||||
unless info_dn.nil? || info_dn.empty?
|
||||
dn = info_dn
|
||||
end
|
||||
end
|
||||
dn
|
||||
end
|
||||
|
||||
def set_display_name(dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.display_name = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_name(entry_name : String, dn)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_display_name[entry_name] = dn
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def cover_url
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
readable_entries = @entries.select &.err_msg.nil?
|
||||
if readable_entries.size > 0
|
||||
url = readable_entries[0].cover_url
|
||||
end
|
||||
TitleInfo.new @dir do |info|
|
||||
info_url = info.cover_url
|
||||
unless info_url.nil? || info_url.empty?
|
||||
url = File.join Config.current.base_url, info_url
|
||||
end
|
||||
end
|
||||
url
|
||||
end
|
||||
|
||||
def set_cover_url(url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.cover_url = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_cover_url(entry_name : String, url : String)
|
||||
TitleInfo.new @dir do |info|
|
||||
info.entry_cover_url[entry_name] = url
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 100%
|
||||
def read_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, e.pages
|
||||
end
|
||||
titles.each do |t|
|
||||
t.read_all username
|
||||
end
|
||||
end
|
||||
|
||||
# Set the reading progress of all entries and nested libraries to 0%
|
||||
def unread_all(username)
|
||||
@entries.each do |e|
|
||||
e.save_progress username, 0
|
||||
end
|
||||
titles.each do |t|
|
||||
t.unread_all username
|
||||
end
|
||||
end
|
||||
|
||||
def deep_read_page_count(username) : Int32
|
||||
load_progress_for_all_entries(username).sum +
|
||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
||||
end
|
||||
|
||||
def deep_total_page_count : Int32
|
||||
entries.map { |e| e.pages }.sum +
|
||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
||||
end
|
||||
|
||||
def load_percentage(username)
|
||||
deep_read_page_count(username) / deep_total_page_count
|
||||
end
|
||||
|
||||
def load_progress_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
ary.map do |e|
|
||||
info_progress = 0
|
||||
if progress && progress.has_key? e.title
|
||||
info_progress = [progress[e.title], e.pages].min
|
||||
end
|
||||
info_progress
|
||||
end
|
||||
end
|
||||
|
||||
def load_percentage_for_all_entries(username, opt : SortOptions? = nil,
|
||||
unsorted = false)
|
||||
if unsorted
|
||||
ary = @entries
|
||||
else
|
||||
ary = sorted_entries username, opt
|
||||
end
|
||||
|
||||
progress = load_progress_for_all_entries username, opt, unsorted
|
||||
ary.map_with_index do |e, i|
|
||||
progress[i] / e.pages
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the sorted entries array
|
||||
#
|
||||
# When `opt` is nil, it uses the preferred sorting options in info.json, or
|
||||
# use the default (auto, ascending)
|
||||
# When `opt` is not nil, it saves the options to info.json
|
||||
def sorted_entries(username, opt : SortOptions? = nil)
|
||||
if opt.nil?
|
||||
opt = SortOptions.from_info_json @dir, username
|
||||
else
|
||||
TitleInfo.new @dir do |info|
|
||||
info.sort_by[username] = opt.to_tuple
|
||||
info.save
|
||||
end
|
||||
end
|
||||
|
||||
case opt.not_nil!.method
|
||||
when .title?
|
||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
||||
when .time_modified?
|
||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .time_added?
|
||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||
compare_numerically a.title, b.title }
|
||||
when .progress?
|
||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||
ary = @entries.zip(percentage_ary)
|
||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
||||
.map { |tp| tp[0] }
|
||||
else
|
||||
unless opt.method.auto?
|
||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||
"Auto instead"
|
||||
end
|
||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
||||
ary = @entries.sort do |a, b|
|
||||
sorter.compare(a.title, b.title).or \
|
||||
compare_numerically a.title, b.title
|
||||
end
|
||||
end
|
||||
|
||||
ary.reverse! unless opt.not_nil!.ascend
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
# === helper methods ===
|
||||
|
||||
# Gets the last read entry in the title. If the entry has been completed,
|
||||
# returns the next entry. Returns nil when no entry has been read yet,
|
||||
# or when all entries are completed
|
||||
def get_last_read_entry(username) : Entry?
|
||||
progress = {} of String => Int32
|
||||
TitleInfo.new @dir do |info|
|
||||
progress = info.progress[username]?
|
||||
end
|
||||
return if progress.nil?
|
||||
|
||||
last_read_entry = nil
|
||||
|
||||
sorted_entries(username).reverse_each do |e|
|
||||
if progress.has_key?(e.title) && progress[e.title] > 0
|
||||
last_read_entry = e
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if last_read_entry && last_read_entry.finished? username
|
||||
last_read_entry = last_read_entry.next_entry username
|
||||
end
|
||||
|
||||
last_read_entry
|
||||
end
|
||||
|
||||
# Equivalent to `@entries.map &. date_added`, but much more efficient
|
||||
def get_date_added_for_all_entries
|
||||
da = {} of String => Time
|
||||
TitleInfo.new @dir do |info|
|
||||
da = info.date_added
|
||||
end
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
info.date_added = da
|
||||
info.save
|
||||
end
|
||||
|
||||
@entries.map { |e| da[e.title] }
|
||||
end
|
||||
|
||||
def deep_entries_with_date_added
|
||||
da_ary = get_date_added_for_all_entries
|
||||
zip = @entries.map_with_index do |e, i|
|
||||
{entry: e, date_added: da_ary[i]}
|
||||
end
|
||||
return zip if title_ids.empty?
|
||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
||||
end
|
||||
|
||||
def bulk_progress(action, ids : Array(String), username)
|
||||
selected_entries = ids
|
||||
.map { |id|
|
||||
@entries.find { |e| e.id == id }
|
||||
}
|
||||
.select(Entry)
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
selected_entries.each do |e|
|
||||
page = action == "read" ? e.pages : 0
|
||||
if info.progress[username]?.nil?
|
||||
info.progress[username] = {e.title => page}
|
||||
else
|
||||
info.progress[username][e.title] = page
|
||||
end
|
||||
end
|
||||
info.save
|
||||
end
|
||||
end
|
||||
end
|
||||
102
src/library/types.cr
Normal file
102
src/library/types.cr
Normal file
@@ -0,0 +1,102 @@
|
||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
Title
|
||||
Progress
|
||||
TimeModified
|
||||
TimeAdded
|
||||
end
|
||||
|
||||
class SortOptions
|
||||
property method : SortMethod, ascend : Bool
|
||||
|
||||
def initialize(in_method : String? = nil, @ascend = true)
|
||||
@method = SortMethod::Auto
|
||||
SortMethod.each do |m, _|
|
||||
if in_method && m.to_s.underscore == in_method
|
||||
@method = m
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(in_method : SortMethod? = nil, @ascend = true)
|
||||
if in_method
|
||||
@method = in_method
|
||||
else
|
||||
@method = SortMethod::Auto
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_tuple(tp : Tuple(String, Bool))
|
||||
method, ascend = tp
|
||||
self.new method, ascend
|
||||
end
|
||||
|
||||
def self.from_info_json(dir, username)
|
||||
opt = SortOptions.new
|
||||
TitleInfo.new dir do |info|
|
||||
if info.sort_by.has_key? username
|
||||
opt = SortOptions.from_tuple info.sort_by[username]
|
||||
end
|
||||
end
|
||||
opt
|
||||
end
|
||||
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
property data : Bytes
|
||||
property mime : String
|
||||
property filename : String
|
||||
property size : Int32
|
||||
|
||||
def initialize(@data, @mime, @filename, @size)
|
||||
end
|
||||
end
|
||||
|
||||
class TitleInfo
|
||||
include JSON::Serializable
|
||||
|
||||
property comment = "Generated by Mango. DO NOT EDIT!"
|
||||
property progress = {} of String => Hash(String, Int32)
|
||||
property display_name = ""
|
||||
property entry_display_name = {} of String => String
|
||||
property cover_url = ""
|
||||
property entry_cover_url = {} of String => String
|
||||
property last_read = {} of String => Hash(String, Time)
|
||||
property date_added = {} of String => Time
|
||||
property sort_by = {} of String => Tuple(String, Bool)
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
property dir : String = ""
|
||||
|
||||
@@mutex_hash = {} of String => Mutex
|
||||
|
||||
def self.new(dir, &)
|
||||
if @@mutex_hash[dir]?
|
||||
mutex = @@mutex_hash[dir]
|
||||
else
|
||||
mutex = Mutex.new
|
||||
@@mutex_hash[dir] = mutex
|
||||
end
|
||||
mutex.synchronize do
|
||||
instance = TitleInfo.allocate
|
||||
json_path = File.join dir, "info.json"
|
||||
if File.exists? json_path
|
||||
instance = TitleInfo.from_json File.read json_path
|
||||
end
|
||||
instance.dir = dir
|
||||
yield instance
|
||||
end
|
||||
end
|
||||
|
||||
def save
|
||||
json_path = File.join @dir, "info.json"
|
||||
File.write json_path, self.to_pretty_json
|
||||
end
|
||||
end
|
||||
@@ -8,12 +8,7 @@ class Logger
|
||||
|
||||
@@severity : Log::Severity = :info
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
level = Config.current.log_level
|
||||
|
||||
@@ -131,12 +131,7 @@ module MangaDex
|
||||
end
|
||||
|
||||
class API
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
require "./api"
|
||||
require "sqlite3"
|
||||
require "zip"
|
||||
|
||||
module MangaDex
|
||||
@@ -14,118 +13,26 @@ module MangaDex
|
||||
end
|
||||
end
|
||||
|
||||
enum JobStatus
|
||||
Pending # 0
|
||||
Downloading # 1
|
||||
Error # 2
|
||||
Completed # 3
|
||||
MissingPages # 4
|
||||
end
|
||||
class Downloader < Queue::Downloader
|
||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
||||
.to_i32
|
||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
||||
|
||||
struct Job
|
||||
property id : String
|
||||
property manga_id : String
|
||||
property title : String
|
||||
property manga_title : String
|
||||
property status : JobStatus
|
||||
property status_message : String = ""
|
||||
property pages : Int32 = 0
|
||||
property success_count : Int32 = 0
|
||||
property fail_count : Int32 = 0
|
||||
property time : Time
|
||||
use_default
|
||||
|
||||
def parse_query_result(res : DB::ResultSet)
|
||||
@id = res.read String
|
||||
@manga_id = res.read String
|
||||
@title = res.read String
|
||||
@manga_title = res.read String
|
||||
status = res.read Int32
|
||||
@status_message = res.read String
|
||||
@pages = res.read Int32
|
||||
@success_count = res.read Int32
|
||||
@fail_count = res.read Int32
|
||||
time = res.read Int64
|
||||
@status = JobStatus.new status
|
||||
@time = Time.unix_ms time
|
||||
def initialize
|
||||
@api = API.default
|
||||
super
|
||||
end
|
||||
|
||||
# Raises if the result set does not contain the correct set of columns
|
||||
def self.from_query_result(res : DB::ResultSet)
|
||||
job = Job.allocate
|
||||
job.parse_query_result res
|
||||
job
|
||||
end
|
||||
|
||||
def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
|
||||
end
|
||||
|
||||
def to_json(json)
|
||||
json.object do
|
||||
{% for name in ["id", "manga_id", "title", "manga_title",
|
||||
"status_message"] %}
|
||||
json.field {{name}}, @{{name.id}}
|
||||
{% end %}
|
||||
{% for name in ["pages", "success_count", "fail_count"] %}
|
||||
json.field {{name}} do
|
||||
json.number @{{name.id}}
|
||||
end
|
||||
{% end %}
|
||||
json.field "status", @status.to_s
|
||||
json.field "time" do
|
||||
json.number @time.to_unix_ms
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Queue
|
||||
property downloader : Downloader?
|
||||
@path : String
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize(db_path : String? = nil)
|
||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
db.exec "create table if not exists queue " \
|
||||
"(id text, manga_id text, title text, manga_title " \
|
||||
"text, status integer, status_message text, " \
|
||||
"pages integer, success_count integer, " \
|
||||
"fail_count integer, time integer)"
|
||||
db.exec "create unique index if not exists id_idx " \
|
||||
"on queue (id)"
|
||||
db.exec "create index if not exists manga_id_idx " \
|
||||
"on queue (manga_id)"
|
||||
db.exec "create index if not exists status_idx " \
|
||||
"on queue (status)"
|
||||
rescue e
|
||||
Logger.error "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the earliest job in queue or nil if the job cannot be parsed.
|
||||
# Returns nil if queue is empty
|
||||
def pop
|
||||
def pop : Queue::Job?
|
||||
job = nil
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where status = 0 " \
|
||||
"or status = 1 order by time limit 1" do |res|
|
||||
job = Job.from_query_result res
|
||||
db.query_one "select * from queue where id not like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
end
|
||||
rescue
|
||||
end
|
||||
@@ -133,178 +40,14 @@ module MangaDex
|
||||
job
|
||||
end
|
||||
|
||||
# Push an array of jobs into the queue, and return the number of jobs
|
||||
# inserted. Any job already exists in the queue will be ignored.
|
||||
def push(jobs : Array(Job))
|
||||
start_count = self.count
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs.each do |job|
|
||||
db.exec "insert or ignore into queue values " \
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
job.id, job.manga_id, job.title, job.manga_title,
|
||||
job.status.to_i, job.status_message, job.pages,
|
||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||
end
|
||||
end
|
||||
self.count - start_count
|
||||
end
|
||||
|
||||
def reset(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where id = (?)", id
|
||||
end
|
||||
end
|
||||
|
||||
def reset(job : Job)
|
||||
self.reset job.id
|
||||
end
|
||||
|
||||
# Reset all failed tasks (missing pages and error)
|
||||
def reset
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where status = 2 or status = 4"
|
||||
end
|
||||
end
|
||||
|
||||
def delete(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where id = (?)", id
|
||||
end
|
||||
end
|
||||
|
||||
def delete(job : Job)
|
||||
self.delete job.id
|
||||
end
|
||||
|
||||
def delete_status(status : JobStatus)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where status = (?)", status.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def count_status(status : JobStatus)
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue where " \
|
||||
"status = (?)", status.to_i, as: Int32
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def count
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue", as: Int32
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def set_status(status : JobStatus, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = (?) where id = (?)",
|
||||
status.to_i, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def get_all
|
||||
jobs = [] of Job
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||
Job.from_query_result rs
|
||||
end
|
||||
end
|
||||
jobs
|
||||
end
|
||||
|
||||
def add_success(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set success_count = success_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
|
||||
def add_fail(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
|
||||
def set_pages(pages : Int32, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||
"fail_count = 0 where id = (?)", pages, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(msg : String, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status_message = " \
|
||||
"status_message || (?) || (?) where id = (?)",
|
||||
"\n", msg, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def pause
|
||||
@downloader.not_nil!.stopped = true
|
||||
end
|
||||
|
||||
def resume
|
||||
@downloader.not_nil!.stopped = false
|
||||
end
|
||||
|
||||
def paused?
|
||||
@downloader.not_nil!.stopped
|
||||
end
|
||||
end
|
||||
|
||||
class Downloader
|
||||
property stopped = false
|
||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
||||
.to_i32
|
||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
||||
@library_path : String = Config.current.library_path
|
||||
@downloading = false
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
|
||||
def initialize
|
||||
@queue = Queue.default
|
||||
@api = API.default
|
||||
@queue.downloader = self
|
||||
|
||||
spawn do
|
||||
loop do
|
||||
sleep 1.second
|
||||
next if @stopped || @downloading
|
||||
begin
|
||||
job = @queue.pop
|
||||
next if job.nil?
|
||||
download job
|
||||
rescue e
|
||||
Logger.error e
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def download(job : Job)
|
||||
private def download(job : Queue::Job)
|
||||
@downloading = true
|
||||
@queue.set_status JobStatus::Downloading, job
|
||||
@queue.set_status Queue::JobStatus::Downloading, job
|
||||
begin
|
||||
chapter = @api.get_chapter(job.id)
|
||||
rescue e
|
||||
Logger.error e
|
||||
@queue.set_status JobStatus::Error, job
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
unless e.message.nil?
|
||||
@queue.add_message e.message.not_nil!, job
|
||||
end
|
||||
@@ -319,7 +62,7 @@ module MangaDex
|
||||
unless File.exists? manga_dir
|
||||
Dir.mkdir_p manga_dir
|
||||
end
|
||||
zip_path = File.join manga_dir, "#{job.title}.cbz"
|
||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
||||
|
||||
# Find the number of digits needed to store the number of pages
|
||||
len = Math.log10(chapter.pages.size).to_i + 1
|
||||
@@ -369,17 +112,20 @@ module MangaDex
|
||||
Logger.debug "Download completed. " \
|
||||
"#{fail_count}/#{page_jobs.size} failed"
|
||||
writer.close
|
||||
Logger.debug "cbz File created at #{zip_path}"
|
||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||
".part")
|
||||
File.rename zip_path, filename
|
||||
Logger.debug "cbz File created at #{filename}"
|
||||
|
||||
zip_exception = validate_archive zip_path
|
||||
zip_exception = validate_archive filename
|
||||
if !zip_exception.nil?
|
||||
@queue.add_message "The downloaded archive is corrupted. " \
|
||||
"Error: #{zip_exception}", job
|
||||
@queue.set_status JobStatus::Error, job
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
elsif fail_count > 0
|
||||
@queue.set_status JobStatus::MissingPages, job
|
||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
||||
else
|
||||
@queue.set_status JobStatus::Completed, job
|
||||
@queue.set_status Queue::JobStatus::Completed, job
|
||||
end
|
||||
@downloading = false
|
||||
end
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
require "./config"
|
||||
require "./queue"
|
||||
require "./server"
|
||||
require "./mangadex/*"
|
||||
require "option_parser"
|
||||
require "clim"
|
||||
require "./plugin/*"
|
||||
|
||||
MANGO_VERSION = "0.7.3"
|
||||
MANGO_VERSION = "0.10.0"
|
||||
|
||||
macro common_option
|
||||
option "-c PATH", "--config=PATH", type: String,
|
||||
@@ -28,6 +30,7 @@ class CLI < Clim
|
||||
run do |opts|
|
||||
Config.load(opts.config).set_current
|
||||
MangaDex::Downloader.default
|
||||
Plugin::Downloader.default
|
||||
|
||||
# empty ARGV so it won't be passed to Kemal
|
||||
ARGV.clear
|
||||
|
||||
131
src/plugin/downloader.cr
Normal file
131
src/plugin/downloader.cr
Normal file
@@ -0,0 +1,131 @@
|
||||
class Plugin
|
||||
class Downloader < Queue::Downloader
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
super
|
||||
end
|
||||
|
||||
def pop : Queue::Job?
|
||||
job = nil
|
||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||
begin
|
||||
db.query_one "select * from queue where id like '%-%' " \
|
||||
"and (status = 0 or status = 1) " \
|
||||
"order by time limit 1" do |res|
|
||||
job = Queue::Job.from_query_result res
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
job
|
||||
end
|
||||
|
||||
private def process_filename(str)
|
||||
return "_" if str == ".."
|
||||
str.gsub "/", "_"
|
||||
end
|
||||
|
||||
private def download(job : Queue::Job)
|
||||
@downloading = true
|
||||
@queue.set_status Queue::JobStatus::Downloading, job
|
||||
|
||||
begin
|
||||
unless job.plugin_id
|
||||
raise "Job does not have a plugin ID specificed"
|
||||
end
|
||||
|
||||
plugin = Plugin.new job.plugin_id.not_nil!
|
||||
info = plugin.select_chapter job.plugin_chapter_id.not_nil!
|
||||
|
||||
pages = info["pages"].as_i
|
||||
|
||||
manga_title = process_filename job.manga_title
|
||||
chapter_title = process_filename info["title"].as_s
|
||||
|
||||
@queue.set_pages pages, job
|
||||
lib_dir = @library_path
|
||||
manga_dir = File.join lib_dir, manga_title
|
||||
unless File.exists? manga_dir
|
||||
Dir.mkdir_p manga_dir
|
||||
end
|
||||
|
||||
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
||||
writer = Zip::Writer.new zip_path
|
||||
rescue e
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
unless e.message.nil?
|
||||
@queue.add_message e.message.not_nil!, job
|
||||
end
|
||||
@downloading = false
|
||||
raise e
|
||||
end
|
||||
|
||||
fail_count = 0
|
||||
|
||||
while page = plugin.next_page
|
||||
fn = process_filename page["filename"].as_s
|
||||
url = page["url"].as_s
|
||||
headers = HTTP::Headers.new
|
||||
|
||||
if page["headers"]?
|
||||
page["headers"].as_h.each do |k, v|
|
||||
headers.add k, v.as_s
|
||||
end
|
||||
end
|
||||
|
||||
page_success = false
|
||||
tries = 4
|
||||
|
||||
loop do
|
||||
sleep plugin.info.wait_seconds.seconds
|
||||
Logger.debug "downloading #{url}"
|
||||
tries -= 1
|
||||
|
||||
begin
|
||||
HTTP::Client.get url, headers do |res|
|
||||
unless res.success?
|
||||
raise "Failed to download page #{url}. " \
|
||||
"[#{res.status_code}] #{res.status_message}"
|
||||
end
|
||||
writer.add fn, res.body_io
|
||||
end
|
||||
rescue e
|
||||
@queue.add_fail job
|
||||
fail_count += 1
|
||||
msg = "Failed to download page #{url}. Error: #{e}"
|
||||
@queue.add_message msg, job
|
||||
Logger.error msg
|
||||
Logger.debug "[failed] #{url}"
|
||||
else
|
||||
@queue.add_success job
|
||||
Logger.debug "[success] #{url}"
|
||||
page_success = true
|
||||
end
|
||||
|
||||
break if page_success || tries < 0
|
||||
end
|
||||
end
|
||||
|
||||
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
||||
writer.close
|
||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||
".part")
|
||||
File.rename zip_path, filename
|
||||
Logger.debug "cbz File created at #{filename}"
|
||||
|
||||
zip_exception = validate_archive filename
|
||||
if !zip_exception.nil?
|
||||
@queue.add_message "The downloaded archive is corrupted. " \
|
||||
"Error: #{zip_exception}", job
|
||||
@queue.set_status Queue::JobStatus::Error, job
|
||||
elsif fail_count > 0
|
||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
||||
else
|
||||
@queue.set_status Queue::JobStatus::Completed, job
|
||||
end
|
||||
|
||||
@downloading = false
|
||||
end
|
||||
end
|
||||
end
|
||||
343
src/plugin/plugin.cr
Normal file
343
src/plugin/plugin.cr
Normal file
@@ -0,0 +1,343 @@
|
||||
require "duktape/runtime"
|
||||
require "myhtml"
|
||||
require "http"
|
||||
require "xml"
|
||||
|
||||
class Plugin
|
||||
class Error < ::Exception
|
||||
end
|
||||
|
||||
class MetadataError < Error
|
||||
end
|
||||
|
||||
class PluginException < Error
|
||||
end
|
||||
|
||||
class SyntaxError < Error
|
||||
end
|
||||
|
||||
struct Info
|
||||
{% for name in ["id", "title", "placeholder"] %}
|
||||
getter {{name.id}} = ""
|
||||
{% end %}
|
||||
getter wait_seconds : UInt64 = 0
|
||||
getter dir : String
|
||||
|
||||
def initialize(@dir)
|
||||
info_path = File.join @dir, "info.json"
|
||||
|
||||
unless File.exists? info_path
|
||||
raise MetadataError.new "File `info.json` not found in the " \
|
||||
"plugin directory #{dir}"
|
||||
end
|
||||
|
||||
@json = JSON.parse File.read info_path
|
||||
|
||||
begin
|
||||
{% for name in ["id", "title", "placeholder"] %}
|
||||
@{{name.id}} = @json[{{name}}].as_s
|
||||
{% end %}
|
||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||
|
||||
unless @id.alphanumeric_underscore?
|
||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||
"underscores"
|
||||
end
|
||||
rescue e
|
||||
raise MetadataError.new "Failed to retrieve metadata from plugin " \
|
||||
"at #{@dir}. Error: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def each(&block : String, JSON::Any -> _)
|
||||
@json.as_h.each &block
|
||||
end
|
||||
end
|
||||
|
||||
struct Storage
|
||||
@hash = {} of String => String
|
||||
|
||||
def initialize(@path : String)
|
||||
unless File.exists? @path
|
||||
save
|
||||
end
|
||||
|
||||
json = JSON.parse File.read @path
|
||||
json.as_h.each do |k, v|
|
||||
@hash[k] = v.as_s
|
||||
end
|
||||
end
|
||||
|
||||
def []?(key)
|
||||
@hash[key]?
|
||||
end
|
||||
|
||||
def []=(key, val : String)
|
||||
@hash[key] = val
|
||||
end
|
||||
|
||||
def save
|
||||
File.write @path, @hash.to_pretty_json
|
||||
end
|
||||
end
|
||||
|
||||
@@info_ary = [] of Info
|
||||
@info : Info?
|
||||
|
||||
getter js_path = ""
|
||||
getter storage_path = ""
|
||||
|
||||
def self.build_info_ary
|
||||
@@info_ary.clear
|
||||
dir = Config.current.plugin_path
|
||||
Dir.mkdir_p dir unless Dir.exists? dir
|
||||
|
||||
Dir.each_child dir do |f|
|
||||
path = File.join dir, f
|
||||
next unless File.directory? path
|
||||
|
||||
begin
|
||||
@@info_ary << Info.new path
|
||||
rescue e : MetadataError
|
||||
Logger.warn e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.list
|
||||
self.build_info_ary
|
||||
@@info_ary.map do |m|
|
||||
{id: m.id, title: m.title}
|
||||
end
|
||||
end
|
||||
|
||||
def info
|
||||
@info.not_nil!
|
||||
end
|
||||
|
||||
def initialize(id : String)
|
||||
Plugin.build_info_ary
|
||||
|
||||
@info = @@info_ary.find { |i| i.id == id }
|
||||
if @info.nil?
|
||||
raise Error.new "Plugin with ID #{id} not found"
|
||||
end
|
||||
|
||||
@js_path = File.join info.dir, "index.js"
|
||||
@storage_path = File.join info.dir, "storage.json"
|
||||
|
||||
unless File.exists? @js_path
|
||||
raise Error.new "Plugin script not found at #{@js_path}"
|
||||
end
|
||||
|
||||
@rt = Duktape::Runtime.new do |sbx|
|
||||
sbx.push_global_object
|
||||
|
||||
sbx.push_pointer @storage_path.as(Void*)
|
||||
path = sbx.require_pointer(-1).as String
|
||||
sbx.pop
|
||||
sbx.push_string path
|
||||
sbx.put_prop_string -2, "storage_path"
|
||||
|
||||
def_helper_functions sbx
|
||||
end
|
||||
|
||||
eval File.read @js_path
|
||||
end
|
||||
|
||||
macro check_fields(ary)
|
||||
{% for field in ary %}
|
||||
unless json[{{field}}]?
|
||||
raise "Field `{{field.id}}` is missing from the function outputs"
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def list_chapters(query : String)
|
||||
json = eval_json "listChapters('#{query}')"
|
||||
begin
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
ary = json["chapters"].as_a
|
||||
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"]?
|
||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def select_chapter(id : String)
|
||||
json = eval_json "selectChapter('#{id}')"
|
||||
begin
|
||||
check_fields ["title", "pages"]
|
||||
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
def next_page
|
||||
json = eval_json "nextPage()"
|
||||
return if json.size == 0
|
||||
begin
|
||||
check_fields ["filename", "url"]
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
private def eval(str)
|
||||
@rt.eval str
|
||||
rescue e : Duktape::SyntaxError
|
||||
raise SyntaxError.new e.message
|
||||
rescue e : Duktape::Error
|
||||
raise Error.new e.message
|
||||
end
|
||||
|
||||
private def eval_json(str)
|
||||
JSON.parse eval(str).as String
|
||||
end
|
||||
|
||||
private def def_helper_functions(sbx)
|
||||
sbx.push_object
|
||||
|
||||
sbx.push_proc LibDUK::VARARGS do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
url = env.require_string 0
|
||||
|
||||
headers = HTTP::Headers.new
|
||||
|
||||
if env.get_top == 2
|
||||
env.enum 1, LibDUK::Enum::OwnPropertiesOnly
|
||||
while env.next -1, true
|
||||
key = env.require_string -2
|
||||
val = env.require_string -1
|
||||
headers.add key, val
|
||||
env.pop_2
|
||||
end
|
||||
end
|
||||
|
||||
res = HTTP::Client.get url, headers
|
||||
|
||||
env.push_object
|
||||
|
||||
env.push_int res.status_code
|
||||
env.put_prop_string -2, "status_code"
|
||||
|
||||
env.push_string res.body
|
||||
env.put_prop_string -2, "body"
|
||||
|
||||
env.push_object
|
||||
res.headers.each do |k, v|
|
||||
if v.size == 1
|
||||
env.push_string v[0]
|
||||
else
|
||||
env.push_string v.join ","
|
||||
end
|
||||
env.put_prop_string -2, k
|
||||
end
|
||||
env.put_prop_string -2, "headers"
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "get"
|
||||
|
||||
sbx.push_proc 2 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
html = env.require_string 0
|
||||
selector = env.require_string 1
|
||||
|
||||
myhtml = Myhtml::Parser.new html
|
||||
ary = myhtml.css(selector).map(&.to_html).to_a
|
||||
|
||||
ary_idx = env.push_array
|
||||
ary.each_with_index do |str, i|
|
||||
env.push_string str
|
||||
env.put_prop_index ary_idx, i.to_u32
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "css"
|
||||
|
||||
sbx.push_proc 1 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
html = env.require_string 0
|
||||
|
||||
str = XML.parse(html).inner_text
|
||||
|
||||
env.push_string str
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "text"
|
||||
|
||||
sbx.push_proc 2 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
html = env.require_string 0
|
||||
name = env.require_string 1
|
||||
|
||||
begin
|
||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
||||
env.push_string attr
|
||||
rescue
|
||||
env.push_undefined
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "attribute"
|
||||
|
||||
sbx.push_proc 1 do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
msg = env.require_string 0
|
||||
env.call_success
|
||||
|
||||
raise PluginException.new msg
|
||||
end
|
||||
sbx.put_prop_string -2, "raise"
|
||||
|
||||
sbx.push_proc LibDUK::VARARGS do |ptr|
|
||||
env = Duktape::Sandbox.new ptr
|
||||
key = env.require_string 0
|
||||
|
||||
env.get_global_string "storage_path"
|
||||
storage_path = env.require_string -1
|
||||
env.pop
|
||||
storage = Storage.new storage_path
|
||||
|
||||
if env.get_top == 2
|
||||
val = env.require_string 1
|
||||
storage[key] = val
|
||||
storage.save
|
||||
else
|
||||
val = storage[key]?
|
||||
if val
|
||||
env.push_string val
|
||||
else
|
||||
env.push_undefined
|
||||
end
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "storage"
|
||||
|
||||
sbx.put_prop_string -2, "mango"
|
||||
end
|
||||
end
|
||||
275
src/queue.cr
Normal file
275
src/queue.cr
Normal file
@@ -0,0 +1,275 @@
|
||||
require "sqlite3"
|
||||
require "./util/*"
|
||||
|
||||
class Queue
|
||||
abstract class Downloader
|
||||
property stopped = false
|
||||
@library_path : String = Config.current.library_path
|
||||
@downloading = false
|
||||
|
||||
def initialize
|
||||
@queue = Queue.default
|
||||
@queue << self
|
||||
|
||||
spawn do
|
||||
loop do
|
||||
sleep 1.second
|
||||
next if @stopped || @downloading
|
||||
begin
|
||||
job = pop
|
||||
next if job.nil?
|
||||
download job
|
||||
rescue e
|
||||
Logger.error e
|
||||
@downloading = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
abstract def pop : Job?
|
||||
private abstract def download(job : Job)
|
||||
end
|
||||
|
||||
enum JobStatus
|
||||
Pending # 0
|
||||
Downloading # 1
|
||||
Error # 2
|
||||
Completed # 3
|
||||
MissingPages # 4
|
||||
end
|
||||
|
||||
struct Job
|
||||
property id : String
|
||||
property manga_id : String
|
||||
property title : String
|
||||
property manga_title : String
|
||||
property status : JobStatus
|
||||
property status_message : String = ""
|
||||
property pages : Int32 = 0
|
||||
property success_count : Int32 = 0
|
||||
property fail_count : Int32 = 0
|
||||
property time : Time
|
||||
property plugin_id : String?
|
||||
property plugin_chapter_id : String?
|
||||
|
||||
def parse_query_result(res : DB::ResultSet)
|
||||
@id = res.read String
|
||||
@manga_id = res.read String
|
||||
@title = res.read String
|
||||
@manga_title = res.read String
|
||||
status = res.read Int32
|
||||
@status_message = res.read String
|
||||
@pages = res.read Int32
|
||||
@success_count = res.read Int32
|
||||
@fail_count = res.read Int32
|
||||
time = res.read Int64
|
||||
@status = JobStatus.new status
|
||||
@time = Time.unix_ms time
|
||||
|
||||
ary = @id.split("-")
|
||||
if ary.size == 2
|
||||
@plugin_id = ary[0]
|
||||
@plugin_chapter_id = ary[1]
|
||||
end
|
||||
end
|
||||
|
||||
# Raises if the result set does not contain the correct set of columns
|
||||
def self.from_query_result(res : DB::ResultSet)
|
||||
job = Job.allocate
|
||||
job.parse_query_result res
|
||||
job
|
||||
end
|
||||
|
||||
def initialize(@id, @manga_id, @title, @manga_title, @status, @time,
|
||||
@plugin_id = nil)
|
||||
end
|
||||
|
||||
def to_json(json)
|
||||
json.object do
|
||||
{% for name in ["id", "manga_id", "title", "manga_title",
|
||||
"status_message"] %}
|
||||
json.field {{name}}, @{{name.id}}
|
||||
{% end %}
|
||||
{% for name in ["pages", "success_count", "fail_count"] %}
|
||||
json.field {{name}} do
|
||||
json.number @{{name.id}}
|
||||
end
|
||||
{% end %}
|
||||
json.field "status", @status.to_s
|
||||
json.field "time" do
|
||||
json.number @time.to_unix_ms
|
||||
end
|
||||
json.field "plugin_id", @plugin_id if @plugin_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
getter path : String
|
||||
@downloaders = [] of Downloader
|
||||
@paused = false
|
||||
|
||||
use_default
|
||||
|
||||
def initialize(db_path : String? = nil)
|
||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
||||
dir = File.dirname @path
|
||||
unless Dir.exists? dir
|
||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||
"Attepmting to create it"
|
||||
Dir.mkdir_p dir
|
||||
end
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
begin
|
||||
db.exec "create table if not exists queue " \
|
||||
"(id text, manga_id text, title text, manga_title " \
|
||||
"text, status integer, status_message text, " \
|
||||
"pages integer, success_count integer, " \
|
||||
"fail_count integer, time integer)"
|
||||
db.exec "create unique index if not exists id_idx " \
|
||||
"on queue (id)"
|
||||
db.exec "create index if not exists manga_id_idx " \
|
||||
"on queue (manga_id)"
|
||||
db.exec "create index if not exists status_idx " \
|
||||
"on queue (status)"
|
||||
rescue e
|
||||
Logger.error "Error when checking tables in DB: #{e}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Push an array of jobs into the queue, and return the number of jobs
|
||||
# inserted. Any job already exists in the queue will be ignored.
|
||||
def push(jobs : Array(Job))
|
||||
start_count = self.count
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs.each do |job|
|
||||
db.exec "insert or ignore into queue values " \
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
job.id, job.manga_id, job.title, job.manga_title,
|
||||
job.status.to_i, job.status_message, job.pages,
|
||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||
end
|
||||
end
|
||||
self.count - start_count
|
||||
end
|
||||
|
||||
def reset(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where id = (?)", id
|
||||
end
|
||||
end
|
||||
|
||||
def reset(job : Job)
|
||||
self.reset job.id
|
||||
end
|
||||
|
||||
# Reset all failed tasks (missing pages and error)
|
||||
def reset
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = 0, status_message = '', " \
|
||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||
"where status = 2 or status = 4"
|
||||
end
|
||||
end
|
||||
|
||||
def delete(id : String)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where id = (?)", id
|
||||
end
|
||||
end
|
||||
|
||||
def delete(job : Job)
|
||||
self.delete job.id
|
||||
end
|
||||
|
||||
def delete_status(status : JobStatus)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "delete from queue where status = (?)", status.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def count_status(status : JobStatus)
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue where " \
|
||||
"status = (?)", status.to_i, as: Int32
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def count
|
||||
num = 0
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
num = db.query_one "select count(*) from queue", as: Int32
|
||||
end
|
||||
num
|
||||
end
|
||||
|
||||
def set_status(status : JobStatus, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status = (?) where id = (?)",
|
||||
status.to_i, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def get_all
|
||||
jobs = [] of Job
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||
Job.from_query_result rs
|
||||
end
|
||||
end
|
||||
jobs
|
||||
end
|
||||
|
||||
def add_success(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set success_count = success_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
|
||||
def add_fail(job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||
"where id = (?)", job.id
|
||||
end
|
||||
end
|
||||
|
||||
def set_pages(pages : Int32, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||
"fail_count = 0 where id = (?)", pages, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def add_message(msg : String, job : Job)
|
||||
DB.open "sqlite3://#{@path}" do |db|
|
||||
db.exec "update queue set status_message = " \
|
||||
"status_message || (?) || (?) where id = (?)",
|
||||
"\n", msg, job.id
|
||||
end
|
||||
end
|
||||
|
||||
def <<(downloader : Downloader)
|
||||
@downloaders << downloader
|
||||
end
|
||||
|
||||
def pause
|
||||
@downloaders.each { |d| d.stopped = true }
|
||||
@paused = true
|
||||
end
|
||||
|
||||
def resume
|
||||
@downloaders.each { |d| d.stopped = false }
|
||||
@paused = false
|
||||
end
|
||||
|
||||
def paused?
|
||||
@paused
|
||||
end
|
||||
end
|
||||
@@ -97,6 +97,28 @@ class APIRouter < Router
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/bulk-progress/:action/:title" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
action = env.params.url["action"]
|
||||
ids = env.params.json["ids"].as(Array).map &.as_s
|
||||
|
||||
unless action.in? ["read", "unread"]
|
||||
raise "Unknow action #{action}"
|
||||
end
|
||||
title.bulk_progress action, ids, username
|
||||
rescue e
|
||||
@context.error e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
else
|
||||
send_json env, {"success" => true}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/admin/display_name/:title/:name" do |env|
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["title"])
|
||||
@@ -136,12 +158,12 @@ class APIRouter < Router
|
||||
begin
|
||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||
jobs = chapters.map { |chapter|
|
||||
MangaDex::Job.new(
|
||||
Queue::Job.new(
|
||||
chapter["id"].as_s,
|
||||
chapter["manga_id"].as_s,
|
||||
chapter["full_title"].as_s,
|
||||
chapter["manga_title"].as_s,
|
||||
MangaDex::JobStatus::Pending,
|
||||
Queue::JobStatus::Pending,
|
||||
Time.unix chapter["time"].as_s.to_i
|
||||
)
|
||||
}
|
||||
@@ -179,7 +201,7 @@ class APIRouter < Router
|
||||
case action
|
||||
when "delete"
|
||||
if id.nil?
|
||||
@context.queue.delete_status MangaDex::JobStatus::Completed
|
||||
@context.queue.delete_status Queue::JobStatus::Completed
|
||||
else
|
||||
@context.queue.delete id
|
||||
end
|
||||
@@ -259,5 +281,56 @@ class APIRouter < Router
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/admin/plugin/list" do |env|
|
||||
begin
|
||||
query = env.params.json["query"].as String
|
||||
plugin = Plugin.new env.params.json["plugin"].as String
|
||||
|
||||
json = plugin.list_chapters query
|
||||
chapters = json["chapters"]
|
||||
title = json["title"]
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"chapters" => chapters,
|
||||
"title" => title,
|
||||
}.to_json
|
||||
rescue e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/admin/plugin/download" do |env|
|
||||
begin
|
||||
plugin = Plugin.new env.params.json["plugin"].as String
|
||||
chapters = env.params.json["chapters"].as Array(JSON::Any)
|
||||
manga_title = env.params.json["title"].as String
|
||||
|
||||
jobs = chapters.map { |ch|
|
||||
Queue::Job.new(
|
||||
"#{plugin.info.id}-#{ch["id"]}",
|
||||
"", # manga_id
|
||||
ch["title"].as_s,
|
||||
manga_title,
|
||||
Queue::JobStatus::Pending,
|
||||
Time.utc
|
||||
)
|
||||
}
|
||||
inserted_count = @context.queue.push jobs
|
||||
send_json env, {
|
||||
"success": inserted_count,
|
||||
"fail": jobs.size - inserted_count,
|
||||
}.to_json
|
||||
rescue e
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,9 +39,14 @@ class MainRouter < Router
|
||||
|
||||
get "/library" do |env|
|
||||
begin
|
||||
titles = @context.library.titles
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.from_info_json @context.library.dir, username
|
||||
get_sort_opt
|
||||
|
||||
titles = @context.library.sorted_titles username, sort_opt
|
||||
percentage = titles.map &.load_percentage username
|
||||
|
||||
layout "library"
|
||||
rescue e
|
||||
@context.error e
|
||||
@@ -53,12 +58,18 @@ class MainRouter < Router
|
||||
begin
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
username = get_username env
|
||||
percentage = title.load_percentage_for_all_entries username
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
get_sort_opt
|
||||
|
||||
entries = title.sorted_entries username, sort_opt
|
||||
|
||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
||||
title_percentage = title.titles.map &.load_percentage username
|
||||
layout "title"
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 404
|
||||
env.response.status_code = 500
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,12 +78,32 @@ class MainRouter < Router
|
||||
layout "download"
|
||||
end
|
||||
|
||||
get "/download/plugins" do |env|
|
||||
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"
|
||||
rescue e
|
||||
@context.error e
|
||||
env.response.status_code = 500
|
||||
end
|
||||
end
|
||||
|
||||
get "/" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
continue_reading = @context
|
||||
.library.get_continue_reading_entries username
|
||||
recently_added = @context.library.get_recently_added_entries username
|
||||
start_reading = @context.library.get_start_reading_titles username
|
||||
titles = @context.library.titles
|
||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||
empty_library = titles.size == 0
|
||||
|
||||
@@ -4,11 +4,14 @@ class ReaderRouter < Router
|
||||
def initialize
|
||||
get "/reader/:title/:entry" do |env|
|
||||
begin
|
||||
username = get_username env
|
||||
|
||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||
|
||||
next layout "reader-error" if entry.err_msg
|
||||
|
||||
# load progress
|
||||
username = get_username env
|
||||
page = entry.load_progress username
|
||||
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
|
||||
# library perloads a few pages in advance, and the user
|
||||
@@ -48,7 +51,7 @@ class ReaderRouter < Router
|
||||
next_page = page + IMGS_PER_PAGE
|
||||
next_url = next_entry_url = nil
|
||||
exit_url = "#{base_url}book/#{title.id}"
|
||||
next_entry = entry.next_entry
|
||||
next_entry = entry.next_entry username
|
||||
unless next_page > entry.pages
|
||||
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
||||
end
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
require "kemal"
|
||||
require "kemal-session"
|
||||
require "./library"
|
||||
require "./library/*"
|
||||
require "./handlers/*"
|
||||
require "./util"
|
||||
require "./util/*"
|
||||
require "./routes/*"
|
||||
|
||||
class Context
|
||||
property library : Library
|
||||
property storage : Storage
|
||||
property queue : MangaDex::Queue
|
||||
property queue : Queue
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
use_default
|
||||
|
||||
def initialize
|
||||
@storage = Storage.default
|
||||
@library = Library.default
|
||||
@queue = MangaDex::Queue.default
|
||||
@queue = Queue.default
|
||||
end
|
||||
|
||||
{% for lvl in Logger::LEVELS %}
|
||||
|
||||
@@ -2,7 +2,7 @@ require "sqlite3"
|
||||
require "crypto/bcrypt"
|
||||
require "uuid"
|
||||
require "base64"
|
||||
require "./util"
|
||||
require "./util/*"
|
||||
|
||||
def hash_password(pw)
|
||||
Crypto::Bcrypt::Password.create(pw).to_s
|
||||
@@ -21,12 +21,7 @@ class Storage
|
||||
id: String,
|
||||
is_title: Bool)
|
||||
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
use_default
|
||||
|
||||
def initialize(db_path : String? = nil, init_user = true, *,
|
||||
@auto_close = true)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
require "./util"
|
||||
require "./util/*"
|
||||
|
||||
class Upload
|
||||
def initialize(@dir : String)
|
||||
|
||||
165
src/util.cr
165
src/util.cr
@@ -1,165 +0,0 @@
|
||||
require "big"
|
||||
|
||||
IMGS_PER_PAGE = 5
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||
|
||||
def requesting_static_file(env)
|
||||
request_path_startswith env, STATIC_DIRS
|
||||
end
|
||||
|
||||
macro layout(name)
|
||||
base_url = Config.current.base_url
|
||||
begin
|
||||
is_admin = false
|
||||
if token = env.session.string? "token"
|
||||
is_admin = @context.storage.verify_admin token
|
||||
end
|
||||
page = {{name}}
|
||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||
rescue e
|
||||
message = e.to_s
|
||||
@context.error message
|
||||
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||
end
|
||||
end
|
||||
|
||||
macro send_img(env, img)
|
||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||
end
|
||||
|
||||
macro get_username(env)
|
||||
# if the request gets here, it has gone through the auth handler, and
|
||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||
token = env.session.string "token"
|
||||
(@context.storage.verify_token token).not_nil!
|
||||
end
|
||||
|
||||
def send_json(env, json)
|
||||
env.response.content_type = "application/json"
|
||||
env.response.print json
|
||||
end
|
||||
|
||||
def send_attachment(env, path)
|
||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||
end
|
||||
|
||||
def hash_to_query(hash)
|
||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||
end
|
||||
|
||||
def request_path_startswith(env, ary)
|
||||
ary.each do |prefix|
|
||||
if env.request.path.starts_with? prefix
|
||||
return true
|
||||
end
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def is_numeric(str)
|
||||
/^\d+/.match(str) != nil
|
||||
end
|
||||
|
||||
def split_by_alphanumeric(str)
|
||||
arr = [] of String
|
||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||
arr += match.captures.select { |s| s != "" }
|
||||
end
|
||||
arr
|
||||
end
|
||||
|
||||
def compare_alphanumerically(c, d)
|
||||
is_c_bigger = c.size <=> d.size
|
||||
if c.size > d.size
|
||||
d += [nil] * (c.size - d.size)
|
||||
elsif c.size < d.size
|
||||
c += [nil] * (d.size - c.size)
|
||||
end
|
||||
c.zip(d) do |a, b|
|
||||
return -1 if a.nil?
|
||||
return 1 if b.nil?
|
||||
if is_numeric(a) && is_numeric(b)
|
||||
compare = a.to_big_i <=> b.to_big_i
|
||||
return compare if compare != 0
|
||||
else
|
||||
compare = a <=> b
|
||||
return compare if compare != 0
|
||||
end
|
||||
end
|
||||
is_c_bigger
|
||||
end
|
||||
|
||||
def compare_alphanumerically(a : String, b : String)
|
||||
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
||||
end
|
||||
|
||||
def validate_archive(path : String) : Exception?
|
||||
file = ArchiveFile.new path
|
||||
file.check
|
||||
file.close
|
||||
return
|
||||
rescue e
|
||||
e
|
||||
end
|
||||
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
end
|
||||
|
||||
def redirect(env, path)
|
||||
base = Config.current.base_url
|
||||
env.redirect File.join base, path
|
||||
end
|
||||
|
||||
def validate_username(username)
|
||||
if username.size < 3
|
||||
raise "Username should contain at least 3 characters"
|
||||
end
|
||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||
raise "Username should contain alphanumeric characters " \
|
||||
"and underscores only"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_password(password)
|
||||
if password.size < 6
|
||||
raise "Password should contain at least 6 characters"
|
||||
end
|
||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||
raise "password should contain ASCII characters only"
|
||||
end
|
||||
end
|
||||
|
||||
macro render_xml(path)
|
||||
base_url = Config.current.base_url
|
||||
send_file env, ECR.render({{path}}).to_slice, "application/xml"
|
||||
end
|
||||
|
||||
macro render_component(filename)
|
||||
render "src/views/components/#{{{filename}}}.html.ecr"
|
||||
end
|
||||
|
||||
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
|
||||
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
|
||||
def ctime(file_path : String) : Time
|
||||
res = LibC.stat(file_path, out stat)
|
||||
raise "Unable to get ctime of file #{file_path}" if res != 0
|
||||
|
||||
{% if flag?(:darwin) %}
|
||||
Time.new stat.st_ctimespec, Time::Location::UTC
|
||||
{% else %}
|
||||
Time.new stat.st_ctim, Time::Location::UTC
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def register_mime_types
|
||||
{
|
||||
".zip" => "application/zip",
|
||||
".rar" => "application/x-rar-compressed",
|
||||
".cbz" => "application/vnd.comicbook+zip",
|
||||
".cbr" => "application/vnd.comicbook-rar",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
end
|
||||
112
src/util/chapter_sort.cr
Normal file
112
src/util/chapter_sort.cr
Normal file
@@ -0,0 +1,112 @@
|
||||
# Helper method used to sort chapters in a folder
|
||||
# It respects the keywords like "Vol." and "Ch." in the filenames
|
||||
# This sorting method was initially implemented in JS and done in the frontend.
|
||||
# see https://github.com/hkalexling/Mango/blob/
|
||||
# 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87
|
||||
|
||||
require "big"
|
||||
|
||||
private class Item
|
||||
getter numbers : Hash(String, BigDecimal)
|
||||
|
||||
def initialize(@numbers)
|
||||
end
|
||||
|
||||
# Compare with another Item using keys
|
||||
def <=>(other : Item, keys : Array(String))
|
||||
keys.each do |key|
|
||||
if !@numbers.has_key?(key) && !other.numbers.has_key?(key)
|
||||
next
|
||||
elsif !@numbers.has_key? key
|
||||
return 1
|
||||
elsif !other.numbers.has_key? key
|
||||
return -1
|
||||
elsif @numbers[key] == other.numbers[key]
|
||||
next
|
||||
else
|
||||
return @numbers[key] <=> other.numbers[key]
|
||||
end
|
||||
end
|
||||
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
private class KeyRange
|
||||
getter min : BigDecimal, max : BigDecimal, count : Int32
|
||||
|
||||
def initialize(value : BigDecimal)
|
||||
@min = @max = value
|
||||
@count = 1
|
||||
end
|
||||
|
||||
def update(value : BigDecimal)
|
||||
@min = value if value < @min
|
||||
@max = value if value > @max
|
||||
@count += 1
|
||||
end
|
||||
|
||||
def range
|
||||
@max - @min
|
||||
end
|
||||
end
|
||||
|
||||
class ChapterSorter
|
||||
@sorted_keys = [] of String
|
||||
|
||||
def initialize(str_ary : Array(String))
|
||||
keys = {} of String => KeyRange
|
||||
|
||||
str_ary.each do |str|
|
||||
scan str do |k, v|
|
||||
if keys.has_key? k
|
||||
keys[k].update v
|
||||
else
|
||||
keys[k] = KeyRange.new v
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Get the array of keys string and sort them
|
||||
@sorted_keys = keys.keys
|
||||
# Only use keys that are present in over half of the strings
|
||||
.select do |key|
|
||||
keys[key].count >= str_ary.size / 2
|
||||
end
|
||||
.sort do |a_key, b_key|
|
||||
a = keys[a_key]
|
||||
b = keys[b_key]
|
||||
# Sort keys by the number of times they appear
|
||||
count_compare = b.count <=> a.count
|
||||
if count_compare == 0
|
||||
# Then sort by value range
|
||||
b.range <=> a.range
|
||||
else
|
||||
count_compare
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def compare(a : String, b : String)
|
||||
item_a = str_to_item a
|
||||
item_b = str_to_item b
|
||||
item_a.<=>(item_b, @sorted_keys)
|
||||
end
|
||||
|
||||
private def scan(str, &)
|
||||
str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match|
|
||||
key = match[1]
|
||||
num = match[2].to_big_d
|
||||
|
||||
yield key, num
|
||||
end
|
||||
end
|
||||
|
||||
private def str_to_item(str)
|
||||
numbers = {} of String => BigDecimal
|
||||
scan str do |k, v|
|
||||
numbers[k] = v
|
||||
end
|
||||
Item.new numbers
|
||||
end
|
||||
end
|
||||
42
src/util/numeric_sort.cr
Normal file
42
src/util/numeric_sort.cr
Normal file
@@ -0,0 +1,42 @@
|
||||
# Properly sort alphanumeric strings
|
||||
# Used to sort the images files inside the archives
|
||||
# https://github.com/hkalexling/Mango/issues/12
|
||||
|
||||
require "big"
|
||||
|
||||
def is_numeric(str)
|
||||
/^\d+/.match(str) != nil
|
||||
end
|
||||
|
||||
def split_by_alphanumeric(str)
|
||||
arr = [] of String
|
||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||
arr += match.captures.select { |s| s != "" }
|
||||
end
|
||||
arr
|
||||
end
|
||||
|
||||
def compare_numerically(c, d)
|
||||
is_c_bigger = c.size <=> d.size
|
||||
if c.size > d.size
|
||||
d += [nil] * (c.size - d.size)
|
||||
elsif c.size < d.size
|
||||
c += [nil] * (d.size - c.size)
|
||||
end
|
||||
c.zip(d) do |a, b|
|
||||
return -1 if a.nil?
|
||||
return 1 if b.nil?
|
||||
if is_numeric(a) && is_numeric(b)
|
||||
compare = a.to_big_i <=> b.to_big_i
|
||||
return compare if compare != 0
|
||||
else
|
||||
compare = a <=> b
|
||||
return compare if compare != 0
|
||||
end
|
||||
end
|
||||
is_c_bigger
|
||||
end
|
||||
|
||||
def compare_numerically(a : String, b : String)
|
||||
compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
||||
end
|
||||
63
src/util/util.cr
Normal file
63
src/util/util.cr
Normal file
@@ -0,0 +1,63 @@
|
||||
IMGS_PER_PAGE = 5
|
||||
ENTRIES_IN_HOME_SECTIONS = 8
|
||||
UPLOAD_URL_PREFIX = "/uploads"
|
||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
end
|
||||
|
||||
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
|
||||
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
|
||||
def ctime(file_path : String) : Time
|
||||
res = LibC.stat(file_path, out stat)
|
||||
raise "Unable to get ctime of file #{file_path}" if res != 0
|
||||
|
||||
{% if flag?(:darwin) %}
|
||||
Time.new stat.st_ctimespec, Time::Location::UTC
|
||||
{% else %}
|
||||
Time.new stat.st_ctim, Time::Location::UTC
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def register_mime_types
|
||||
{
|
||||
".zip" => "application/zip",
|
||||
".rar" => "application/x-rar-compressed",
|
||||
".cbz" => "application/vnd.comicbook+zip",
|
||||
".cbr" => "application/vnd.comicbook-rar",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
end
|
||||
|
||||
struct Int
|
||||
def or(other : Int)
|
||||
if self == 0
|
||||
other
|
||||
else
|
||||
self
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
struct Nil
|
||||
def or(other : Int)
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
macro use_default
|
||||
def self.default : self
|
||||
unless @@default
|
||||
@@default = new
|
||||
end
|
||||
@@default.not_nil!
|
||||
end
|
||||
end
|
||||
|
||||
class String
|
||||
def alphanumeric_underscore?
|
||||
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
||||
end
|
||||
end
|
||||
31
src/util/validation.cr
Normal file
31
src/util/validation.cr
Normal file
@@ -0,0 +1,31 @@
|
||||
def validate_username(username)
|
||||
if username.size < 3
|
||||
raise "Username should contain at least 3 characters"
|
||||
end
|
||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||
raise "Username should contain alphanumeric characters " \
|
||||
"and underscores only"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_password(password)
|
||||
if password.size < 6
|
||||
raise "Password should contain at least 6 characters"
|
||||
end
|
||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||
raise "password should contain ASCII characters only"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_archive(path : String) : Exception?
|
||||
file = nil
|
||||
begin
|
||||
file = ArchiveFile.new path
|
||||
file.check
|
||||
file.close
|
||||
return
|
||||
rescue e
|
||||
file.close unless file.nil?
|
||||
e
|
||||
end
|
||||
end
|
||||
83
src/util/web.cr
Normal file
83
src/util/web.cr
Normal file
@@ -0,0 +1,83 @@
|
||||
# Web related helper functions/macros
|
||||
|
||||
macro layout(name)
|
||||
base_url = Config.current.base_url
|
||||
begin
|
||||
is_admin = false
|
||||
if token = env.session.string? "token"
|
||||
is_admin = @context.storage.verify_admin token
|
||||
end
|
||||
page = {{name}}
|
||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||
rescue e
|
||||
message = e.to_s
|
||||
@context.error message
|
||||
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||
end
|
||||
end
|
||||
|
||||
macro send_img(env, img)
|
||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||
end
|
||||
|
||||
macro get_username(env)
|
||||
# if the request gets here, it has gone through the auth handler, and
|
||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||
token = env.session.string "token"
|
||||
(@context.storage.verify_token token).not_nil!
|
||||
end
|
||||
|
||||
def send_json(env, json)
|
||||
env.response.content_type = "application/json"
|
||||
env.response.print json
|
||||
end
|
||||
|
||||
def send_attachment(env, path)
|
||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||
end
|
||||
|
||||
def redirect(env, path)
|
||||
base = Config.current.base_url
|
||||
env.redirect File.join base, path
|
||||
end
|
||||
|
||||
def hash_to_query(hash)
|
||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||
end
|
||||
|
||||
def request_path_startswith(env, ary)
|
||||
ary.each do |prefix|
|
||||
if env.request.path.starts_with? prefix
|
||||
return true
|
||||
end
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def requesting_static_file(env)
|
||||
request_path_startswith env, STATIC_DIRS
|
||||
end
|
||||
|
||||
macro render_xml(path)
|
||||
base_url = Config.current.base_url
|
||||
send_file env, ECR.render({{path}}).to_slice, "application/xml"
|
||||
end
|
||||
|
||||
macro render_component(filename)
|
||||
render "src/views/components/#{{{filename}}}.html.ecr"
|
||||
end
|
||||
|
||||
macro get_sort_opt
|
||||
sort_method = env.params.query["sort"]?
|
||||
|
||||
if sort_method
|
||||
is_ascending = true
|
||||
|
||||
ascend = env.params.query["ascend"]?
|
||||
if ascend && ascend.to_i? == 0
|
||||
is_ascending = false
|
||||
end
|
||||
|
||||
sort_opt = SortOptions.new sort_method, is_ascending
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,14 @@
|
||||
<span hidden></span>
|
||||
</span>
|
||||
</li>
|
||||
<li data-url="<%= base_url %>admin/downloads">Download Manager</li>
|
||||
<li class="nopointer">
|
||||
<span>Theme</span>
|
||||
<select id="theme-select" class="uk-select uk-align-right uk-width-1-3@m uk-width-1-2">
|
||||
<option>Dark</option>
|
||||
<option>Light</option>
|
||||
<option>System</option>
|
||||
</select>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="uk-divider-icon">
|
||||
|
||||
@@ -9,35 +9,50 @@
|
||||
<% grouped_count = 1 %>
|
||||
<% end %>
|
||||
|
||||
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress %>"
|
||||
<div class="item"
|
||||
<% if item.is_a? Entry %>
|
||||
id="<%= item.id %>"
|
||||
<% end %>>
|
||||
|
||||
<div class="acard
|
||||
<% if item.is_a? Entry %>
|
||||
<% if item.is_a? Entry && item.err_msg.nil? %>
|
||||
<%= "is_entry" %>
|
||||
<% end %>
|
||||
"
|
||||
<% if item.is_a? Entry %>
|
||||
data-encoded-path="<%= item.encoded_path %>"
|
||||
data-pages="<%= item.pages %>"
|
||||
data-progress="<%= (progress * 100).round(1) %>"
|
||||
data-encoded-book-title="<%= item.book.encoded_display_name %>"
|
||||
data-encoded-title="<%= item.encoded_display_name %>"
|
||||
data-book-id="<%= item.book.id %>"
|
||||
data-id="<%= item.id %>"
|
||||
<% if item.err_msg %>
|
||||
onclick="location='<%= base_url %>reader/<%= item.book.id %>/<%= item.id %>'"
|
||||
<% else %>
|
||||
data-encoded-path="<%= item.encoded_path %>"
|
||||
data-pages="<%= item.pages %>"
|
||||
data-progress="<%= (progress * 100).round(1) %>"
|
||||
data-encoded-book-title="<%= item.book.encoded_display_name %>"
|
||||
data-encoded-title="<%= item.encoded_display_name %>"
|
||||
data-book-id="<%= item.book.id %>"
|
||||
data-id="<%= item.id %>"
|
||||
<% end %>
|
||||
<% else %>
|
||||
onclick="location='<%= base_url %>book/<%= item.id %>'"
|
||||
<% end %>>
|
||||
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top">
|
||||
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
|
||||
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
|
||||
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
|
||||
x-init="disabled = false"
|
||||
<% end %>>
|
||||
<div class="uk-card-media-top uk-inline" @mouseenter="hover = true" @mouseleave="hover = false">
|
||||
<img data-src="<%= item.cover_url %>" width="100%" height="100%" alt="" uk-img
|
||||
<% if item.is_a? Entry && item.err_msg %>
|
||||
class="grayscale"
|
||||
<% end %>>
|
||||
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
|
||||
<div class="uk-position-center">
|
||||
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-card-body">
|
||||
<% unless progress < 0 || progress > 100 %>
|
||||
<% unless progress < 0 || progress > 100 || progress.nan? %>
|
||||
<div class="uk-card-badge label"><%= (progress * 100).round(1) %>%</div>
|
||||
<% end %>
|
||||
|
||||
@@ -45,13 +60,18 @@
|
||||
<% if page == "home" && item.is_a? Entry %>
|
||||
<%= "uk-margin-remove-bottom" %>
|
||||
<% end %>
|
||||
" data-title="<%= HTML.escape(item.display_name) %>"><%= item.display_name %>
|
||||
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
|
||||
</h3>
|
||||
<% if page == "home" && item.is_a? Entry %>
|
||||
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
|
||||
<% end %>
|
||||
<% if item.is_a? Entry %>
|
||||
<p class="uk-text-meta"><%= item.pages %> pages</p>
|
||||
<% if item.err_msg %>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom">Error <span uk-icon="info"></span></p>
|
||||
<div uk-dropdown><%= item.err_msg %></div>
|
||||
<% else %>
|
||||
<p class="uk-text-meta"><%= item.pages %> pages</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if item.is_a? Title %>
|
||||
<% if grouped_count == 1 %>
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js" defer></script>
|
||||
<script src="<%= base_url %>js/theme.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<div class="uk-form-horizontal">
|
||||
<select class="uk-select" id="sort-select">
|
||||
<% hash.each do |k, v| %>
|
||||
<option id="<%= k %>-up">â–˛ <%= v %></option>
|
||||
<option id="<%= k %>-down">â–Ľ <%= v %></option>
|
||||
<option id="<%= k %>-up"
|
||||
<% if sort_opt && k == sort_opt.method.to_s.underscore && sort_opt.ascend %>
|
||||
<%= "selected" %>
|
||||
<% end %>>â–˛ <%= v %></option>
|
||||
<option id="<%= k %>-down"
|
||||
<% if sort_opt && k == sort_opt.method.to_s.underscore && !sort_opt.ascend %>
|
||||
<%= "selected" %>
|
||||
<% end %>>â–Ľ <%= v %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<th>Progress</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Plugin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<%- unless continue_reading.empty? -%>
|
||||
<h2 class="uk-title home-headings">Continue Reading</h2>
|
||||
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- continue_reading.each do |cr| -%>
|
||||
<% item = cr[:entry] %>
|
||||
<% progress = cr[:percentage] %>
|
||||
@@ -50,9 +50,20 @@
|
||||
</div>
|
||||
<%- end -%>
|
||||
|
||||
<%- unless start_reading.empty? -%>
|
||||
<h2 class="uk-title home-headings">Start Reading</h2>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- start_reading.each do |t| -%>
|
||||
<% item = t %>
|
||||
<% progress = 0.0 %>
|
||||
<%= render_component "card" %>
|
||||
<%- end -%>
|
||||
</div>
|
||||
<%- end -%>
|
||||
|
||||
<%- unless recently_added.empty? -%>
|
||||
<h2 class="uk-title home-headings">Recently Added</h2>
|
||||
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<%- recently_added.each do |ra| -%>
|
||||
<% item = ra %>
|
||||
<% progress = ra[:percentage] %>
|
||||
|
||||
@@ -8,12 +8,19 @@
|
||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li><a href="<%= base_url %>download">Download</a></li>
|
||||
<li class="uk-parent">
|
||||
<a href="#">Download</a>
|
||||
<ul class="uk-nav-sub">
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
<hr uk-divider>
|
||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||
@@ -35,7 +42,18 @@
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
<% if is_admin %>
|
||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||
<li><a href="<%= base_url %>download">Download</a></li>
|
||||
<li>
|
||||
<a href="#">Download</a>
|
||||
<div class="uk-navbar-dropdown">
|
||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||
<li class="uk-nav-header">Source</li>
|
||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li class="uk-nav-divider"></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -56,11 +74,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
setTheme();
|
||||
const base_url = "<%= base_url %>";
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
|
||||
<%= yield_content "script" %>
|
||||
</body>
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
</div>
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<% hash = {
|
||||
"name" => "Name",
|
||||
"date" => "Date Modified",
|
||||
"auto" => "Auto",
|
||||
"time_modified" => "Date Modified",
|
||||
"progress" => "Progress"
|
||||
} %>
|
||||
<%= render_component "sort-form" %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% titles.each_with_index do |item, i| %>
|
||||
<% progress = percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
setTheme(getTheme());
|
||||
setTheme();
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||
|
||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.title_id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||
|
||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.title_id %>/<%= e.id %>" />
|
||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.title_id %>" />
|
||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||
</entry>
|
||||
<% end %>
|
||||
|
||||
|
||||
75
src/views/plugin-download.html.ecr
Normal file
75
src/views/plugin-download.html.ecr
Normal file
@@ -0,0 +1,75 @@
|
||||
<% if plugins.empty? %>
|
||||
<div class="uk-container uk-text-center">
|
||||
<h2>No Plugins Found</h2>
|
||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<h2 class=uk-title>Download with Plugins</h2>
|
||||
|
||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </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 class="uk-width-expand">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="plugin-select" class="uk-select">
|
||||
<% plugins.each do |p| %>
|
||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uk-width-auto">
|
||||
<div class="uk-margin">
|
||||
<label class="uk-form-label" for="search-input"> </label>
|
||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||
</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 %>
|
||||
<% if plugin %>
|
||||
<script>
|
||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
||||
</script>
|
||||
<% end %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||
<% end %>
|
||||
31
src/views/reader-error.html.ecr
Normal file
31
src/views/reader-error.html.ecr
Normal file
@@ -0,0 +1,31 @@
|
||||
<div id="modal" class="uk-flex-top" uk-modal>
|
||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<div>
|
||||
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
|
||||
</div>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
|
||||
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<p uk-margin>
|
||||
<% if next_entry = entry.next_entry username %>
|
||||
<a class="uk-button uk-button-default" href="<%= base_url %>reader/<%= entry.book.id %>/<%= next_entry.id %>">Next Entry</a>
|
||||
<% end %>
|
||||
<a class="uk-button uk-button-primary" href="<%= base_url %>book/<%= entry.book.id %>">Return to Title</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script>
|
||||
UIkit.modal('#modal').show().then(function() {
|
||||
styleModal();
|
||||
});
|
||||
UIkit.util.on('#modal', 'hide', function() {
|
||||
location.href = "<%= base_url %>book/<%= entry.book.id %>";
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
||||
<div class="uk-container uk-container-small">
|
||||
<%- urls.each_with_index do |url, i| -%>
|
||||
<img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
|
||||
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
|
||||
<%- end -%>
|
||||
<%- if next_url -%>
|
||||
<a class="next-url" href="<%= next_url %>"></a>
|
||||
@@ -53,9 +53,9 @@
|
||||
const base_url = "<%= base_url %>"
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
||||
<script src="<%= base_url %>js/reader.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
<div class="data" data-title-id="<%= title.id %>"></div>
|
||||
<div>
|
||||
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
|
||||
<div class="uk-child-width-1-3" uk-grid>
|
||||
<div>
|
||||
<p x-text="count + ' items selected'" style="color:orange"></p>
|
||||
</div>
|
||||
<div class="uk-text-center" id="select-bar-controls">
|
||||
<a class="uk-icon uk-margin-right" uk-tooltip="title: Mark selected as read" href="" @click.prevent="bulkProgress('read', $el)">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</a>
|
||||
<a class="uk-icon" uk-tooltip="title: Mark selected as unread" href="" @click.prevent="bulkProgress('unread', $el)">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="uk-text-right">
|
||||
<a @click="selectAll()" uk-tooltip="title: Select all"><i class="fas fa-check-double uk-margin-small-right"></i></a>
|
||||
<a @click="deselectAll();" uk-tooltip="title: Deselect all"><i class="fas fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||
|
||||
<% if is_admin %>
|
||||
@@ -25,19 +43,23 @@
|
||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||
<% hash = {
|
||||
"auto" => "Auto",
|
||||
"name" => "Name",
|
||||
"date" => "Date Modified",
|
||||
"title" => "Name",
|
||||
"time_modified" => "Date Modified",
|
||||
"time_added" => "Date Added",
|
||||
"progress" => "Progress"
|
||||
} %>
|
||||
<%= render_component "sort-form" %>
|
||||
</div>
|
||||
</div>
|
||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% title.titles.each_with_index do |item, i| %>
|
||||
<% progress = title_percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
<% end %>
|
||||
<% title.entries.each_with_index do |item, i| %>
|
||||
</div>
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||
<% entries.each_with_index do |item, i| %>
|
||||
<% progress = percentage[i] %>
|
||||
<%= render_component "card" %>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user