mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79aa816ca8 | |||
| e35cf2ce0c | |||
| 47ba0e39af | |||
| aedb13ac92 | |||
| d1c0e52f90 | |||
| 173ff2d2e6 | |||
| ae281e2e21 | |||
| 2c10623731 | |||
| 31da5acdc5 | |||
| 77237a274a | |||
| 318501bc9b | |||
| dc5284968d | |||
| 01216d806c | |||
| c4ffb5cd59 | |||
| 50ce0e2b54 | |||
| 8b8967de26 | |||
| 335fb45de6 | |||
| 00d2540b95 | |||
| d120433525 | |||
| 9536ce62e6 | |||
| 4ba81b9ffe | |||
| c355c67415 | |||
| 4def23a5cf | |||
| 943076ccf7 | |||
| 36034042f2 | |||
| 36e2b2bfaf | |||
| c6c908953b | |||
| 3ae0ad6348 | |||
| 7ca40215b6 | |||
| 54206bc6ac | |||
| 1abdac2fdd | |||
| 9ffe896705 | |||
| 7a7c855ce4 | |||
| e2d01f7eb9 | |||
| 7575785c1c | |||
| dfd53bc51d | |||
| f140ffa4b2 | |||
| 589483cd75 | |||
| 306edc3c77 | |||
| 30af64e9ca | |||
| fecb96c91b | |||
| 4f01aba3e1 | |||
| f13f7989d5 | |||
| 1ce553f541 | |||
| c4253db572 | |||
| db6d33eae1 | |||
| 8fbc5528a8 | |||
| d50804830d | |||
| 5d7bbc7c9b | |||
| 0b463539c9 | |||
| 7f0088f45a | |||
| 5645f272df | |||
| dc3bbd10d6 | |||
| c89c74c71b | |||
| cb76a96126 | |||
| 73b38492ba | |||
| bf37c4aa10 | |||
| f837be0718 | |||
| 8c47d50291 | |||
| 4ca8daca29 | |||
| d3d8dff6d2 | |||
| f11a5cd608 | |||
| 6bccba16da | |||
| 28ac5c7a00 | |||
| f8e0c6d795 | |||
| e3d505d62b | |||
| 77864afa67 | |||
| 5abdca24c2 | |||
| e8c365b7a1 | |||
| 6659041631 | |||
| fa50f4cb88 | |||
| c39a1ddbaf | |||
| 7de01991a0 | |||
| 319967438b | |||
| 1bbb08eede | |||
| d9d1dbc26f | |||
| c33884ea29 | |||
| 2dd980b92c | |||
| 89e747d3ee | |||
| 468f109776 | |||
| 905d02e911 | |||
| bb00c2e77f | |||
| bc75f4d336 | |||
| 98baf63b0c | |||
| 46b36860d1 | |||
| 9f6261e02d | |||
| d782995bac | |||
| b264f7dd76 | |||
| 59622930c7 | |||
| e90b97ca43 | |||
| b58d2e3620 | |||
| a507e3be7a | |||
| 67d3d2bd55 | |||
| 5ec35f3af6 | |||
| 0fa95959a7 | |||
| 83597e7f84 | |||
| c893135ec6 | |||
| a3356344fa | |||
| aecac748dc | |||
| c449a1e9b1 | |||
| f9a4698fca | |||
| 676f2ae032 | |||
| fd342fe1ee | |||
| 1649f286aa | |||
| 60a1032f71 |
Binary file not shown.
|
After Width: | Height: | Size: 598 KiB |
@@ -10,12 +10,18 @@ uglify:
|
|||||||
build: libs
|
build: libs
|
||||||
crystal build src/mango.cr --release --progress
|
crystal build src/mango.cr --release --progress
|
||||||
|
|
||||||
|
static: uglify | libs
|
||||||
|
crystal build src/mango.cr --release --progress --static
|
||||||
|
|
||||||
libs:
|
libs:
|
||||||
shards install
|
shards install
|
||||||
|
|
||||||
run:
|
run:
|
||||||
crystal run src/mango.cr --error-trace
|
crystal run src/mango.cr --error-trace
|
||||||
|
|
||||||
|
test:
|
||||||
|
crystal spec
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cp mango $(INSTALL_DIR)/mango
|
cp mango $(INSTALL_DIR)/mango
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
# Mango
|
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
# Mango
|
||||||
|
|
||||||
|
[](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
- Multi-user support
|
- Multi-user support
|
||||||
|
- Dark/light mode switch
|
||||||
- Supports both `.zip` and `.cbz` formats
|
- Supports both `.zip` and `.cbz` formats
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
|
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango e-manga server/reader. Version 0.1.0
|
Mango e-manga server/reader. Version 0.2.0
|
||||||
|
|
||||||
-v, --version Show version
|
-v, --version Show version
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
@@ -56,6 +63,12 @@ library_path: ~/mango/library
|
|||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
log_level: info
|
log_level: info
|
||||||
|
mangadex:
|
||||||
|
base_url: https://mangadex.org
|
||||||
|
api_url: https://mangadex.org/api
|
||||||
|
download_wait_seconds: 5
|
||||||
|
download_retries: 4
|
||||||
|
download_queue_db_path: ~/mango/queue.db
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
||||||
@@ -91,6 +104,10 @@ Title:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Dark mode:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Reader:
|
Reader:
|
||||||
|
|
||||||

|

|
||||||
@@ -98,3 +115,7 @@ Reader:
|
|||||||
Mobile UI:
|
Mobile UI:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@ const minifyCss = require('gulp-minify-css');
|
|||||||
|
|
||||||
gulp.task('minify-js', () => {
|
gulp.task('minify-js', () => {
|
||||||
return gulp.src('public/js/*.js')
|
return gulp.src('public/js/*.js')
|
||||||
.pipe(minify())
|
.pipe(minify({
|
||||||
|
removeConsole: true
|
||||||
|
}))
|
||||||
.pipe(gulp.dest('dist/js'));
|
.pipe(gulp.dest('dist/js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
.uk-alert-close {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
.uk-card-body {
|
.uk-card-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -26,3 +29,18 @@
|
|||||||
.uk-search {
|
.uk-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
#selectable .ui-selecting {
|
||||||
|
background: #EEE6B9;
|
||||||
|
}
|
||||||
|
#selectable .ui-selected {
|
||||||
|
background: #F4E487;
|
||||||
|
}
|
||||||
|
#selectable .ui-selecting.dark {
|
||||||
|
background: #5E5731;
|
||||||
|
}
|
||||||
|
#selectable .ui-selected.dark {
|
||||||
|
background: #9D9252;
|
||||||
|
}
|
||||||
|
td > .uk-dropdown {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
const alert = (level, text) => {
|
||||||
|
$('#alert').empty();
|
||||||
|
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
|
||||||
|
$('#alert').append(html);
|
||||||
|
$("html, body").animate({ scrollTop: 0 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
$(() => {
|
||||||
|
$('input.uk-checkbox').each((i, e) => {
|
||||||
|
$(e).change(() => {
|
||||||
|
loadConfig();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
loadConfig();
|
||||||
|
load();
|
||||||
|
|
||||||
|
const intervalMS = 5000;
|
||||||
|
setTimeout(() => {
|
||||||
|
setInterval(() => {
|
||||||
|
if (globalConfig.autoRefresh !== true) return;
|
||||||
|
load();
|
||||||
|
}, intervalMS);
|
||||||
|
}, intervalMS);
|
||||||
|
});
|
||||||
|
var globalConfig = {};
|
||||||
|
var loading = false;
|
||||||
|
|
||||||
|
const loadConfig = () => {
|
||||||
|
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
||||||
|
};
|
||||||
|
const remove = (id) => {
|
||||||
|
var url = '/api/admin/mangadex/queue/delete';
|
||||||
|
if (id !== undefined)
|
||||||
|
url += '?' + $.param({id: id});
|
||||||
|
console.log(url);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const refresh = (id) => {
|
||||||
|
var url = '/api/admin/mangadex/queue/retry';
|
||||||
|
if (id !== undefined)
|
||||||
|
url += '?' + $.param({id: id});
|
||||||
|
console.log(url);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to restart download job. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggle = () => {
|
||||||
|
$('#pause-resume-btn').attr('disabled', '');
|
||||||
|
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
||||||
|
const action = paused ? 'resume' : 'pause';
|
||||||
|
const url = `/api/admin/mangadex/queue/${action}`;
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
load();
|
||||||
|
$('#pause-resume-btn').removeAttr('disabled');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const load = () => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
console.log('fetching');
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: '/api/admin/mangadex/queue',
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(data);
|
||||||
|
const btnText = data.paused ? "Resume download" : "Pause download";
|
||||||
|
$('#pause-resume-btn').text(btnText);
|
||||||
|
$('#pause-resume-btn').removeAttr('hidden');
|
||||||
|
const rows = data.jobs.map(obj => {
|
||||||
|
var cls = 'uk-label ';
|
||||||
|
if (obj.status === 'Completed')
|
||||||
|
cls += 'uk-label-success';
|
||||||
|
if (obj.status === 'Error')
|
||||||
|
cls += 'uk-label-danger';
|
||||||
|
if (obj.status === 'MissingPages')
|
||||||
|
cls += 'uk-label-warning';
|
||||||
|
|
||||||
|
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
||||||
|
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
||||||
|
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.success_count}/${obj.pages}</td>
|
||||||
|
<td>${moment(obj.time).fromNow()}</td>
|
||||||
|
<td>${statusSpan} ${dropdown}</td>
|
||||||
|
<td>
|
||||||
|
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
||||||
|
${retryBtn}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
||||||
|
$('tbody').remove();
|
||||||
|
$('table').append(tbody);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
$(() => {
|
||||||
|
$('#search-input').keypress(event => {
|
||||||
|
if (event.which === 13) {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('.filter-field').each((i, ele) => {
|
||||||
|
$(ele).change(() => {
|
||||||
|
buildTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const selectAll = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const unselect = () => {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const download = () => {
|
||||||
|
const selected = $('tbody > tr.ui-selected');
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
$('#download-btn').attr('hidden', '');
|
||||||
|
$('#download-spinner').removeAttr('hidden');
|
||||||
|
const ids = selected.map((i, e) => {
|
||||||
|
return $(e).find('td').first().text();
|
||||||
|
}).get();
|
||||||
|
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
|
console.log(ids);
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: '/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 = '/admin/downloads';
|
||||||
|
});
|
||||||
|
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 {
|
||||||
|
$('#search-btn').removeAttr('hidden');
|
||||||
|
$('#spinner').attr('hidden', '');
|
||||||
|
}
|
||||||
|
searching = !searching;
|
||||||
|
};
|
||||||
|
var searching = false;
|
||||||
|
var globalChapters;
|
||||||
|
const search = () => {
|
||||||
|
if (searching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#manga-details').attr('hidden', '');
|
||||||
|
$('#filter-form').attr('hidden', '');
|
||||||
|
$('table').attr('hidden', '');
|
||||||
|
$('#selection-controls').attr('hidden', '');
|
||||||
|
$('#filter-notification').attr('hidden', '');
|
||||||
|
toggleSpinner();
|
||||||
|
const input = $('input').val();
|
||||||
|
|
||||||
|
if (input === "") {
|
||||||
|
toggleSpinner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var int_id = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = new URL(input).pathname;
|
||||||
|
const match = /\/title\/([0-9]+)/.exec(path);
|
||||||
|
int_id = parseInt(match[1]);
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
int_id = parseInt(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int_id <= 0 || isNaN(int_id)) {
|
||||||
|
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
||||||
|
toggleSpinner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.getJSON("/api/admin/mangadex/manga/" + int_id)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cover = baseURL + data.cover_url;
|
||||||
|
$('#cover').attr("src", cover);
|
||||||
|
$('#title').text("Title: " + data.title);
|
||||||
|
$('#artist').text("Artist: " + data.artist);
|
||||||
|
$('#author').text("Author: " + data.author);
|
||||||
|
|
||||||
|
$('#manga-details').removeAttr('hidden');
|
||||||
|
|
||||||
|
console.log(data.chapters);
|
||||||
|
globalChapters = data.chapters;
|
||||||
|
|
||||||
|
let langs = new Set();
|
||||||
|
let group_names = new Set();
|
||||||
|
data.chapters.forEach(chp => {
|
||||||
|
Object.entries(chp.groups).forEach(([k, v]) => {
|
||||||
|
group_names.add(k);
|
||||||
|
});
|
||||||
|
langs.add(chp.language);
|
||||||
|
});
|
||||||
|
|
||||||
|
const comp = (a, b) => {
|
||||||
|
var ai;
|
||||||
|
var bi;
|
||||||
|
try {ai = parseFloat(a);} catch(e) {}
|
||||||
|
try {bi = parseFloat(b);} catch(e) {}
|
||||||
|
if (typeof ai === 'undefined') return -1;
|
||||||
|
if (typeof bi === 'undefined') return 1;
|
||||||
|
if (ai < bi) return 1;
|
||||||
|
if (ai > bi) return -1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
langs = [...langs].sort();
|
||||||
|
group_names = [...group_names].sort();
|
||||||
|
|
||||||
|
langs.unshift('All');
|
||||||
|
group_names.unshift('All');
|
||||||
|
|
||||||
|
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
|
||||||
|
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
|
||||||
|
|
||||||
|
$('#filter-form').removeAttr('hidden');
|
||||||
|
|
||||||
|
buildTable();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
toggleSpinner();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const parseRange = str => {
|
||||||
|
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||||
|
const matches = str.match(regex);
|
||||||
|
var num;
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
|
// e.g., <= 30
|
||||||
|
num = parseInt(matches[2]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
switch (matches[1]) {
|
||||||
|
case '<':
|
||||||
|
return [null, num - 1];
|
||||||
|
case '<=':
|
||||||
|
return [null, num];
|
||||||
|
case '>':
|
||||||
|
return [num + 1, null];
|
||||||
|
case '>=':
|
||||||
|
return [num, null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (typeof matches[3] !== 'undefined') {
|
||||||
|
// a single number
|
||||||
|
num = parseInt(matches[3]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, num];
|
||||||
|
}
|
||||||
|
else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
|
// e.g., 10 - 23
|
||||||
|
num = parseInt(matches[4]);
|
||||||
|
const n2 = parseInt(matches[5]);
|
||||||
|
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||||
|
alert('danger', `Failed to parse filter input ${str}`);
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, n2];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// empty or space only
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getFilters = () => {
|
||||||
|
const filters = {};
|
||||||
|
$('.uk-select').each((i, ele) => {
|
||||||
|
const id = $(ele).attr('id');
|
||||||
|
const by = id.split('-')[0];
|
||||||
|
const choice = $(ele).val();
|
||||||
|
filters[by] = choice;
|
||||||
|
});
|
||||||
|
filters.volume = parseRange($('#volume-range').val());
|
||||||
|
filters.chapter = parseRange($('#chapter-range').val());
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
const buildTable = () => {
|
||||||
|
$('table').attr('hidden', '');
|
||||||
|
$('#selection-controls').attr('hidden', '');
|
||||||
|
$('#filter-notification').attr('hidden', '');
|
||||||
|
console.log('rebuilding table');
|
||||||
|
const filters = getFilters();
|
||||||
|
console.log('filters:', filters);
|
||||||
|
var chapters = globalChapters.slice();
|
||||||
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
|
if (v === 'All') return;
|
||||||
|
if (k === 'group') {
|
||||||
|
chapters = chapters.filter(c => v in c.groups);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === 'lang') {
|
||||||
|
chapters = chapters.filter(c => c.language === v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lb = parseFloat(v[0]);
|
||||||
|
const ub = parseFloat(v[1]);
|
||||||
|
if (isNaN(lb) && isNaN(ub)) return;
|
||||||
|
chapters = chapters.filter(c => {
|
||||||
|
const val = parseFloat(c[k]);
|
||||||
|
if (isNaN(val)) return false;
|
||||||
|
if (isNaN(lb))
|
||||||
|
return val <= ub;
|
||||||
|
else if (isNaN(ub))
|
||||||
|
return val >= lb;
|
||||||
|
else
|
||||||
|
return val >= lb && val <= ub;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('filtered chapters:', chapters);
|
||||||
|
$('#count-text').text(`${chapters.length} chapters found`);
|
||||||
|
|
||||||
|
const chaptersLimit = 1000;
|
||||||
|
if (chapters.length > chaptersLimit) {
|
||||||
|
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
||||||
|
$('#filter-notification').removeAttr('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = chapters.map(chp => {
|
||||||
|
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
||||||
|
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
||||||
|
}).join(' | ');
|
||||||
|
const dark = getTheme() === 'dark' ? 'dark' : '';
|
||||||
|
return `<tr class="ui-widget-content ${dark}">
|
||||||
|
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
||||||
|
<td>${chp.title}</td>
|
||||||
|
<td>${chp.language}</td>
|
||||||
|
<td>${group_str}</td>
|
||||||
|
<td>${chp.volume}</td>
|
||||||
|
<td>${chp.chapter}</td>
|
||||||
|
<td>${moment.unix(chp.time).fromNow()}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
||||||
|
$('tbody').remove();
|
||||||
|
$('table').append(tbody);
|
||||||
|
$('table').removeAttr('hidden');
|
||||||
|
$("#selectable").selectable({
|
||||||
|
filter: 'tr'
|
||||||
|
});
|
||||||
|
$('#selection-controls').removeAttr('hidden');
|
||||||
|
};
|
||||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -58,8 +58,12 @@ $('#page-select').change(function(){
|
|||||||
jumpTo(parseInt($('#page-select').val()));
|
jumpTo(parseInt($('#page-select').val()));
|
||||||
});
|
});
|
||||||
function showControl(idx) {
|
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);
|
$('#page-select').val(idx);
|
||||||
UIkit.modal($('#modal-sections')).show();
|
UIkit.modal($('#modal-sections')).show();
|
||||||
|
styleModal();
|
||||||
}
|
}
|
||||||
function jumpTo(page) {
|
function jumpTo(page) {
|
||||||
var ary = window.location.pathname.split('/');
|
var ary = window.location.pathname.split('/');
|
||||||
|
|||||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,124 @@
|
|||||||
|
$(() => {
|
||||||
|
const sortItems = () => {
|
||||||
|
const sort = $('#sort-select').find(':selected').attr('id');
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
// then sort by range of the key
|
||||||
|
return (keyRange[a][1] - keyRange[a][0]) < (keyRange[b][1] - keyRange[b][0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
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 = $(a).attr('data-progress');
|
||||||
|
const bp = $(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;
|
||||||
|
else
|
||||||
|
return !res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
$('#item-container').append(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#sort-select').change(() => {
|
||||||
|
sortItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($('option#auto-up').length > 0)
|
||||||
|
$('option#auto-up').attr('selected', '');
|
||||||
|
else
|
||||||
|
$('option#name-up').attr('selected', '');
|
||||||
|
|
||||||
|
sortItems();
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const getTheme = () => {
|
||||||
|
var theme = localStorage.getItem('theme');
|
||||||
|
if (!theme) theme = 'light';
|
||||||
|
return theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTheme = theme => {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = getTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(newTheme);
|
||||||
|
saveTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/28344281
|
||||||
|
const hasClass = (ele,cls) => {
|
||||||
|
return !!ele.className.match(new RegExp('(\\s|^)'+cls+'(\\s|$)'));
|
||||||
|
};
|
||||||
|
const addClass = (ele,cls) => {
|
||||||
|
if (!hasClass(ele,cls)) ele.className += " "+cls;
|
||||||
|
};
|
||||||
|
const removeClass = (ele,cls) => {
|
||||||
|
if (hasClass(ele,cls)) {
|
||||||
|
var reg = new RegExp('(\\s|^)'+cls+'(\\s|$)');
|
||||||
|
ele.className=ele.className.replace(reg,' ');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addClassToClass = (targetCls, newCls) => {
|
||||||
|
const elements = document.getElementsByClassName(targetCls);
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
addClass(elements[i], newCls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeClassFromClass = (targetCls, newCls) => {
|
||||||
|
const elements = document.getElementsByClassName(targetCls);
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
removeClass(elements[i], newCls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = themeStr => {
|
||||||
|
if (themeStr === 'dark') {
|
||||||
|
document.getElementsByTagName('html')[0].style.background = 'rgb(20, 20, 20)';
|
||||||
|
addClass(document.getElementsByTagName('body')[0], 'uk-light');
|
||||||
|
addClassToClass('uk-card', 'uk-card-secondary');
|
||||||
|
removeClassFromClass('uk-card', 'uk-card-default');
|
||||||
|
addClassToClass('ui-widget-content', 'dark');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.getElementsByTagName('html')[0].style.background = '';
|
||||||
|
removeClass(document.getElementsByTagName('body')[0], 'uk-light');
|
||||||
|
removeClassFromClass('uk-card', 'uk-card-secondary');
|
||||||
|
addClassToClass('uk-card', 'uk-card-default');
|
||||||
|
removeClassFromClass('ui-widget-content', 'dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
setTheme(getTheme());
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// because this script is attached at the top of HTML, the style on uk-card
|
||||||
|
// won't be applied because the elements are not available yet. We have to
|
||||||
|
// apply the theme again for it to take effect
|
||||||
|
setTheme(getTheme());
|
||||||
|
}, false);
|
||||||
+1
-8
@@ -30,6 +30,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
});
|
});
|
||||||
|
|
||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
|
styleModal();
|
||||||
}
|
}
|
||||||
function updateProgress(titleID, entryID, page) {
|
function updateProgress(titleID, entryID, page) {
|
||||||
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
|
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
|
||||||
@@ -42,11 +43,3 @@ function updateProgress(titleID, entryID, page) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function alert(level, text) {
|
|
||||||
hideAlert();
|
|
||||||
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
|
|
||||||
$('#alert').append(html);
|
|
||||||
}
|
|
||||||
function hideAlert() {
|
|
||||||
$('#alert').empty();
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-11
@@ -1,16 +1,6 @@
|
|||||||
$(function(){
|
$(() => {
|
||||||
var target = '/admin/user/edit';
|
var target = '/admin/user/edit';
|
||||||
if (username) target += username;
|
if (username) target += username;
|
||||||
$('form').attr('action', target);
|
$('form').attr('action', target);
|
||||||
|
|
||||||
function alert(level, text) {
|
|
||||||
hideAlert();
|
|
||||||
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
|
|
||||||
$('#alert').append(html);
|
|
||||||
}
|
|
||||||
function hideAlert() {
|
|
||||||
$('#alert').empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) alert('danger', error);
|
if (error) alert('danger', error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
function alert(level, text) {
|
|
||||||
hideAlert();
|
|
||||||
var html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
|
|
||||||
$('#alert').append(html);
|
|
||||||
}
|
|
||||||
function hideAlert() {
|
|
||||||
$('#alert').empty();
|
|
||||||
}
|
|
||||||
function remove(username) {
|
function remove(username) {
|
||||||
$.post('/api/admin/user/delete/' + username, function(data) {
|
$.post('/api/admin/user/delete/' + username, function(data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
port: 3000
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe Config do
|
||||||
|
it "creates config if it does not exist" do
|
||||||
|
with_default_config do |config, logger, path|
|
||||||
|
File.exists?(path).should be_true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "correctly loads config" do
|
||||||
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
|
config.port.should eq 3000
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
include MangaDex
|
||||||
|
|
||||||
|
describe Queue do
|
||||||
|
it "creates DB at given path" do
|
||||||
|
with_queue do |queue, 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
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
require "spec"
|
||||||
|
require "../src/context"
|
||||||
|
require "../src/server"
|
||||||
|
|
||||||
|
class State
|
||||||
|
@@hash = {} of String => String
|
||||||
|
|
||||||
|
def self.get(key)
|
||||||
|
@@hash[key]?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get!(key)
|
||||||
|
@@hash[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.set(key, value)
|
||||||
|
return if value.nil?
|
||||||
|
@@hash[key] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.reset
|
||||||
|
@@hash.clear
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_tempfile(name)
|
||||||
|
path = State.get name
|
||||||
|
if path.nil? || !File.exists? path
|
||||||
|
file = File.tempfile name
|
||||||
|
State.set name, file.path
|
||||||
|
return file
|
||||||
|
else
|
||||||
|
return File.new path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_default_config
|
||||||
|
temp_config = get_tempfile "mango-test-config"
|
||||||
|
config = Config.load temp_config.path
|
||||||
|
logger = MLogger.new config
|
||||||
|
yield config, logger, temp_config.path
|
||||||
|
temp_config.delete
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_storage
|
||||||
|
with_default_config do |config, logger|
|
||||||
|
temp_db = get_tempfile "mango-test-db"
|
||||||
|
storage = Storage.new temp_db.path, logger
|
||||||
|
clear = yield storage, temp_db.path
|
||||||
|
if clear == true
|
||||||
|
temp_db.delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_queue
|
||||||
|
with_default_config do |config, logger|
|
||||||
|
temp_queue_db = get_tempfile "mango-test-queue-db"
|
||||||
|
queue = MangaDex::Queue.new temp_queue_db.path, logger
|
||||||
|
clear = yield queue, temp_queue_db.path
|
||||||
|
if clear == true
|
||||||
|
temp_queue_db.delete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe Storage do
|
||||||
|
it "creates DB at given path" do
|
||||||
|
with_storage do |storage, path|
|
||||||
|
File.exists?(path).should be_true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "deletes user" do
|
||||||
|
with_storage do |storage|
|
||||||
|
storage.delete_user "admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "creates new user" do
|
||||||
|
with_storage do |storage|
|
||||||
|
storage.new_user "user", "123456", false
|
||||||
|
storage.new_user "admin", "123456", true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "verifies username/password combination" do
|
||||||
|
with_storage do |storage|
|
||||||
|
user_token = storage.verify_user "user", "123456"
|
||||||
|
admin_token = storage.verify_user "admin", "123456"
|
||||||
|
user_token.should_not be_nil
|
||||||
|
admin_token.should_not be_nil
|
||||||
|
State.set "user_token", user_token
|
||||||
|
State.set "admin_token", admin_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects duplicate username" do
|
||||||
|
with_storage do |storage|
|
||||||
|
expect_raises SQLite3::Exception,
|
||||||
|
"UNIQUE constraint failed: users.username" do
|
||||||
|
storage.new_user "admin", "123456", true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "verifies token" do
|
||||||
|
with_storage do |storage|
|
||||||
|
user_token = State.get! "user_token"
|
||||||
|
user = storage.verify_token user_token
|
||||||
|
user.should eq "user"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "verfies admin token" do
|
||||||
|
with_storage do |storage|
|
||||||
|
admin_token = State.get! "admin_token"
|
||||||
|
storage.verify_admin(admin_token).should be_true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects non-admin token" do
|
||||||
|
with_storage do |storage|
|
||||||
|
user_token = State.get! "user_token"
|
||||||
|
storage.verify_admin(user_token).should be_false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "updates user" do
|
||||||
|
with_storage do |storage|
|
||||||
|
storage.update_user "admin", "admin", "654321", true
|
||||||
|
token = storage.verify_user "admin", "654321"
|
||||||
|
admin_token = State.get! "admin_token"
|
||||||
|
token.should eq admin_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "logs user out" do
|
||||||
|
with_storage do |storage|
|
||||||
|
user_token = State.get! "user_token"
|
||||||
|
admin_token = State.get! "admin_token"
|
||||||
|
storage.logout user_token
|
||||||
|
storage.logout admin_token
|
||||||
|
storage.verify_token(user_token).should be_nil
|
||||||
|
storage.verify_token(admin_token).should be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "cleans up" do
|
||||||
|
with_storage do
|
||||||
|
true
|
||||||
|
end
|
||||||
|
State.reset
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe "compare_alphanumerically" do
|
||||||
|
it "sorts filenames with leading zeros correctly" do
|
||||||
|
ary = ["010.jpg", "001.jpg", "002.png"]
|
||||||
|
ary.sort! {|a, b|
|
||||||
|
compare_alphanumerically a, b
|
||||||
|
}
|
||||||
|
ary.should eq ["001.jpg", "002.png", "010.jpg"]
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# https://ux.stackexchange.com/a/95441
|
||||||
|
it "sorts like the stack exchange post" 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
|
||||||
|
}.should eq ary
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
Arabic,sa
|
||||||
|
Bengali,bd
|
||||||
|
Bulgarian,bg
|
||||||
|
Burmese,mm
|
||||||
|
Catalan,ct
|
||||||
|
Chinese (Simp),cn
|
||||||
|
Chinese (Trad),hk
|
||||||
|
Czech,cz
|
||||||
|
Danish,dk
|
||||||
|
Dutch,nl
|
||||||
|
English,gb
|
||||||
|
Filipino,ph
|
||||||
|
Finnish,fi
|
||||||
|
French,fr
|
||||||
|
German,de
|
||||||
|
Greek,gr
|
||||||
|
Hebrew,il
|
||||||
|
Hindi,in
|
||||||
|
Hungarian,hu
|
||||||
|
Indonesian,id
|
||||||
|
Italian,it
|
||||||
|
Japanese,jp
|
||||||
|
Korean,kr
|
||||||
|
Lithuanian,lt
|
||||||
|
Malay,my
|
||||||
|
Mongolian,mn
|
||||||
|
Other,
|
||||||
|
Persian,ir
|
||||||
|
Polish,pl
|
||||||
|
Portuguese (Br),br
|
||||||
|
Portuguese (Pt),pt
|
||||||
|
Romanian,ro
|
||||||
|
Russian,ru
|
||||||
|
Serbo-Croatian,rs
|
||||||
|
Spanish (Es),es
|
||||||
|
Spanish (LATAM),mx
|
||||||
|
Swedish,se
|
||||||
|
Thai,th
|
||||||
|
Turkish,tr
|
||||||
|
Ukrainian,ua
|
||||||
|
Vietnames,vn
|
||||||
|
+1
-1
@@ -15,7 +15,7 @@ class AuthHandler < Kemal::Handler
|
|||||||
return env.redirect "/login"
|
return env.redirect "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin"]
|
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||||
unless @storage.verify_admin cookie.value
|
unless @storage.verify_admin cookie.value
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
end
|
end
|
||||||
|
|||||||
+25
-9
@@ -3,28 +3,33 @@ require "yaml"
|
|||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@[YAML::Field(key: "port")]
|
|
||||||
property port : Int32 = 9000
|
property port : Int32 = 9000
|
||||||
|
|
||||||
@[YAML::Field(key: "library_path")]
|
|
||||||
property library_path : String = \
|
property library_path : String = \
|
||||||
File.expand_path "~/mango/library", home: true
|
File.expand_path "~/mango/library", home: true
|
||||||
|
|
||||||
@[YAML::Field(key: "db_path")]
|
|
||||||
property db_path : String = \
|
property db_path : String = \
|
||||||
File.expand_path "~/mango/mango.db", home: true
|
File.expand_path "~/mango/mango.db", home: true
|
||||||
|
|
||||||
@[YAML::Field(key: "scan_interval_minutes")]
|
@[YAML::Field(key: "scan_interval_minutes")]
|
||||||
property scan_interval : Int32 = 5
|
property scan_interval : Int32 = 5
|
||||||
|
|
||||||
@[YAML::Field(key: "log_level")]
|
|
||||||
property log_level : String = "info"
|
property log_level : String = "info"
|
||||||
|
property mangadex = Hash(String, String|Int32).new
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@mangadex_defaults = {
|
||||||
|
"base_url" => "https://mangadex.org",
|
||||||
|
"api_url" => "https://mangadex.org/api",
|
||||||
|
"download_wait_seconds" => 5,
|
||||||
|
"download_retries" => 4,
|
||||||
|
"download_queue_db_path" => File.expand_path "~/mango/queue.db",
|
||||||
|
home: true
|
||||||
|
}
|
||||||
|
|
||||||
def self.load(path : String?)
|
def self.load(path : String?)
|
||||||
path = "~/.config/mango/config.yml" if path.nil?
|
path = "~/.config/mango/config.yml" if path.nil?
|
||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
return self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
|
config.fill_defaults
|
||||||
|
return config
|
||||||
end
|
end
|
||||||
puts "The config file #{cfg_path} does not exist." \
|
puts "The config file #{cfg_path} does not exist." \
|
||||||
" Do you want mango to dump the default config there? [Y/n]"
|
" Do you want mango to dump the default config there? [Y/n]"
|
||||||
@@ -33,6 +38,7 @@ class Config
|
|||||||
abort "Aborting..."
|
abort "Aborting..."
|
||||||
end
|
end
|
||||||
default = self.allocate
|
default = self.allocate
|
||||||
|
default.fill_defaults
|
||||||
cfg_dir = File.dirname cfg_path
|
cfg_dir = File.dirname cfg_path
|
||||||
unless Dir.exists? cfg_dir
|
unless Dir.exists? cfg_dir
|
||||||
Dir.mkdir_p cfg_dir
|
Dir.mkdir_p cfg_dir
|
||||||
@@ -41,4 +47,14 @@ class Config
|
|||||||
puts "The config file has been created at #{cfg_path}."
|
puts "The config file has been created at #{cfg_path}."
|
||||||
default
|
default
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fill_defaults
|
||||||
|
{% for hash_name in ["mangadex"] %}
|
||||||
|
@{{hash_name.id}}_defaults.map do |k, v|
|
||||||
|
if @{{hash_name.id}}[k]?.nil?
|
||||||
|
@{{hash_name.id}}[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+2
-1
@@ -8,8 +8,9 @@ class Context
|
|||||||
property library : Library
|
property library : Library
|
||||||
property storage : Storage
|
property storage : Storage
|
||||||
property logger : MLogger
|
property logger : MLogger
|
||||||
|
property queue : MangaDex::Queue
|
||||||
|
|
||||||
def initialize(@config, @logger, @library, @storage)
|
def initialize(@config, @logger, @library, @storage, @queue)
|
||||||
end
|
end
|
||||||
|
|
||||||
{% for lvl in LEVELS %}
|
{% for lvl in LEVELS %}
|
||||||
|
|||||||
+36
-10
@@ -2,6 +2,7 @@ require "zip"
|
|||||||
require "mime"
|
require "mime"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
require "./util"
|
||||||
|
|
||||||
struct Image
|
struct Image
|
||||||
property data : Bytes
|
property data : Bytes
|
||||||
@@ -14,9 +15,10 @@ struct Image
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Entry
|
class Entry
|
||||||
JSON.mapping zip_path: String, book_title: String, title: String, \
|
JSON.mapping zip_path: String, book_title: String, title: String,
|
||||||
size: String, pages: Int32, cover_url: String, id: String, \
|
size: String, pages: Int32, cover_url: String, id: String,
|
||||||
title_id: String, encoded_path: String, encoded_title: String
|
title_id: String, encoded_path: String, encoded_title: String,
|
||||||
|
mtime: Time
|
||||||
|
|
||||||
def initialize(path, @book_title, @title_id, storage)
|
def initialize(path, @book_title, @title_id, storage)
|
||||||
@zip_path = path
|
@zip_path = path
|
||||||
@@ -24,14 +26,17 @@ class Entry
|
|||||||
@title = File.basename path, File.extname path
|
@title = File.basename path, File.extname path
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@size = (File.size path).humanize_bytes
|
@size = (File.size path).humanize_bytes
|
||||||
@pages = Zip::File.new(path).entries
|
file = Zip::File.new path
|
||||||
|
@pages = file.entries
|
||||||
.select { |e|
|
.select { |e|
|
||||||
["image/jpeg", "image/png"].includes? \
|
["image/jpeg", "image/png"].includes? \
|
||||||
MIME.from_filename? e.filename
|
MIME.from_filename? e.filename
|
||||||
}
|
}
|
||||||
.size
|
.size
|
||||||
|
file.close
|
||||||
@id = storage.get_id @zip_path, false
|
@id = storage.get_id @zip_path, false
|
||||||
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
|
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
|
||||||
|
@mtime = File.info(@zip_path).modification_time
|
||||||
end
|
end
|
||||||
def read_page(page_num)
|
def read_page(page_num)
|
||||||
Zip::File.open @zip_path do |file|
|
Zip::File.open @zip_path do |file|
|
||||||
@@ -40,7 +45,9 @@ class Entry
|
|||||||
["image/jpeg", "image/png"].includes? \
|
["image/jpeg", "image/png"].includes? \
|
||||||
MIME.from_filename? e.filename
|
MIME.from_filename? e.filename
|
||||||
}
|
}
|
||||||
.sort { |a, b| a.filename <=> b.filename }
|
.sort { |a, b|
|
||||||
|
compare_alphanumerically a.filename, b.filename
|
||||||
|
}
|
||||||
.[page_num - 1]
|
.[page_num - 1]
|
||||||
page.open do |io|
|
page.open do |io|
|
||||||
slice = Bytes.new page.uncompressed_size
|
slice = Bytes.new page.uncompressed_size
|
||||||
@@ -57,20 +64,40 @@ end
|
|||||||
|
|
||||||
class Title
|
class Title
|
||||||
JSON.mapping dir: String, entries: Array(Entry), title: String,
|
JSON.mapping dir: String, entries: Array(Entry), title: String,
|
||||||
id: String, encoded_title: String
|
id: String, encoded_title: String, mtime: Time, logger: MLogger
|
||||||
|
|
||||||
def initialize(dir : String, storage)
|
def initialize(dir : String, storage, @logger : MLogger)
|
||||||
@dir = dir
|
@dir = dir
|
||||||
@id = storage.get_id @dir, true
|
@id = storage.get_id @dir, true
|
||||||
@title = File.basename dir
|
@title = File.basename dir
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@entries = (Dir.entries dir)
|
@entries = (Dir.entries dir)
|
||||||
.select { |path| [".zip", ".cbz"].includes? File.extname path }
|
.select { |path| [".zip", ".cbz"].includes? File.extname path }
|
||||||
|
.map { |path| File.join dir, path }
|
||||||
|
.select { |path| valid_zip path }
|
||||||
.map { |path|
|
.map { |path|
|
||||||
Entry.new File.join(dir, path), @title, @id, storage
|
Entry.new path, @title, @id, storage
|
||||||
}
|
}
|
||||||
.select { |e| e.pages > 0 }
|
.select { |e| e.pages > 0 }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort { |a, b| a.title <=> b.title }
|
||||||
|
mtimes = [File.info(dir).modification_time]
|
||||||
|
mtimes += @entries.map{|e| e.mtime}
|
||||||
|
@mtime = mtimes.max
|
||||||
|
end
|
||||||
|
# When downloading from MangaDex, the zip/cbz file would not be valid
|
||||||
|
# before the download is completed. If we scan the zip file,
|
||||||
|
# Entry.new would throw, so we use this method to check before
|
||||||
|
# constructing Entry
|
||||||
|
private def valid_zip(path : String)
|
||||||
|
begin
|
||||||
|
file = Zip::File.new path
|
||||||
|
file.close
|
||||||
|
return true
|
||||||
|
rescue
|
||||||
|
@logger.warn "File #{path} is corrupted or is not a valid zip "\
|
||||||
|
"archive. Ignoring it."
|
||||||
|
return false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@entries.find { |e| e.id == eid }
|
@entries.find { |e| e.id == eid }
|
||||||
@@ -178,10 +205,9 @@ class Library
|
|||||||
end
|
end
|
||||||
@titles = (Dir.entries @dir)
|
@titles = (Dir.entries @dir)
|
||||||
.select { |path| File.directory? File.join @dir, path }
|
.select { |path| File.directory? File.join @dir, path }
|
||||||
.map { |path| Title.new File.join(@dir, path), @storage }
|
.map { |path| Title.new File.join(@dir, path), @storage, @logger }
|
||||||
.select { |title| !title.entries.empty? }
|
.select { |title| !title.entries.empty? }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort { |a, b| a.title <=> b.title }
|
||||||
@logger.debug "Scan completed"
|
@logger.debug "Scan completed"
|
||||||
@logger.debug "Scanned library: \n#{self.to_pretty_json}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
require "http/client"
|
||||||
|
require "json"
|
||||||
|
require "csv"
|
||||||
|
|
||||||
|
macro string_properties (names)
|
||||||
|
{% for name in names %}
|
||||||
|
property {{name.id}} = ""
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
macro parse_strings_from_json (names)
|
||||||
|
{% for name in names %}
|
||||||
|
@{{name.id}} = obj[{{name}}].as_s
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
module MangaDex
|
||||||
|
class Chapter
|
||||||
|
string_properties ["lang_code", "title", "volume", "chapter"]
|
||||||
|
property manga : Manga
|
||||||
|
property time = Time.local
|
||||||
|
property id : String
|
||||||
|
property full_title = ""
|
||||||
|
property language = ""
|
||||||
|
property pages = [] of {String, String} # filename, url
|
||||||
|
property groups = [] of {Int32, String} # group_id, group_name
|
||||||
|
|
||||||
|
def initialize(@id, json_obj : JSON::Any, @manga, lang :
|
||||||
|
Hash(String, String))
|
||||||
|
self.parse_json json_obj, lang
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for name in ["id", "title", "volume", "chapter",
|
||||||
|
"language", "full_title"] %}
|
||||||
|
json.field {{name}}, @{{name.id}}
|
||||||
|
{% end %}
|
||||||
|
json.field "time", @time.to_unix.to_s
|
||||||
|
json.field "manga_title", @manga.title
|
||||||
|
json.field "manga_id", @manga.id
|
||||||
|
json.field "groups" do
|
||||||
|
json.object do
|
||||||
|
@groups.each do |gid, gname|
|
||||||
|
json.field gname, gid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json(obj, lang)
|
||||||
|
begin
|
||||||
|
parse_strings_from_json ["lang_code", "title", "volume",
|
||||||
|
"chapter"]
|
||||||
|
language = lang[@lang_code]?
|
||||||
|
@language = language if language
|
||||||
|
@time = Time.unix obj["timestamp"].as_i
|
||||||
|
suffixes = ["", "_2", "_3"]
|
||||||
|
suffixes.each do |s|
|
||||||
|
gid = obj["group_id#{s}"].as_i
|
||||||
|
next if gid == 0
|
||||||
|
gname = obj["group_name#{s}"].as_s
|
||||||
|
@groups << {gid, gname}
|
||||||
|
end
|
||||||
|
@full_title = @title
|
||||||
|
unless @chapter.empty?
|
||||||
|
@full_title = "Ch.#{@chapter} " + @full_title
|
||||||
|
end
|
||||||
|
unless @volume.empty?
|
||||||
|
@full_title = "Vol.#{@volume} " + @full_title
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise "failed to parse json: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class Manga
|
||||||
|
string_properties ["cover_url", "description", "title", "author",
|
||||||
|
"artist"]
|
||||||
|
property chapters = [] of Chapter
|
||||||
|
property id : String
|
||||||
|
|
||||||
|
def initialize(@id, json_obj : JSON::Any)
|
||||||
|
self.parse_json json_obj
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json(with_chapters = true)
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for name in ["id", "title", "description",
|
||||||
|
"author", "artist", "cover_url"] %}
|
||||||
|
json.field {{name}}, @{{name.id}}
|
||||||
|
{% end %}
|
||||||
|
if with_chapters
|
||||||
|
json.field "chapters" do
|
||||||
|
json.array do
|
||||||
|
@chapters.each do |c|
|
||||||
|
json.raw c.to_info_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json(obj)
|
||||||
|
begin
|
||||||
|
parse_strings_from_json ["cover_url", "description", "title",
|
||||||
|
"author", "artist"]
|
||||||
|
rescue e
|
||||||
|
raise "failed to parse json: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class API
|
||||||
|
def initialize(@base_url = "https://mangadex.org/api/")
|
||||||
|
@lang = {} of String => String
|
||||||
|
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
||||||
|
@lang[row[1]] = row[0]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(url)
|
||||||
|
headers = HTTP::Headers {
|
||||||
|
"User-agent" => "Mangadex.cr"
|
||||||
|
}
|
||||||
|
res = HTTP::Client.get url, headers
|
||||||
|
raise "Failed to get #{url}. [#{res.status_code}] "\
|
||||||
|
"#{res.status_message}" if !res.success?
|
||||||
|
JSON.parse res.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_manga(id)
|
||||||
|
obj = self.get File.join @base_url, "manga/#{id}"
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. " \
|
||||||
|
"Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
manga = Manga.new id, obj["manga"]
|
||||||
|
obj["chapter"].as_h.map do |k, v|
|
||||||
|
chapter = Chapter.new k, v, manga, @lang
|
||||||
|
manga.chapters << chapter
|
||||||
|
end
|
||||||
|
return manga
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_chapter(chapter : Chapter)
|
||||||
|
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
||||||
|
if obj["status"]? == "external"
|
||||||
|
raise "This chapter is hosted on an external site " \
|
||||||
|
"#{obj["external"]?}, and Mango does not support " \
|
||||||
|
"external chapters."
|
||||||
|
end
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. " \
|
||||||
|
"Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
server = obj["server"].as_s
|
||||||
|
hash = obj["hash"].as_s
|
||||||
|
chapter.pages = obj["page_array"].as_a.map do |fn|
|
||||||
|
{
|
||||||
|
fn.as_s,
|
||||||
|
"#{server}#{hash}/#{fn.as_s}"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_chapter(id : String)
|
||||||
|
obj = self.get File.join @base_url, "chapter/#{id}"
|
||||||
|
if obj["status"]? == "external"
|
||||||
|
raise "This chapter is hosted on an external site " \
|
||||||
|
"#{obj["external"]?}, and Mango does not support " \
|
||||||
|
"external chapters."
|
||||||
|
end
|
||||||
|
if obj["status"]? != "OK"
|
||||||
|
raise "Expecting `OK` in the `status` field. " \
|
||||||
|
"Got `#{obj["status"]?}`"
|
||||||
|
end
|
||||||
|
manga_id = ""
|
||||||
|
begin
|
||||||
|
manga_id = obj["manga_id"].as_i.to_s
|
||||||
|
rescue
|
||||||
|
raise "Failed to parse JSON"
|
||||||
|
end
|
||||||
|
manga = self.get_manga manga_id
|
||||||
|
chapter = manga.chapters.find {|c| c.id == id}.not_nil!
|
||||||
|
self.get_chapter chapter
|
||||||
|
return chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
require "./api"
|
||||||
|
require "sqlite3"
|
||||||
|
|
||||||
|
module MangaDex
|
||||||
|
class PageJob
|
||||||
|
property success = false
|
||||||
|
property url : String
|
||||||
|
property filename : String
|
||||||
|
property writer : Zip::Writer
|
||||||
|
property tries_remaning : Int32
|
||||||
|
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
return 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?
|
||||||
|
|
||||||
|
def initialize(@path : String, @logger : MLogger)
|
||||||
|
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
|
||||||
|
job = nil
|
||||||
|
DB.open "sqlite3://#{@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
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 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)
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
return db.query_one "select count(*) from queue where "\
|
||||||
|
"status = (?)", status.to_i, as: Int32
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def count
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
return db.query_one "select count(*) from queue", as: Int32
|
||||||
|
end
|
||||||
|
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
|
||||||
|
return 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
|
||||||
|
|
||||||
|
def initialize(@queue : Queue, @api : API, @library_path : String,
|
||||||
|
@wait_seconds : Int32, @retries : Int32,
|
||||||
|
@logger : MLogger)
|
||||||
|
@queue.downloader = self
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
sleep 1.second
|
||||||
|
next if @stopped
|
||||||
|
begin
|
||||||
|
job = @queue.pop
|
||||||
|
next if job.nil?
|
||||||
|
download job
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def download(job : Job)
|
||||||
|
@stopped = true
|
||||||
|
@queue.set_status JobStatus::Downloading, job
|
||||||
|
begin
|
||||||
|
chapter = @api.get_chapter(job.id)
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
@queue.set_status JobStatus::Error, job
|
||||||
|
unless e.message.nil?
|
||||||
|
@queue.add_message e.message.not_nil!, job
|
||||||
|
end
|
||||||
|
@stopped = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
@queue.set_pages chapter.pages.size, job
|
||||||
|
lib_dir = @library_path
|
||||||
|
manga_dir = File.join lib_dir, chapter.manga.title
|
||||||
|
unless File.exists? manga_dir
|
||||||
|
Dir.mkdir_p manga_dir
|
||||||
|
end
|
||||||
|
zip_path = File.join manga_dir, "#{job.title}.cbz"
|
||||||
|
|
||||||
|
# Find the number of digits needed to store the number of pages
|
||||||
|
len = Math.log10(chapter.pages.size).to_i + 1
|
||||||
|
|
||||||
|
writer = Zip::Writer.new zip_path
|
||||||
|
# Create a buffered channel. It works as an FIFO queue
|
||||||
|
channel = Channel(PageJob).new chapter.pages.size
|
||||||
|
spawn do
|
||||||
|
chapter.pages.each_with_index do |tuple, i|
|
||||||
|
fn, url = tuple
|
||||||
|
ext = File.extname fn
|
||||||
|
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||||
|
page_job = PageJob.new url, fn, writer, @retries
|
||||||
|
@logger.debug "Downloading #{url}"
|
||||||
|
loop do
|
||||||
|
sleep @wait_seconds.seconds
|
||||||
|
download_page page_job
|
||||||
|
break if page_job.success ||
|
||||||
|
page_job.tries_remaning <= 0
|
||||||
|
page_job.tries_remaning -= 1
|
||||||
|
@logger.warn "Failed to download page #{url}. " \
|
||||||
|
"Retrying... Remaining retries: " \
|
||||||
|
"#{page_job.tries_remaning}"
|
||||||
|
end
|
||||||
|
|
||||||
|
channel.send page_job
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
page_jobs = [] of PageJob
|
||||||
|
chapter.pages.size.times do
|
||||||
|
page_job = channel.receive
|
||||||
|
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
||||||
|
"#{page_job.url}"
|
||||||
|
page_jobs << page_job
|
||||||
|
if page_job.success
|
||||||
|
@queue.add_success job
|
||||||
|
else
|
||||||
|
@queue.add_fail job
|
||||||
|
msg = "Failed to download page #{page_job.url}"
|
||||||
|
@queue.add_message msg, job
|
||||||
|
@logger.error msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
fail_count = page_jobs.select{|j| !j.success}.size
|
||||||
|
@logger.debug "Download completed. "\
|
||||||
|
"#{fail_count}/#{page_jobs.size} failed"
|
||||||
|
writer.close
|
||||||
|
@logger.debug "cbz File created at #{zip_path}"
|
||||||
|
if fail_count == 0
|
||||||
|
@queue.set_status JobStatus::Completed, job
|
||||||
|
else
|
||||||
|
@queue.set_status JobStatus::MissingPages, job
|
||||||
|
end
|
||||||
|
@stopped = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def download_page(job : PageJob)
|
||||||
|
@logger.debug "downloading #{job.url}"
|
||||||
|
headers = HTTP::Headers {
|
||||||
|
"User-agent" => "Mangadex.cr"
|
||||||
|
}
|
||||||
|
begin
|
||||||
|
HTTP::Client.get job.url, headers do |res|
|
||||||
|
unless res.success?
|
||||||
|
raise "Failed to download page #{job.url}. " \
|
||||||
|
"[#{res.status_code}] #{res.status_message}"
|
||||||
|
end
|
||||||
|
job.writer.add job.filename, res.body_io
|
||||||
|
end
|
||||||
|
job.success = true
|
||||||
|
rescue e
|
||||||
|
@logger.error e
|
||||||
|
job.success = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+9
-2
@@ -1,8 +1,9 @@
|
|||||||
require "./server"
|
require "./server"
|
||||||
require "./context"
|
require "./context"
|
||||||
|
require "./mangadex/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
|
|
||||||
VERSION = "0.1.0"
|
VERSION = "0.2.0"
|
||||||
|
|
||||||
config_path = nil
|
config_path = nil
|
||||||
|
|
||||||
@@ -27,8 +28,14 @@ config = Config.load config_path
|
|||||||
logger = MLogger.new config
|
logger = MLogger.new config
|
||||||
storage = Storage.new config.db_path, logger
|
storage = Storage.new config.db_path, logger
|
||||||
library = Library.new config.library_path, config.scan_interval, logger, storage
|
library = Library.new config.library_path, config.scan_interval, logger, storage
|
||||||
|
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
|
||||||
|
logger
|
||||||
|
api = MangaDex::API.new config.mangadex["api_url"].to_s
|
||||||
|
downloader = MangaDex::Downloader.new queue, api, config.library_path,
|
||||||
|
config.mangadex["download_wait_seconds"].to_i,
|
||||||
|
config.mangadex["download_retries"].to_i, logger
|
||||||
|
|
||||||
context = Context.new config, logger, library, storage
|
context = Context.new config, logger, library, storage, queue
|
||||||
|
|
||||||
server = Server.new context
|
server = Server.new context
|
||||||
server.start
|
server.start
|
||||||
|
|||||||
@@ -99,5 +99,10 @@ class AdminRouter < Router
|
|||||||
env.redirect redirect_url.to_s
|
env.redirect redirect_url.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/admin/downloads" do |env|
|
||||||
|
base_url = @context.config.mangadex["base_url"];
|
||||||
|
layout "download-manager"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require "./router"
|
require "./router"
|
||||||
|
require "../mangadex/*"
|
||||||
|
|
||||||
class APIRouter < Router
|
class APIRouter < Router
|
||||||
def setup
|
def setup
|
||||||
@@ -88,5 +89,92 @@ class APIRouter < Router
|
|||||||
send_json env, {"success" => true}.to_json
|
send_json env, {"success" => true}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
|
begin
|
||||||
|
id = env.params.url["id"]
|
||||||
|
api = MangaDex::API.new \
|
||||||
|
@context.config.mangadex["api_url"].to_s
|
||||||
|
manga = api.get_manga id
|
||||||
|
send_json env, manga.to_info_json
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
send_json env, {"error" => e.message}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/api/admin/mangadex/download" do |env|
|
||||||
|
begin
|
||||||
|
chapters = env.params.json["chapters"].as(Array).map{|c| c.as_h}
|
||||||
|
jobs = chapters.map {|chapter|
|
||||||
|
MangaDex::Job.new(
|
||||||
|
chapter["id"].as_s,
|
||||||
|
chapter["manga_id"].as_s,
|
||||||
|
chapter["full_title"].as_s,
|
||||||
|
chapter["manga_title"].as_s,
|
||||||
|
MangaDex::JobStatus::Pending,
|
||||||
|
Time.unix chapter["time"].as_s.to_i
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inserted_count = @context.queue.push jobs
|
||||||
|
send_json env, {
|
||||||
|
"success": inserted_count,
|
||||||
|
"fail": jobs.size - inserted_count
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
send_json env, {"error" => e.message}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/admin/mangadex/queue" do |env|
|
||||||
|
begin
|
||||||
|
jobs = @context.queue.get_all
|
||||||
|
send_json env, {
|
||||||
|
"jobs" => jobs,
|
||||||
|
"paused" => @context.queue.paused?,
|
||||||
|
"success" => true
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/api/admin/mangadex/queue/:action" do |env|
|
||||||
|
begin
|
||||||
|
action = env.params.url["action"]
|
||||||
|
id = env.params.query["id"]?
|
||||||
|
case action
|
||||||
|
when "delete"
|
||||||
|
if id.nil?
|
||||||
|
@context.queue.delete_status MangaDex::JobStatus::Completed
|
||||||
|
else
|
||||||
|
@context.queue.delete id
|
||||||
|
end
|
||||||
|
when "retry"
|
||||||
|
if id.nil?
|
||||||
|
@context.queue.reset
|
||||||
|
else
|
||||||
|
@context.queue.reset id
|
||||||
|
end
|
||||||
|
when "pause"
|
||||||
|
@context.queue.pause
|
||||||
|
when "resume"
|
||||||
|
@context.queue.resume
|
||||||
|
else
|
||||||
|
raise "Unknown queue action #{action}"
|
||||||
|
end
|
||||||
|
|
||||||
|
send_json env, {"success" => true}.to_json
|
||||||
|
rescue e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -52,5 +52,10 @@ class MainRouter < Router
|
|||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/download" do |env|
|
||||||
|
base_url = @context.config.mangadex["base_url"];
|
||||||
|
layout "download"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+11
-2
@@ -10,7 +10,16 @@ class Server
|
|||||||
def initialize(@context : Context)
|
def initialize(@context : Context)
|
||||||
|
|
||||||
error 403 do |env|
|
error 403 do |env|
|
||||||
message = "You are not authorized to visit #{env.request.path}"
|
message = "HTTP 403: You are not authorized to visit " \
|
||||||
|
"#{env.request.path}"
|
||||||
|
layout "message"
|
||||||
|
end
|
||||||
|
error 404 do |env|
|
||||||
|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
||||||
|
layout "message"
|
||||||
|
end
|
||||||
|
error 500 do |env|
|
||||||
|
message = "HTTP 500: Internal server error. Please try again later."
|
||||||
layout "message"
|
layout "message"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -24,7 +33,7 @@ class Server
|
|||||||
add_handler AuthHandler.new @context.storage
|
add_handler AuthHandler.new @context.storage
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
# when building for relase, embed the static files in binary
|
# when building for relase, embed the static files in binary
|
||||||
@context.debug "We are in release mode. Using embeded static files."
|
@context.debug "We are in release mode. Using embedded static files."
|
||||||
serve_static false
|
serve_static false
|
||||||
add_handler StaticHandler.new
|
add_handler StaticHandler.new
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
require "baked_file_system"
|
require "baked_file_system"
|
||||||
require "kemal"
|
require "kemal"
|
||||||
require "gzip"
|
|
||||||
require "./util"
|
require "./util"
|
||||||
|
|
||||||
class FS
|
class FS
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
|
{% if flag?(:release) %}
|
||||||
{% if read_file? "#{__DIR__}/../dist/favicon.ico" %}
|
{% if read_file? "#{__DIR__}/../dist/favicon.ico" %}
|
||||||
{% puts "baking ../dist" %}
|
{% puts "baking ../dist" %}
|
||||||
bake_folder "../dist"
|
bake_folder "../dist"
|
||||||
@@ -12,6 +12,7 @@ class FS
|
|||||||
{% puts "baking ../public" %}
|
{% puts "baking ../public" %}
|
||||||
bake_folder "../public"
|
bake_folder "../public"
|
||||||
{% end %}
|
{% end %}
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
class StaticHandler < Kemal::Handler
|
class StaticHandler < Kemal::Handler
|
||||||
|
|||||||
+38
-1
@@ -9,7 +9,7 @@ macro send_img(env, img)
|
|||||||
end
|
end
|
||||||
|
|
||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
# if the request gets here, its has gone through the auth handler, and
|
# 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
|
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||||
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
|
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
|
||||||
(@context.storage.verify_token cookie.value).not_nil!
|
(@context.storage.verify_token cookie.value).not_nil!
|
||||||
@@ -32,3 +32,40 @@ def request_path_startswith(env, ary)
|
|||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end
|
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_i <=> b.to_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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<span hidden></span>
|
<span hidden></span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li data-url="/admin/downloads">Download Manager</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="uk-margin">
|
||||||
|
<div id="actions" class="uk-margin">
|
||||||
|
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
||||||
|
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
||||||
|
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
||||||
|
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
||||||
|
</div>
|
||||||
|
<div id="config" class="uk-margin">
|
||||||
|
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Chapter</th>
|
||||||
|
<th>Manga</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script>
|
||||||
|
var baseURL = "<%= base_url %>".replace(/\/$/, "");
|
||||||
|
</script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
|
<script src="/js/alert.js"></script>
|
||||||
|
<script src="/js/download-manager.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-3-4">
|
||||||
|
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-4">
|
||||||
|
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||||
|
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
||||||
|
<div class="uk-width-1-4@s">
|
||||||
|
<img id="cover">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-4@s">
|
||||||
|
<p id="title"></p>
|
||||||
|
<p id="artist"></p>
|
||||||
|
<p id="author"></p>
|
||||||
|
</div>
|
||||||
|
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||||
|
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||||
|
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="lang-select">Language</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select class="uk-select filter-field" id="lang-select">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="group-select">Group</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select class="uk-select filter-field" id="group-select">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="volume-range">Volume</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="selection-controls" class="uk-margin" hidden>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||||
|
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||||
|
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||||
|
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||||
|
</div>
|
||||||
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
|
</div>
|
||||||
|
<p id="filter-notification" hidden></p>
|
||||||
|
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Language</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Volume</th>
|
||||||
|
<th>Chapter</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script>
|
||||||
|
var baseURL = "<%= base_url %>".replace(/\/$/, "");
|
||||||
|
</script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||||
|
<script src="/js/alert.js"></script>
|
||||||
|
<script src="/js/download.js"></script>
|
||||||
|
<% end %>
|
||||||
+18
-3
@@ -1,14 +1,28 @@
|
|||||||
<h2 class=uk-title>Library</h2>
|
<h2 class=uk-title>Library</h2>
|
||||||
<p class="uk-text-meta"><%= titles.size %> titles found</p>
|
<p class="uk-text-meta"><%= titles.size %> titles found</p>
|
||||||
<div class="uk-margin">
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
<form class="uk-search uk-search-default">
|
<form class="uk-search uk-search-default">
|
||||||
<span uk-search-icon></span>
|
<span uk-search-icon></span>
|
||||||
<input class="uk-search-input" type="search" placeholder="Search">
|
<input class="uk-search-input" type="search" placeholder="Search">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
|
<div class="uk-form-horizontal">
|
||||||
|
<select class="uk-select" id="sort-select">
|
||||||
|
<option id="name-up">â–˛ Name</option>
|
||||||
|
<option id="name-down">â–Ľ Name</option>
|
||||||
|
<option id="date-up">â–˛ Date Modified</option>
|
||||||
|
<option id="date-down">â–Ľ Date Modified</option>
|
||||||
|
<option id="progress-up">â–˛ Progress</option>
|
||||||
|
<option id="progress-down">â–Ľ Progress</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
<%- titles.each_with_index do |t, i| -%>
|
<%- titles.each_with_index do |t, i| -%>
|
||||||
<div class="item">
|
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
|
||||||
<a class="acard" href="/book/<%= t.id %>">
|
<a class="acard" href="/book/<%= t.id %>">
|
||||||
<div class="uk-card uk-card-default">
|
<div class="uk-card uk-card-default">
|
||||||
<div class="uk-card-media-top">
|
<div class="uk-card-media-top">
|
||||||
@@ -27,4 +41,5 @@
|
|||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script src="/js/search.js"></script>
|
<script src="/js/search.js"></script>
|
||||||
|
<script src="/js/sort-items.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
+10
-2
@@ -8,9 +8,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||||
<link rel="stylesheet" href="/css/mango.css" />
|
<link rel="stylesheet" href="/css/mango.css" />
|
||||||
|
<script defer src="/js/fontawesome.min.js"></script>
|
||||||
|
<script defer src="/js/solid.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
<div class="uk-offcanvas-content">
|
<div class="uk-offcanvas-content">
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||||
@@ -18,7 +21,9 @@
|
|||||||
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/admin">Admin</a></li>
|
<li><a href="/admin">Admin</a></li>
|
||||||
|
<li><a href="/download">Download</a></li>
|
||||||
<hr uk-divider>
|
<hr uk-divider>
|
||||||
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a href="/logout">Logout</a></li>
|
<li><a href="/logout">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,19 +40,22 @@
|
|||||||
<ul class="uk-navbar-nav">
|
<ul class="uk-navbar-nav">
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/admin">Admin</a></li>
|
<li><a href="/admin">Admin</a></li>
|
||||||
|
<li><a href="/download">Download</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
<div class="uk-navbar-right uk-visible@s">
|
||||||
<ul class="uk-navbar-nav">
|
<ul class="uk-navbar-nav">
|
||||||
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a href="/logout">Logout</a></li>
|
<li><a href="/logout">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-default uk-section-small">
|
<div class="uk-section uk-section-small">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-default uk-section-small">
|
<div class="uk-section uk-section-small">
|
||||||
<div class="uk-container uk-container-small">
|
<div class="uk-container uk-container-small">
|
||||||
|
<div id="alert"></div>
|
||||||
<%= content %>
|
<%= content %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="uk-section uk-section-muted uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
<script src="/js/theme.js"></script>
|
||||||
|
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
||||||
<div class="uk-width-1-1">
|
<div class="uk-width-1-1">
|
||||||
<div class="uk-container">
|
<div class="uk-container">
|
||||||
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<p class="uk-text-lead"><%= message %></p>
|
<p class="uk-text-lead uk-text-center"><%= message %></p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
||||||
<div class="uk-container uk-container-small">
|
<div class="uk-container uk-container-small">
|
||||||
<%- urls.each_with_index do |url, i| -%>
|
<%- urls.each_with_index do |url, i| -%>
|
||||||
@@ -36,6 +37,9 @@
|
|||||||
<h3 class="uk-modal-title">Options</h3>
|
<h3 class="uk-modal-title">Options</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<p id="progress-label"></p>
|
||||||
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
|
|||||||
+21
-4
@@ -1,15 +1,30 @@
|
|||||||
<div id="alert"></div>
|
|
||||||
<h2 class=uk-title><%= title.title %></h2>
|
<h2 class=uk-title><%= title.title %></h2>
|
||||||
<p class="uk-text-meta"><%= title.entries.size %> entries found</p>
|
<p class="uk-text-meta"><%= title.entries.size %> entries found</p>
|
||||||
<div class="uk-margin">
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
<form class="uk-search uk-search-default">
|
<form class="uk-search uk-search-default">
|
||||||
<span uk-search-icon></span>
|
<span uk-search-icon></span>
|
||||||
<input class="uk-search-input" type="search" placeholder="Search">
|
<input class="uk-search-input" type="search" placeholder="Search">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
|
<div class="uk-form-horizontal">
|
||||||
|
<select class="uk-select" id="sort-select">
|
||||||
|
<option id="auto-up">â–˛ Auto</option>
|
||||||
|
<option id="auto-down">â–Ľ Auto</option>
|
||||||
|
<option id="name-up">â–˛ Name</option>
|
||||||
|
<option id="name-down">â–Ľ Name</option>
|
||||||
|
<option id="date-up">â–˛ Date Modified</option>
|
||||||
|
<option id="date-down">â–Ľ Date Modified</option>
|
||||||
|
<option id="progress-up">â–˛ Progress</option>
|
||||||
|
<option id="progress-down">â–Ľ Progress</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
<%- title.entries.each_with_index do |e, i| -%>
|
<%- title.entries.each_with_index do |e, i| -%>
|
||||||
<div class="item">
|
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
|
||||||
<a class="acard">
|
<a class="acard">
|
||||||
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_title %>", "<%= e.encoded_title %>", '<%= e.title_id %>', '<%= e.id %>')">
|
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_title %>", "<%= e.encoded_title %>", '<%= e.title_id %>', '<%= e.id %>')">
|
||||||
<div class="uk-card-media-top">
|
<div class="uk-card-media-top">
|
||||||
@@ -49,6 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
<script src="/js/alert.js"></script>
|
||||||
<script src="/js/title.js"></script>
|
<script src="/js/title.js"></script>
|
||||||
<script src="/js/search.js"></script>
|
<script src="/js/search.js"></script>
|
||||||
|
<script src="/js/sort-items.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<div id="alert"></div>
|
|
||||||
|
|
||||||
<form action="/admin/user/edit" method="post" accept-charset="utf-8">
|
<form action="/admin/user/edit" method="post" accept-charset="utf-8">
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@@ -51,5 +49,6 @@
|
|||||||
error = '<%= error %>';
|
error = '<%= error %>';
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/alert.js"></script>
|
||||||
<script src="/js/user-edit.js"></script>
|
<script src="/js/user-edit.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,3 @@
|
|||||||
<div id="alert"></div>
|
|
||||||
<table class="uk-table uk-table-divider">
|
<table class="uk-table uk-table-divider">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -27,5 +26,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
<script src="/js/alert.js"></script>
|
||||||
<script src="/js/user.js"></script>
|
<script src="/js/user.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
Reference in New Issue
Block a user