- Filter MangaDex chapters with volume and chapter range

- Select from table rows using jQuery selectable
This commit is contained in:
Alex Ling 2020-02-25 01:52:04 +00:00
parent b264f7dd76
commit d782995bac
3 changed files with 282 additions and 194 deletions

View File

@ -26,3 +26,9 @@
.uk-search { .uk-search {
width: 100%; width: 100%;
} }
#selectable .ui-selecting {
background: #EEE6B9;
}
#selectable .ui-selected {
background: #F4E487;
}

257
public/js/download.js Normal file
View File

@ -0,0 +1,257 @@
$(() => {
$('#search-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('.filter-field').each((i, ele) => {
$(ele).change(() => {
buildTable();
});
});
});
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: ' + status);
})
.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(' | ');
return `<tr class="ui-widget-content">
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
<td>${chp.title}</td>
<td>${chp.language}</td>
<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',
autoRefresh: false
});
$('#selection-controls').removeAttr('hidden');
};
const alert = (level, text) => {
hideAlert();
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 });
};
const hideAlert = () => {
$('#alert').empty();
};

View File

@ -2,7 +2,7 @@
<h2 class=uk-title>Download from MangaDex</h2> <h2 class=uk-title>Download from MangaDex</h2>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
<div class="uk-width-3-4"> <div class="uk-width-3-4">
<input class="uk-input" type="text" placeholder="MangaDex manga ID or URL"> <input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
</div> </div>
<div class="uk-width-1-4"> <div class="uk-width-1-4">
<div id="spinner" uk-spinner class="uk-align-center" hidden></div> <div id="spinner" uk-spinner class="uk-align-center" hidden></div>
@ -24,34 +24,41 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="lang-select">Language</label> <label class="uk-form-label" for="lang-select">Language</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select" id="lang-select"> <select class="uk-select filter-field" id="lang-select">
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="group-select">Group</label> <label class="uk-form-label" for="group-select">Group</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select" id="group-select"> <select class="uk-select filter-field" id="group-select">
</select> </select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="volume-select">Volume</label> <label class="uk-form-label" for="volume-range">Volume</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select" id="volume-select"> <input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</select>
</div> </div>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="chapter-select">Chapter</label> <label class="uk-form-label" for="chapter-range">Chapter</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select class="uk-select" id="chapter-select"> <input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
</select>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<table class="uk-table uk-table-striped" hidden> <div id="selection-controls" class="uk-margin" hidden>
<div class="uk-margin">
<button class="uk-button uk-button-default">Select All</button>
<button class="uk-button uk-button-default">Clear Selections</button>
<button class="uk-button uk-button-primary">Download Selected</button>
</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> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@ -70,188 +77,6 @@
var baseURL = "<%= base_url %>".replace(/\/$/, ""); var baseURL = "<%= base_url %>".replace(/\/$/, "");
</script> </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/moment.js/2.24.0/moment.min.js"></script>
<script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
$(() => { <script src="/js/download.js"></script>
$('.uk-input').keypress(event => {
if (event.which === 13) {
search();
}
});
$('.uk-select').each((i, ele) => {
$(ele).change(() => {
buildTable();
});
});
});
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 = undefined;
const search = () => {
if (searching) {
return;
}
$('#manga-details').attr('hidden', '');
$('table').attr('hidden', '');
$('#filter-form').attr('hidden', '');
toggleSpinner();
var 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 {
try {
int_id = parseInt(input);
}
catch {}
}
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();
let volumes = new Set();
let chapters = new Set();
data.chapters.forEach(chp => {
Object.entries(chp.groups).forEach(([k, v]) => {
group_names.add(k);
});
langs.add(chp.language);
volumes.add(chp.volume);
chapters.add(chp.chapter);
});
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();
volumes = [...volumes].sort(comp);
chapters = [...chapters].sort(comp);
langs.unshift('All');
group_names.unshift('All');
volumes.unshift('All');
chapters.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(''));
$('select#volume-select').append(volumes.map(e => `<option>${e}</option>`).join(''));
$('select#chapter-select').append(chapters.map(e => `<option>${e}</option>`).join(''));
$('#filter-form').removeAttr('hidden');
buildTable();
})
.fail((jqXHR, status) => {
alert('danger', 'Failed to get manga info. Error: ' + status);
})
.always(() => {
toggleSpinner();
});
};
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;
});
return filters;
};
const buildTable = () => {
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') k = 'language';
chapters = chapters.filter(c => c[k] === v);
});
console.log('filtered chapters:', chapters);
$('#count-text').text(`${chapters.length} chapters found`)
const inner = chapters.map(chp => {
const group_str = Object.entries(chp.groups).map(([k, v]) => {
return `<a href="${baseURL }/group/${v}">${k}</a>`;
}).join(' | ');
return `<tr>
<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>${inner}</tbody>`;
$('table > tbody').remove();
$('table').append(tbody);
$('table').removeAttr('hidden');
};
const alert = (level, text) => {
hideAlert();
const html = '<div class="uk-alert-' + level + '" uk-alert><a class="uk-alert-close" uk-close></a><p>' + text + '</p></div>';
$('#alert').append(html);
};
const hideAlert = () => {
$('#alert').empty();
};
</script>
<% end %> <% end %>