mirror of
https://github.com/hkalexling/Mango.git
synced 2026-03-20 00:00:48 -04:00
Compare commits
8 Commits
dev
...
3b19883dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b19883dde | ||
|
|
6844860065 | ||
|
|
9eb699ea3b | ||
|
|
59bcb4db3b | ||
|
|
87c479bf42 | ||
|
|
e0713ccde8 | ||
|
|
a571d21cba | ||
|
|
23541f457e |
@@ -1,31 +0,0 @@
|
|||||||
class CreateSubscription < MG::Base
|
|
||||||
def up : String
|
|
||||||
# We allow multiple subscriptions for the same manga.
|
|
||||||
# This can be useful for example when you want to download from multiple
|
|
||||||
# groups.
|
|
||||||
<<-SQL
|
|
||||||
CREATE TABLE subscription (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
manga_id INTEGER NOT NULL,
|
|
||||||
language TEXT,
|
|
||||||
group_id INTEGER,
|
|
||||||
min_volume INTEGER,
|
|
||||||
max_volume INTEGER,
|
|
||||||
min_chapter INTEGER,
|
|
||||||
max_chapter INTEGER,
|
|
||||||
last_checked INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (username) REFERENCES users (username)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
|
|
||||||
def down : String
|
|
||||||
<<-SQL
|
|
||||||
DROP TABLE subscription;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -280,100 +280,6 @@ const downloadComponent = () => {
|
|||||||
UIkit.modal($('#modal').get(0)).hide();
|
UIkit.modal($('#modal').get(0)).hide();
|
||||||
this.searchInput = id;
|
this.searchInput = id;
|
||||||
this.search();
|
this.search();
|
||||||
},
|
|
||||||
|
|
||||||
subscribe(langConfirmed = false, groupConfirmed = false) {
|
|
||||||
const filters = {
|
|
||||||
manga: this.data.id,
|
|
||||||
language: this.langChoice === 'All' ? null : this.langChoice,
|
|
||||||
group: this.groupChoice === 'All' ? null : this.groupChoice,
|
|
||||||
volume: this.volumeRange === '' ? null : this.volumeRange,
|
|
||||||
chapter: this.chapterRange === '' ? null : this.chapterRange
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get group ID
|
|
||||||
if (filters.group) {
|
|
||||||
this.data.chapters.forEach(chp => {
|
|
||||||
const gid = chp.groups[filters.group];
|
|
||||||
if (gid) {
|
|
||||||
filters.groupId = gid;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse range values
|
|
||||||
if (filters.volume) {
|
|
||||||
[filters.volumeMin, filters.volumeMax] = this.parseRange(filters.volume);
|
|
||||||
}
|
|
||||||
if (filters.chapter) {
|
|
||||||
[filters.chapterMin, filters.chapterMax] = this.parseRange(filters.chapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filters.language && !langConfirmed) {
|
|
||||||
UIkit.modal.confirm('You didn\'t specify a language in the filtering rules. This might cause Mango to download chapters that are not in your preferred language. Are you sure you want to continue?', {
|
|
||||||
labels: {
|
|
||||||
ok: 'Yes',
|
|
||||||
cancel: 'Cancel'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
this.subscribe(true, groupConfirmed);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filters.group && !groupConfirmed) {
|
|
||||||
UIkit.modal.confirm('You didn\'t specify a group in the filtering rules. This might cause Mango to download multiple versions of the same chapter. Are you sure you want to continue?', {
|
|
||||||
labels: {
|
|
||||||
ok: 'Yes',
|
|
||||||
cancel: 'Cancel'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
this.subscribe(langConfirmed, true);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mangaURL = `${mangadex_base_url}/manga/${filters.manga}`;
|
|
||||||
|
|
||||||
console.log(filters);
|
|
||||||
UIkit.modal.confirm(`All <strong>FUTURE</strong> chapters matching the following filters will be downloaded:<br>
|
|
||||||
<ul>
|
|
||||||
<li>Manga ID: ${filters.manga}</li>
|
|
||||||
<li>Language: ${filters.language || 'all'}</li>
|
|
||||||
<li>Group: ${filters.group || 'all'}</li>
|
|
||||||
<li>Volume: ${filters.volume || 'all'}</li>
|
|
||||||
<li>Chapter: ${filters.chapter || 'all'}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<strong>IMPORTANT:</strong> Please make sure you are following the manga on MangaDex, otherwise Mango won't be able to receive any updates. To follow it, visit <a href="${mangaURL}">${mangaURL}</a> and click "Follow".
|
|
||||||
`, {
|
|
||||||
labels: {
|
|
||||||
ok: 'Confirm',
|
|
||||||
cancel: 'Cancel'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: `${base_url}api/admin/mangadex/subscriptions`,
|
|
||||||
data: JSON.stringify({
|
|
||||||
subscription: filters
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to subscribe. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
alert('success', `You've successfully subscribed to this manga! You can view and manage your subscriptions on the <a href="${base_url}download/subscription">subscription manager page</a>.`);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to subscribe. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,139 +1,326 @@
|
|||||||
const loadPlugin = id => {
|
const component = () => {
|
||||||
localStorage.setItem('plugin', id);
|
return {
|
||||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
plugins: [],
|
||||||
const newURL = `${url}?${$.param({
|
info: undefined,
|
||||||
plugin: id
|
pid: undefined,
|
||||||
})}`;
|
chapters: undefined, // undefined: not searched yet, []: empty
|
||||||
window.location.href = newURL;
|
manga: undefined, // undefined: not searched yet, []: empty
|
||||||
};
|
allChapters: [],
|
||||||
|
query: '',
|
||||||
|
mangaTitle: '',
|
||||||
|
searching: false,
|
||||||
|
adding: false,
|
||||||
|
sortOptions: [],
|
||||||
|
showFilters: false,
|
||||||
|
appliedFilters: [],
|
||||||
|
chaptersLimit: 500,
|
||||||
|
listManga: false,
|
||||||
|
|
||||||
$(() => {
|
init() {
|
||||||
var storedID = localStorage.getItem('plugin');
|
const tableObserver = new MutationObserver(() => {
|
||||||
if (storedID && storedID !== pid) {
|
console.log('table mutated');
|
||||||
loadPlugin(storedID);
|
$('#selectable').selectable({
|
||||||
} else {
|
filter: 'tr'
|
||||||
$('#controls').removeAttr('hidden');
|
});
|
||||||
}
|
});
|
||||||
|
tableObserver.observe($('table').get(0), {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
fetch(`${base_url}api/admin/plugin`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
this.plugins = data.plugins;
|
||||||
|
|
||||||
$('#search-input').keypress(event => {
|
const pid = localStorage.getItem('plugin');
|
||||||
if (event.which === 13) {
|
if (pid && this.plugins.map(p => p.id).includes(pid))
|
||||||
search();
|
return this.loadPlugin(pid);
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#plugin-select').val(pid);
|
|
||||||
$('#plugin-select').change(() => {
|
|
||||||
const id = $('#plugin-select').val();
|
|
||||||
loadPlugin(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mangaTitle = "";
|
if (this.plugins.length > 0)
|
||||||
let searching = false;
|
this.loadPlugin(this.plugins[0].id);
|
||||||
const search = () => {
|
})
|
||||||
if (searching)
|
.catch(e => {
|
||||||
return;
|
alert('danger', `Failed to list the available plugins. Error: ${e}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadPlugin(pid) {
|
||||||
|
fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
||||||
|
plugin: pid
|
||||||
|
})}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
this.info = data.info;
|
||||||
|
this.pid = pid;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to get plugin metadata. Error: ${e}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pluginChanged() {
|
||||||
|
this.loadPlugin(this.pid);
|
||||||
|
localStorage.setItem('plugin', this.pid);
|
||||||
|
},
|
||||||
|
get chapterKeys() {
|
||||||
|
if (this.allChapters.length < 1) return [];
|
||||||
|
return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k));
|
||||||
|
},
|
||||||
|
searchChapters(query) {
|
||||||
|
this.searching = true;
|
||||||
|
this.allChapters = [];
|
||||||
|
this.chapters = undefined;
|
||||||
|
this.listManga = false;
|
||||||
|
fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({
|
||||||
|
plugin: this.pid,
|
||||||
|
query: query
|
||||||
|
})}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
try {
|
||||||
|
this.mangaTitle = data.chapters[0].manga_title;
|
||||||
|
if (!this.mangaTitle) throw new Error();
|
||||||
|
} catch (e) {
|
||||||
|
this.mangaTitle = data.title;
|
||||||
|
}
|
||||||
|
|
||||||
const query = $.param({
|
this.allChapters = data.chapters;
|
||||||
query: $('#search-input').val(),
|
this.chapters = data.chapters;
|
||||||
plugin: pid
|
})
|
||||||
});
|
.catch(e => {
|
||||||
$.ajax({
|
alert('danger', `Failed to list chapters. Error: ${e}`);
|
||||||
type: 'GET',
|
})
|
||||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
.finally(() => {
|
||||||
contentType: "application/json",
|
this.searching = false;
|
||||||
dataType: 'json'
|
});
|
||||||
})
|
},
|
||||||
.done(data => {
|
searchManga() {
|
||||||
console.log(data);
|
this.searching = true;
|
||||||
if (data.error) {
|
this.allChapters = [];
|
||||||
alert('danger', `Search failed. Error: ${data.error}`);
|
this.chapters = undefined;
|
||||||
|
this.manga = undefined;
|
||||||
|
fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
||||||
|
plugin: this.pid,
|
||||||
|
query: this.query
|
||||||
|
})}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
this.manga = data.manga;
|
||||||
|
this.listManga = true;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Search failed. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.searching = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
this.manga = undefined;
|
||||||
|
if (this.info.version === 1) {
|
||||||
|
this.searchChapters(this.query);
|
||||||
|
} else {
|
||||||
|
this.searchManga();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectAll() {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).addClass('ui-selected');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
download() {
|
||||||
|
const selected = $('tbody > tr.ui-selected').get();
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
|
const ids = selected.map(e => e.id);
|
||||||
|
const chapters = this.chapters.filter(c => ids.includes(c.id));
|
||||||
|
console.log(chapters);
|
||||||
|
this.adding = true;
|
||||||
|
fetch(`${base_url}api/admin/plugin/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapters,
|
||||||
|
plugin: this.pid,
|
||||||
|
title: this.mangaTitle
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success)
|
||||||
|
throw new Error(data.error);
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.adding = false;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
},
|
||||||
|
thClicked(event) {
|
||||||
|
const idx = parseInt(event.currentTarget.id.split('-')[1]);
|
||||||
|
if (idx === undefined || isNaN(idx)) return;
|
||||||
|
const curOption = this.sortOptions[idx];
|
||||||
|
let option;
|
||||||
|
this.sortOptions = [];
|
||||||
|
switch (curOption) {
|
||||||
|
case 1:
|
||||||
|
option = -1;
|
||||||
|
break;
|
||||||
|
case -1:
|
||||||
|
option = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
option = 1;
|
||||||
|
}
|
||||||
|
this.sortOptions[idx] = option;
|
||||||
|
this.sort(this.chapterKeys[idx], option)
|
||||||
|
},
|
||||||
|
// Returns an array of filtered but unsorted chapters. Useful when
|
||||||
|
// reseting the sort options.
|
||||||
|
get filteredChapters() {
|
||||||
|
let ary = this.allChapters.slice();
|
||||||
|
|
||||||
|
console.log('initial size:', ary.length);
|
||||||
|
for (let filter of this.appliedFilters) {
|
||||||
|
if (!filter.value) continue;
|
||||||
|
if (filter.type === 'array' && filter.value === 'all') continue;
|
||||||
|
|
||||||
|
console.log('applying filter:', filter);
|
||||||
|
|
||||||
|
if (filter.type === 'string') {
|
||||||
|
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (filter.type === 'number-min') {
|
||||||
|
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'number-max') {
|
||||||
|
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'date-min') {
|
||||||
|
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'date-max') {
|
||||||
|
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value));
|
||||||
|
}
|
||||||
|
if (filter.type === 'array') {
|
||||||
|
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('filtered size:', ary.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ary;
|
||||||
|
},
|
||||||
|
// option:
|
||||||
|
// - 1: asending
|
||||||
|
// - -1: desending
|
||||||
|
// - 0: unsorted
|
||||||
|
sort(key, option) {
|
||||||
|
if (option === 0) {
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mangaTitle = data.title;
|
|
||||||
$('#title-text').text(data.title);
|
|
||||||
buildTable(data.chapters);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTable = (chapters) => {
|
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||||
$('#table').attr('hidden', '');
|
const comp = this.compare(a[key], b[key]);
|
||||||
$('table').empty();
|
return option < 0 ? comp * -1 : comp;
|
||||||
|
|
||||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
|
||||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
|
||||||
$('table').append(thead);
|
|
||||||
|
|
||||||
const rows = chapters.map(ch => {
|
|
||||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
|
||||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
|
||||||
});
|
|
||||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
|
||||||
$('table').append(tbody);
|
|
||||||
|
|
||||||
$('#selectable').selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
|
||||||
$('#table').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
|
||||||
id: $(e).attr('data-id'),
|
|
||||||
title: $(e).attr('data-title')
|
|
||||||
}
|
|
||||||
}).get();
|
|
||||||
console.log(chapters);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/plugin/download',
|
|
||||||
data: JSON.stringify({
|
|
||||||
plugin: pid,
|
|
||||||
chapters: chapters,
|
|
||||||
title: mangaTitle
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
compare(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
|
||||||
|
// try numbers
|
||||||
|
// this must come before the date checks, because any integer would
|
||||||
|
// also be parsed as a date.
|
||||||
|
if (!isNaN(a) && !isNaN(b))
|
||||||
|
return Number(a) - Number(b);
|
||||||
|
|
||||||
|
// try dates
|
||||||
|
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
|
||||||
|
return this.parseDate(a) - this.parseDate(b);
|
||||||
|
|
||||||
|
const preprocessString = (val) => {
|
||||||
|
if (typeof val !== 'string') return val;
|
||||||
|
return val.toLowerCase().replace(/\s\s/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||||
|
},
|
||||||
|
fieldType(values) {
|
||||||
|
if (values.every(v => !isNaN(v))) return 'number'; // display input for number range
|
||||||
|
if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range
|
||||||
|
if (values.every(v => Array.isArray(v))) return 'array'; // display input for contains
|
||||||
|
return 'string'; // display input for string searching.
|
||||||
|
// for the last two, if the number of options is small enough (say < 50), display a multi-select2
|
||||||
|
},
|
||||||
|
get filters() {
|
||||||
|
if (this.allChapters.length < 1) return [];
|
||||||
|
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k));
|
||||||
|
return keys.map(k => {
|
||||||
|
let values = this.allChapters.map(c => c[k]);
|
||||||
|
const type = this.fieldType(values);
|
||||||
|
|
||||||
|
if (type === 'array') {
|
||||||
|
// if the type is an array, return the list of available elements
|
||||||
|
// example: an array of groups or authors
|
||||||
|
values = Array.from(new Set(values.flat().map(v => {
|
||||||
|
if (typeof v === 'string') return v.toLowerCase();
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: k,
|
||||||
|
type: type,
|
||||||
|
values: values
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyFilters() {
|
||||||
|
const values = $('#filter-form input, #filter-form select')
|
||||||
|
.get()
|
||||||
|
.map(i => ({
|
||||||
|
key: i.getAttribute('data-filter-key'),
|
||||||
|
value: i.value.trim(),
|
||||||
|
type: i.getAttribute('data-filter-type')
|
||||||
|
}));
|
||||||
|
this.appliedFilters = values;
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
},
|
||||||
|
clearFilters() {
|
||||||
|
$('#filter-form input').get().forEach(i => i.value = '');
|
||||||
|
this.appliedFilters = [];
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
},
|
||||||
|
mangaSelected(event) {
|
||||||
|
const mid = event.currentTarget.getAttribute('data-id');
|
||||||
|
this.searchChapters(mid);
|
||||||
|
},
|
||||||
|
parseDate(str) {
|
||||||
|
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
|
||||||
|
// Basic sanity check to make sure it's an actual date.
|
||||||
|
// We need this because Date.parse thinks 'Chapter 1' is a date.
|
||||||
|
if (!regex.test(str))
|
||||||
|
return NaN;
|
||||||
|
return Date.parse(str);
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ shards:
|
|||||||
|
|
||||||
mangadex:
|
mangadex:
|
||||||
git: https://github.com/hkalexling/mangadex.git
|
git: https://github.com/hkalexling/mangadex.git
|
||||||
version: 0.11.0+git.commit.f5b0d64fbb138879fb9228b6e9ff34ec97c3e824
|
version: 0.9.0+git.commit.a8e5deb3e6f882f5bc0f4de66e0f6c20aa98a8a6
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ class Config
|
|||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
home: true),
|
home: true),
|
||||||
"chapter_rename_rule" => "[Vol.{volume} ]" \
|
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
||||||
"[Ch.{chapter} ]{title|id}",
|
"manga_rename_rule" => "{title}",
|
||||||
"manga_rename_rule" => "{title}",
|
|
||||||
"subscription_update_interval_hours" => 24,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@singlet : Config?
|
@@singlet : Config?
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Entry
|
|||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "title_id", @book.id
|
json.field "title_id", @book.id
|
||||||
|
json.field "title_title", @book.title
|
||||||
json.field "pages" { json.number @pages }
|
json.field "pages" { json.number @pages }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -42,25 +42,6 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
subscription_interval = Config.current
|
|
||||||
.mangadex["subscription_update_interval_hours"].as Int32
|
|
||||||
unless subscription_interval < 1
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
subscriptions = Storage.default.subscriptions
|
|
||||||
Logger.info "Checking MangaDex for updates on " \
|
|
||||||
"#{subscriptions.size} subscriptions"
|
|
||||||
added_count = 0
|
|
||||||
subscriptions.each do |sub|
|
|
||||||
added_count += sub.check_for_updates
|
|
||||||
end
|
|
||||||
Logger.info "Subscription update completed. Added #{added_count} " \
|
|
||||||
"chapters to the download queue"
|
|
||||||
sleep subscription_interval.hours
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
|
|||||||
@@ -56,39 +56,5 @@ module MangaDex
|
|||||||
hash["full_title"] = JSON::Any.new full_title
|
hash["full_title"] = JSON::Any.new full_title
|
||||||
hash.to_json
|
hash.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
# We don't need to rename the manga title here. It will be renamed in
|
|
||||||
# src/mangadex/downloader.cr
|
|
||||||
def to_job : Queue::Job
|
|
||||||
Queue::Job.new(
|
|
||||||
id.to_s,
|
|
||||||
manga_id.to_s,
|
|
||||||
full_title,
|
|
||||||
manga_title,
|
|
||||||
Queue::JobStatus::Pending,
|
|
||||||
Time.unix timestamp
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct User
|
|
||||||
def updates_after(time : Time, &block : Chapter ->)
|
|
||||||
page = 1
|
|
||||||
stopped = false
|
|
||||||
until stopped
|
|
||||||
chapters = followed_updates(page: page).chapters
|
|
||||||
return if chapters.empty?
|
|
||||||
chapters.each do |c|
|
|
||||||
if time > Time.unix c.timestamp
|
|
||||||
stopped = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
yield c
|
|
||||||
end
|
|
||||||
page += 1
|
|
||||||
# Let's not DDOS MangaDex :)
|
|
||||||
sleep 5.seconds
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ require "duktape/runtime"
|
|||||||
require "myhtml"
|
require "myhtml"
|
||||||
require "xml"
|
require "xml"
|
||||||
|
|
||||||
|
require "./subscriptions"
|
||||||
|
|
||||||
class Plugin
|
class Plugin
|
||||||
class Error < ::Exception
|
class Error < ::Exception
|
||||||
end
|
end
|
||||||
@@ -16,12 +18,19 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct Info
|
struct Info
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
{% for name in ["id", "title", "placeholder"] %}
|
||||||
getter {{name.id}} = ""
|
getter {{name.id}} = ""
|
||||||
{% end %}
|
{% end %}
|
||||||
getter wait_seconds : UInt64 = 0
|
getter wait_seconds = 0u64
|
||||||
|
getter version = 0u64
|
||||||
|
getter settings = {} of String => String?
|
||||||
getter dir : String
|
getter dir : String
|
||||||
|
|
||||||
|
@[JSON::Field(ignore: true)]
|
||||||
|
@json : JSON::Any
|
||||||
|
|
||||||
def initialize(@dir)
|
def initialize(@dir)
|
||||||
info_path = File.join @dir, "info.json"
|
info_path = File.join @dir, "info.json"
|
||||||
|
|
||||||
@@ -37,6 +46,16 @@ class Plugin
|
|||||||
@{{name.id}} = @json[{{name}}].as_s
|
@{{name.id}} = @json[{{name}}].as_s
|
||||||
{% end %}
|
{% end %}
|
||||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||||
|
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
||||||
|
|
||||||
|
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
||||||
|
settings_hash.each do |k, v|
|
||||||
|
unless str_value = v.as_s?
|
||||||
|
raise "The settings object can only contain strings or null"
|
||||||
|
end
|
||||||
|
@settings[k] = str_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
unless @id.alphanumeric_underscore?
|
unless @id.alphanumeric_underscore?
|
||||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||||
@@ -114,6 +133,22 @@ class Plugin
|
|||||||
@info.not_nil!
|
@info.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def subscribe(subscription : Subscription)
|
||||||
|
list = SubscriptionList.new info.dir
|
||||||
|
list << subscription
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_subscriptions
|
||||||
|
SubscriptionList.new(info.dir).ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribe(id : String)
|
||||||
|
list = SubscriptionList.new info.dir
|
||||||
|
list.reject &.id.== id
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(id : String)
|
def initialize(id : String)
|
||||||
Plugin.build_info_ary
|
Plugin.build_info_ary
|
||||||
|
|
||||||
@@ -138,6 +173,12 @@ class Plugin
|
|||||||
sbx.push_string path
|
sbx.push_string path
|
||||||
sbx.put_prop_string -2, "storage_path"
|
sbx.put_prop_string -2, "storage_path"
|
||||||
|
|
||||||
|
sbx.push_pointer info.dir.as(Void*)
|
||||||
|
path = sbx.require_pointer(-1).as String
|
||||||
|
sbx.pop
|
||||||
|
sbx.push_string path
|
||||||
|
sbx.put_prop_string -2, "info_dir"
|
||||||
|
|
||||||
def_helper_functions sbx
|
def_helper_functions sbx
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -152,23 +193,67 @@ class Plugin
|
|||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assert_manga_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Manga type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_chapter_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
||||||
|
obj["manga_title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Chapter type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_page_type(obj : JSON::Any)
|
||||||
|
obj["url"].as_s && obj["filename"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Page type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_manga(query : String)
|
||||||
|
if info.version == 1
|
||||||
|
raise Error.new "Manga searching is only available for plugins targeting API " \
|
||||||
|
"v2 or above"
|
||||||
|
end
|
||||||
|
json = eval_json "searchManga('#{query}')"
|
||||||
|
begin
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_manga_type obj
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
def list_chapters(query : String)
|
def list_chapters(query : String)
|
||||||
json = eval_json "listChapters('#{query}')"
|
json = eval_json "listChapters('#{query}')"
|
||||||
begin
|
begin
|
||||||
check_fields ["title", "chapters"]
|
if info.version > 1
|
||||||
|
# Since v2, listChapters returns an array
|
||||||
ary = json["chapters"].as_a
|
json.as_a.each do |obj|
|
||||||
ary.each do |obj|
|
assert_chapter_type obj
|
||||||
id = obj["id"]?
|
|
||||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
||||||
|
|
||||||
unless id.to_s.alphanumeric_underscore?
|
|
||||||
raise "The `id` field can only contain alphanumeric characters " \
|
|
||||||
"and underscores"
|
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
check_fields ["title", "chapters"]
|
||||||
|
|
||||||
title = obj["title"]?
|
ary = json["chapters"].as_a
|
||||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
ary.each do |obj|
|
||||||
|
id = obj["id"]?
|
||||||
|
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||||
|
|
||||||
|
unless id.to_s.alphanumeric_underscore?
|
||||||
|
raise "The `id` field can only contain alphanumeric characters " \
|
||||||
|
"and underscores"
|
||||||
|
end
|
||||||
|
|
||||||
|
title = obj["title"]?
|
||||||
|
if title.nil?
|
||||||
|
raise "Field `title` missing from `listChapters` outputs"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -179,10 +264,14 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
check_fields ["title", "pages"]
|
if info.version > 1
|
||||||
|
assert_chapter_type json
|
||||||
|
else
|
||||||
|
check_fields ["title", "pages"]
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
if json["title"].to_s.empty?
|
||||||
raise "The `title` field of the chapter can not be empty"
|
raise "The `title` field of the chapter can not be empty"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -194,7 +283,19 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
check_fields ["filename", "url"]
|
assert_page_type json
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_chapters(manga_id : String, after : Int64)
|
||||||
|
json = eval_json "newChapters('#{manga_id}', #{after})"
|
||||||
|
begin
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_chapter_type obj
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
@@ -379,6 +480,27 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "storage"
|
sbx.put_prop_string -2, "storage"
|
||||||
|
|
||||||
|
if info.version > 1
|
||||||
|
sbx.push_proc 1 do |ptr|
|
||||||
|
env = Duktape::Sandbox.new ptr
|
||||||
|
key = env.require_string 0
|
||||||
|
|
||||||
|
env.get_global_string "info_dir"
|
||||||
|
info_dir = env.require_string -1
|
||||||
|
env.pop
|
||||||
|
info = Info.new info_dir
|
||||||
|
|
||||||
|
if value = info.settings[key]?
|
||||||
|
env.push_string value
|
||||||
|
else
|
||||||
|
env.push_undefined
|
||||||
|
end
|
||||||
|
|
||||||
|
env.call_success
|
||||||
|
end
|
||||||
|
sbx.put_prop_string -2, "settings"
|
||||||
|
end
|
||||||
|
|
||||||
sbx.put_prop_string -2, "mango"
|
sbx.put_prop_string -2, "mango"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
87
src/plugin/subscriptions.cr
Normal file
87
src/plugin/subscriptions.cr
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
require "uuid"
|
||||||
|
|
||||||
|
enum FilterType
|
||||||
|
String
|
||||||
|
NumMin
|
||||||
|
NumMax
|
||||||
|
DateMin
|
||||||
|
DateMax
|
||||||
|
Array
|
||||||
|
|
||||||
|
def self.from_string(str)
|
||||||
|
case str
|
||||||
|
when "string"
|
||||||
|
String
|
||||||
|
when "number-min"
|
||||||
|
NumMin
|
||||||
|
when "number-max"
|
||||||
|
NumMax
|
||||||
|
when "date-min"
|
||||||
|
DateMin
|
||||||
|
when "date-max"
|
||||||
|
DateMax
|
||||||
|
when "array"
|
||||||
|
Array
|
||||||
|
else
|
||||||
|
raise "Unknown filter type with string #{str}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Filter
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property key : String
|
||||||
|
property value : String | Int32 | Int64 | Float32 | Nil
|
||||||
|
property type : FilterType
|
||||||
|
|
||||||
|
def initialize(@key, @value, @type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_json(str) : Filter
|
||||||
|
json = JSON.parse str
|
||||||
|
key = json["key"].as_s
|
||||||
|
type = FilterType.from_string json["type"].as_s
|
||||||
|
_value = json["value"]
|
||||||
|
value = _value.as_s? || _value.as_i32? || _value.as_i64? ||
|
||||||
|
_value.as_f32? || nil
|
||||||
|
self.new key, value, type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Subscription
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property id : String
|
||||||
|
property plugin_id : String
|
||||||
|
property name : String
|
||||||
|
property created_at : Int64
|
||||||
|
property last_checked : Int64
|
||||||
|
property filters = [] of Filter
|
||||||
|
|
||||||
|
def initialize(@plugin_id, @name)
|
||||||
|
@id = UUID.random.to_s
|
||||||
|
@created_at = Time.utc.to_unix
|
||||||
|
@last_checked = Time.utc.to_unix
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct SubscriptionList
|
||||||
|
@dir : String
|
||||||
|
@path : String
|
||||||
|
|
||||||
|
getter ary = [] of Subscription
|
||||||
|
|
||||||
|
forward_missing_to @ary
|
||||||
|
|
||||||
|
def initialize(@dir)
|
||||||
|
@path = Path[@dir, "subscriptions.json"]
|
||||||
|
if File.exists? @path
|
||||||
|
@ary = Array(Subscription).from_json File.read @path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def save
|
||||||
|
File.write @path, @ary.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -539,6 +539,97 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns a list of available plugins"
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"plugins" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin" do |env|
|
||||||
|
begin
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"plugins" => Plugin.list,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the metadata of a plugin"
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"info" => {
|
||||||
|
"dir" => String,
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
"placeholder" => String,
|
||||||
|
"wait_seconds" => Int32,
|
||||||
|
"version" => Int32,
|
||||||
|
"settings" => {} of String => String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin/info" do |env|
|
||||||
|
begin
|
||||||
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"info" => plugin.info,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
|
||||||
|
Only available for plugins targeting API v2 or above.
|
||||||
|
MD
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.query "query", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"manga" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin/search" do |env|
|
||||||
|
begin
|
||||||
|
query = env.params.query["query"].as String
|
||||||
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
|
manga_ary = plugin.search_manga(query).as_a
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"manga" => manga_ary,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
Koa.describe "Lists the chapters in a title from a plugin"
|
Koa.describe "Lists the chapters in a title from a plugin"
|
||||||
Koa.tags ["admin", "downloader"]
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.query "plugin", schema: String
|
Koa.query "plugin", schema: String
|
||||||
@@ -547,8 +638,8 @@ struct APIRouter
|
|||||||
"success" => Bool,
|
"success" => Bool,
|
||||||
"error" => String?,
|
"error" => String?,
|
||||||
"chapters?" => [{
|
"chapters?" => [{
|
||||||
"id" => String,
|
"id" => String,
|
||||||
"title" => String,
|
"title?" => String,
|
||||||
}],
|
}],
|
||||||
"title" => String?,
|
"title" => String?,
|
||||||
}
|
}
|
||||||
@@ -558,8 +649,14 @@ struct APIRouter
|
|||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
json = plugin.list_chapters query
|
||||||
chapters = json["chapters"]
|
|
||||||
title = json["title"]
|
if plugin.info.version == 1
|
||||||
|
chapters = json["chapters"]
|
||||||
|
title = json["title"]
|
||||||
|
else
|
||||||
|
chapters = json
|
||||||
|
title = nil
|
||||||
|
end
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@@ -986,147 +1083,23 @@ struct APIRouter
|
|||||||
Koa.tags ["admin", "mangadex"]
|
Koa.tags ["admin", "mangadex"]
|
||||||
get "/api/admin/mangadex/search" do |env|
|
get "/api/admin/mangadex/search" do |env|
|
||||||
begin
|
begin
|
||||||
|
username = get_username env
|
||||||
|
token, expires = Storage.default.get_md_token username
|
||||||
|
|
||||||
|
unless expires && token
|
||||||
|
raise "No token found for user #{username}"
|
||||||
|
end
|
||||||
|
|
||||||
|
client = MangaDex::Client.from_config
|
||||||
|
client.token = token
|
||||||
|
client.token_expires = expires
|
||||||
|
|
||||||
query = env.params.query["query"]
|
query = env.params.query["query"]
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
"error" => nil,
|
"error" => nil,
|
||||||
"manga" => get_client(env).partial_search query,
|
"manga" => client.partial_search query,
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Lists all MangaDex subscriptions"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"subscriptions?" => [{
|
|
||||||
"id" => Int64,
|
|
||||||
"username" => String,
|
|
||||||
"manga_id" => Int64,
|
|
||||||
"language" => String?,
|
|
||||||
"group_id" => Int64?,
|
|
||||||
"min_volume" => Int64?,
|
|
||||||
"max_volume" => Int64?,
|
|
||||||
"min_chapter" => Int64?,
|
|
||||||
"max_chapter" => Int64?,
|
|
||||||
"last_checked" => Int64,
|
|
||||||
"created_at" => Int64,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "subscriptions"]
|
|
||||||
get "/api/admin/mangadex/subscriptions" do |env|
|
|
||||||
begin
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
"subscriptions" => Storage.default.subscriptions,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Creates a new MangaDex subscription"
|
|
||||||
Koa.body schema: {
|
|
||||||
"subscription" => {
|
|
||||||
"manga" => Int64,
|
|
||||||
"language" => String?,
|
|
||||||
"groupId" => Int64?,
|
|
||||||
"volumeMin" => Int64?,
|
|
||||||
"volumeMax" => Int64?,
|
|
||||||
"chapterMin" => Int64?,
|
|
||||||
"chapterMax" => Int64?,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "subscriptions"]
|
|
||||||
post "/api/admin/mangadex/subscriptions" do |env|
|
|
||||||
begin
|
|
||||||
json = env.params.json["subscription"].as Hash(String, JSON::Any)
|
|
||||||
sub = Subscription.new json["manga"].as_i64, get_username env
|
|
||||||
sub.language = json["language"]?.try &.as_s?
|
|
||||||
sub.group_id = json["groupId"]?.try &.as_i64?
|
|
||||||
sub.min_volume = json["volumeMin"]?.try &.as_i64?
|
|
||||||
sub.max_volume = json["volumeMax"]?.try &.as_i64?
|
|
||||||
sub.min_chapter = json["chapterMin"]?.try &.as_i64?
|
|
||||||
sub.max_chapter = json["chapterMax"]?.try &.as_i64?
|
|
||||||
|
|
||||||
Storage.default.save_subscription sub
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a MangaDex subscription identified by `id`", <<-MD
|
|
||||||
Does nothing if the subscription was not created by the current user.
|
|
||||||
MD
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "subscriptions"]
|
|
||||||
delete "/api/admin/mangadex/subscriptions/:id" do |env|
|
|
||||||
begin
|
|
||||||
id = env.params.url["id"].to_i64
|
|
||||||
Storage.default.delete_subscription id, get_username env
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Triggers an update for a MangaDex subscription identified by `id`", <<-MD
|
|
||||||
Does nothing if the subscription was not created by the current user.
|
|
||||||
MD
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
Koa.tags ["admin", "mangadex", "subscriptions"]
|
|
||||||
post "/api/admin/mangadex/subscriptions/check/:id" do |env|
|
|
||||||
begin
|
|
||||||
id = env.params.url["id"].to_i64
|
|
||||||
username = get_username env
|
|
||||||
sub = Storage.default.get_subscription id, username
|
|
||||||
unless sub
|
|
||||||
raise "Subscription with id #{id} not found under user #{username}"
|
|
||||||
end
|
|
||||||
spawn do
|
|
||||||
sub.check_for_updates
|
|
||||||
end
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"error" => nil,
|
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
|
|||||||
@@ -79,16 +79,6 @@ struct MainRouter
|
|||||||
|
|
||||||
get "/download/plugins" do |env|
|
get "/download/plugins" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.query["plugin"]?
|
|
||||||
plugins = Plugin.list
|
|
||||||
plugin = nil
|
|
||||||
|
|
||||||
if id
|
|
||||||
plugin = Plugin.new id
|
|
||||||
elsif !plugins.empty?
|
|
||||||
plugin = Plugin.new plugins[0][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
layout "plugin-download"
|
layout "plugin-download"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@@ -96,12 +86,6 @@ struct MainRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/download/subscription" do |env|
|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
username = get_username env
|
|
||||||
layout "subscription"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/" do |env|
|
get "/" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ require "base64"
|
|||||||
require "./util/*"
|
require "./util/*"
|
||||||
require "mg"
|
require "mg"
|
||||||
require "../migration/*"
|
require "../migration/*"
|
||||||
require "./subscription"
|
|
||||||
|
|
||||||
def hash_password(pw)
|
def hash_password(pw)
|
||||||
Crypto::Bcrypt::Password.create(pw).to_s
|
Crypto::Bcrypt::Password.create(pw).to_s
|
||||||
@@ -15,9 +14,6 @@ def verify_password(hash, pw)
|
|||||||
(Crypto::Bcrypt::Password.new hash).verify pw
|
(Crypto::Bcrypt::Password.new hash).verify pw
|
||||||
end
|
end
|
||||||
|
|
||||||
SUB_ATTR = %w(manga_id language group_id min_volume max_volume min_chapter
|
|
||||||
max_chapter username)
|
|
||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
@@insert_entry_ids = [] of IDTuple
|
@@insert_entry_ids = [] of IDTuple
|
||||||
@@insert_title_ids = [] of IDTuple
|
@@insert_title_ids = [] of IDTuple
|
||||||
@@ -549,70 +545,6 @@ class Storage
|
|||||||
{token, expires}
|
{token, expires}
|
||||||
end
|
end
|
||||||
|
|
||||||
def save_subscription(sub : Subscription)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
{% begin %}
|
|
||||||
db.exec "insert into subscription (#{SUB_ATTR.join ","}, " \
|
|
||||||
"last_checked, created_at) values " \
|
|
||||||
"(#{Array.new(SUB_ATTR.size + 2, "?").join ","})",
|
|
||||||
{% for type in SUB_ATTR %}
|
|
||||||
sub.{{type.id}},
|
|
||||||
{% end %}
|
|
||||||
sub.last_checked.to_unix, sub.created_at.to_unix
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscriptions : Array(Subscription)
|
|
||||||
subs = [] of Subscription
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select * from subscription" do |rs|
|
|
||||||
subs += Subscription.from_rs rs
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
subs
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_subscription(id : Int64, username : String)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.exec "delete from subscription where id = (?) and username = (?)",
|
|
||||||
id, username
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_subscription(id : Int64, username : String) : Subscription?
|
|
||||||
sub = nil
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
db.query "select * from subscription where id = (?) and " \
|
|
||||||
"username = (?) limit 1", id, username do |rs|
|
|
||||||
sub = Subscription.from_rs(rs).first?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
sub
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_subscription_last_checked(id : Int64? = nil)
|
|
||||||
MainFiber.run do
|
|
||||||
get_db do |db|
|
|
||||||
if id
|
|
||||||
db.exec "update subscription set last_checked = (?) where id = (?)",
|
|
||||||
Time.utc.to_unix, id
|
|
||||||
else
|
|
||||||
db.exec "update subscription set last_checked = (?)",
|
|
||||||
Time.utc.to_unix
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def close
|
def close
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
unless @db.nil?
|
unless @db.nil?
|
||||||
|
|||||||
@@ -107,25 +107,6 @@ macro get_sort_opt
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns an authorized client
|
|
||||||
def get_client(username : String) : MangaDex::Client
|
|
||||||
token, expires = Storage.default.get_md_token username
|
|
||||||
|
|
||||||
unless expires && token
|
|
||||||
raise "No token found for user #{username}"
|
|
||||||
end
|
|
||||||
|
|
||||||
client = MangaDex::Client.from_config
|
|
||||||
client.token = token
|
|
||||||
client.token_expires = expires
|
|
||||||
|
|
||||||
client
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_client(env) : MangaDex::Client
|
|
||||||
get_client get_username env
|
|
||||||
end
|
|
||||||
|
|
||||||
module HTTP
|
module HTTP
|
||||||
class Client
|
class Client
|
||||||
private def self.exec(uri : URI, tls : TLSContext = nil)
|
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||||
|
|||||||
@@ -52,9 +52,9 @@
|
|||||||
<td x-text="`${job.plugin_id || ''}`"></td>
|
<td x-text="`${job.plugin_id || ''}`"></td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<a @click="jobAction('delete', $event)" uk-icon="trash" uk-tooltip="Delete"></a>
|
<a @click="jobAction('delete', $event)" uk-icon="trash"></a>
|
||||||
<template x-if="job.status_message.length > 0">
|
<template x-if="job.status_message.length > 0">
|
||||||
<a @click="jobAction('retry', $event)" uk-icon="refresh" uk-tooltip="Retry"></a>
|
<a @click="jobAction('retry', $event)" uk-icon="refresh"></a>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,170 +1,162 @@
|
|||||||
<h2 class=uk-title>Download from MangaDex</h2>
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
<div x-data="downloadComponent()" x-init="init()">
|
<div x-data="downloadComponent()" x-init="init()">
|
||||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
<div class="uk-width-expand">
|
<div class="uk-width-expand">
|
||||||
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
|
<input class="uk-input" type="text" :placeholder="searchAvailable ? 'Search MangaDex or enter a manga ID/URL' : 'MangaDex manga ID or URL'" x-model="searchInput" @keydown.enter.debounce="search()">
|
||||||
</div>
|
|
||||||
<div class="uk-width-auto">
|
|
||||||
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
|
||||||
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-width-auto">
|
||||||
|
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
||||||
|
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template x-if="mangaAry">
|
<template x-if="mangaAry">
|
||||||
<div>
|
<div>
|
||||||
<p x-show="mangaAry.length === 0">No matching manga found.</p>
|
<p x-show="mangaAry.length === 0">No matching manga found.</p>
|
||||||
|
|
||||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
<template x-for="manga in mangaAry" :key="manga.id">
|
<template x-for="manga in mangaAry" :key="manga.id">
|
||||||
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
|
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
|
||||||
<div class="uk-card uk-card-default">
|
<div class="uk-card uk-card-default">
|
||||||
<div class="uk-card-media-top uk-inline">
|
<div class="uk-card-media-top uk-inline">
|
||||||
<img uk-img :data-src="manga.mainCover">
|
<img uk-img :data-src="manga.mainCover">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-card-body">
|
<div class="uk-card-body">
|
||||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
|
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="manga.title"></h3>
|
||||||
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
|
<p class="uk-text-meta" x-text="`ID: ${manga.id}`"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div x-show="data && data.chapters" x-cloak>
|
<div x-show="data && data.chapters" x-cloak>
|
||||||
<div class"uk-grid-small" uk-grid>
|
<div class"uk-grid-small" uk-grid>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<img :src="data.mainCover">
|
<img :src="data.mainCover">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
|
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
|
||||||
<p x-text="`Artist: ${data.artist}`"></p>
|
<p x-text="`Artist: ${data.artist}`"></p>
|
||||||
<p x-text="`Author: ${data.author}`"></p>
|
<p x-text="`Author: ${data.author}`"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
||||||
<p class="uk-text-lead uk-margin-remove-bottom">
|
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||||
<span>Filter Chapters</span>
|
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
||||||
<button class="uk-icon-button uk-align-right" uk-icon="rss" uk-tooltip="Subscribe" x-show="searchAvailable" @click="subscribe()"></button>
|
<div class="uk-margin">
|
||||||
</p>
|
<label class="uk-form-label">Language</label>
|
||||||
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
<div class="uk-form-controls">
|
||||||
<div class="uk-margin">
|
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
||||||
<label class="uk-form-label">Language</label>
|
<template x-for="lang in languages" :key="lang">
|
||||||
<div class="uk-form-controls">
|
<option x-text="lang"></option>
|
||||||
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
</template>
|
||||||
<template x-for="lang in languages" :key="lang">
|
</select>
|
||||||
<option x-text="lang"></option>
|
</div>
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label">Group</label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
|
|
||||||
<template x-for="group in groups" :key="group">
|
|
||||||
<option x-text="group"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label">Volume</label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label">Chapter</label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<div class="uk-margin">
|
<label class="uk-form-label">Group</label>
|
||||||
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
<div class="uk-form-controls">
|
||||||
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
|
||||||
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
|
<template x-for="group in groups" :key="group">
|
||||||
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
|
<option x-text="group"></option>
|
||||||
</div>
|
</template>
|
||||||
<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>
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
|
|
||||||
<div class="uk-overflow-auto">
|
|
||||||
<table class="uk-table uk-table-striped" x-show="chapters.length <= chaptersLimit">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<template x-if="chapters.length <= chaptersLimit">
|
<div class="uk-margin">
|
||||||
<tbody id="selectable">
|
<label class="uk-form-label">Volume</label>
|
||||||
<template x-for="chp in chapters" :key="chp">
|
<div class="uk-form-controls">
|
||||||
<tr class="ui-widget-content">
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
|
||||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
|
</div>
|
||||||
<td x-text="chp.title"></td>
|
</div>
|
||||||
<td x-text="chp.language"></td>
|
|
||||||
<td>
|
<div class="uk-margin">
|
||||||
<template x-for="grp in Object.entries(chp.groups)">
|
<label class="uk-form-label">Chapter</label>
|
||||||
<div>
|
<div class="uk-form-controls">
|
||||||
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td x-text="chp.volume"></td>
|
</div>
|
||||||
<td x-text="chp.chapter"></td>
|
|
||||||
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
|
<div class="uk-margin">
|
||||||
</tr>
|
<div class="uk-margin">
|
||||||
</template>
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
</tbody>
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
|
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
|
||||||
|
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></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 x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
|
||||||
|
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<template x-if="chapters.length <= chaptersLimit">
|
||||||
|
<tbody id="selectable">
|
||||||
|
<template x-for="chp in chapters" :key="chp">
|
||||||
|
<tr class="ui-widget-content">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
|
||||||
|
<td x-text="chp.title"></td>
|
||||||
|
<td x-text="chp.language"></td>
|
||||||
|
<td>
|
||||||
|
<template x-for="grp in Object.entries(chp.groups)">
|
||||||
|
<div>
|
||||||
|
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</td>
|
||||||
</div>
|
<td x-text="chp.volume"></td>
|
||||||
</div>
|
<td x-text="chp.chapter"></td>
|
||||||
|
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="modal" class="uk-flex-top" uk-modal="container: false">
|
<div id="modal" class="uk-flex-top" uk-modal="container: false">
|
||||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
<div class="uk-modal-header">
|
<div class="uk-modal-header">
|
||||||
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
|
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
<div class="uk-grid">
|
<div class="uk-grid">
|
||||||
<div class="uk-width-1-3@s">
|
<div class="uk-width-1-3@s">
|
||||||
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
|
<img uk-img data-width data-height :src="candidateManga.mainCover" style="width:100%;margin-bottom:10px;">
|
||||||
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
|
<a :href="`<%= mangadex_base_url %>/manga/${candidateManga.id}`" x-text="`ID: ${candidateManga.id}`" class="uk-link-muted"></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-2-3@s">
|
<div class="uk-width-2-3@s" uk-overflow-auto>
|
||||||
<p x-text="candidateManga.description"></p>
|
<p x-text="candidateManga.description"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-modal-footer">
|
|
||||||
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-footer">
|
||||||
|
<button class="uk-button uk-button-primary" type="button" @click="confirmManga(candidateManga.id)">Choose</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<%= render_component "moment" %>
|
<%= render_component "moment" %>
|
||||||
<%= render_component "jquery-ui" %>
|
<%= render_component "jquery-ui" %>
|
||||||
<script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
const mangadex_base_url = "<%= mangadex_base_url %>";
|
<script src="<%= base_url %>js/download.js"></script>
|
||||||
</script>
|
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
|
||||||
<script src="<%= base_url %>js/download.js"></script>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,91 +1,89 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<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">
|
||||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||||
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
<ul class="uk-nav-parent-icon uk-nav-primary uk-nav-center uk-margin-auto-vertical" uk-nav>
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li class="uk-parent">
|
<li class="uk-parent">
|
||||||
<a href="#">Download</a>
|
<a href="#">Download</a>
|
||||||
<ul class="uk-nav-sub">
|
<ul class="uk-nav-sub">
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
<li><a href="<%= base_url %>download/subscription">Subscription Manager</a></li>
|
</ul>
|
||||||
</ul>
|
</li>
|
||||||
</li>
|
<% end %>
|
||||||
<% end %>
|
<hr uk-divider>
|
||||||
<hr uk-divider>
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-position-top">
|
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
|
||||||
<div class="uk-navbar-left uk-hidden@s">
|
|
||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
|
||||||
<li><a href="<%= base_url %>tags">Tags</a></li>
|
|
||||||
<% if is_admin %>
|
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Download</a>
|
|
||||||
<div class="uk-navbar-dropdown">
|
|
||||||
<ul class="uk-nav uk-navbar-dropdown-nav">
|
|
||||||
<li class="uk-nav-header">Source</li>
|
|
||||||
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
|
||||||
<li class="uk-nav-divider"></li>
|
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
|
||||||
<li><a href="<%= base_url %>download/subscription">Subscription Manager</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
<div class="uk-position-top">
|
||||||
<ul class="uk-navbar-nav">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<div class="uk-navbar-left uk-hidden@s">
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
</ul>
|
</div>
|
||||||
|
<div class="uk-navbar-left uk-visible@s">
|
||||||
|
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||||
|
<ul class="uk-navbar-nav">
|
||||||
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
|
<li><a href="<%= base_url %>tags">Tags</a></li>
|
||||||
|
<% if is_admin %>
|
||||||
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Download</a>
|
||||||
|
<div class="uk-navbar-dropdown">
|
||||||
|
<ul class="uk-nav uk-navbar-dropdown-nav">
|
||||||
|
<li class="uk-nav-header">Source</li>
|
||||||
|
<li><a href="<%= base_url %>download">MangaDex</a></li>
|
||||||
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
|
<li class="uk-nav-divider"></li>
|
||||||
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="uk-navbar-right uk-visible@s">
|
||||||
|
<ul class="uk-navbar-nav">
|
||||||
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="uk-section uk-section-small">
|
||||||
</div>
|
|
||||||
<div class="uk-section uk-section-small">
|
|
||||||
</div>
|
|
||||||
<div class="uk-section uk-section-small" style="position:relative;">
|
|
||||||
<div class="uk-container uk-container-small">
|
|
||||||
<div id="alert"></div>
|
|
||||||
<%= content %>
|
|
||||||
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
|
||||||
<a href="#" uk-totop uk-scroll></a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="uk-section uk-section-small" style="position:relative;">
|
||||||
</div>
|
<div class="uk-container uk-container-small">
|
||||||
<script>
|
<div id="alert"></div>
|
||||||
setTheme();
|
<%= content %>
|
||||||
const base_url = "<%= base_url %>";
|
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
|
||||||
</script>
|
<a href="#" uk-totop uk-scroll></a>
|
||||||
<%= render_component "uikit" %>
|
</div>
|
||||||
<%= yield_content "script" %>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
|
<script>
|
||||||
|
setTheme();
|
||||||
|
const base_url = "<%= base_url %>";
|
||||||
|
</script>
|
||||||
|
<%= render_component "uikit" %>
|
||||||
|
<%= yield_content "script" %>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,77 +1,180 @@
|
|||||||
<% if plugins.empty? %>
|
<div x-data="component()" x-init="init()" x-cloak>
|
||||||
<div class="uk-container uk-text-center">
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
<h2>No Plugins Found</h2>
|
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
<h2>No Plugins Found</h2>
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||||
</div>
|
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||||
|
|
||||||
<% else %>
|
|
||||||
<h2 class=uk-title>Download with Plugins</h2>
|
|
||||||
|
|
||||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
|
||||||
<div class="uk-width-3-4@m uk-child-width-1-1">
|
|
||||||
<div class="uk-margin">
|
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-expand">
|
|
||||||
<div class="uk-margin">
|
<div x-show="plugins.length > 0" style="width:100%">
|
||||||
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
<h2 class=uk-title>Download with Plugins
|
||||||
<div class="uk-form-controls">
|
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||||
<select id="plugin-select" class="uk-select">
|
</h2>
|
||||||
<% plugins.each do |p| %>
|
|
||||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
<template x-if="info !== undefined">
|
||||||
<% end %>
|
<div>
|
||||||
</select>
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<label class="uk-form-label"> </label>
|
||||||
|
<input class="uk-input" type="text" :placeholder="info.placeholder" x-model="query" @keydown.enter="search()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-expand">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">Choose a plugin</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||||
|
<template x-for="p in plugins" :key="p">
|
||||||
|
<option :value="p.id" x-text="p.title"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-auto">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label"> </label>
|
||||||
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
|
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
||||||
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
|
<dt x-text="entry[0]"></dt>
|
||||||
|
<dd x-text="entry[1]"></dd>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
<div class="uk-width-auto">
|
<template x-if="manga">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||||
<div class="uk-form-controls" style="padding-top: 10px;">
|
<p x-show="manga.length > 0">
|
||||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
<span x-text="`${manga.length} manga found`"></span>
|
||||||
|
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
|
||||||
|
<template x-for="m in manga" :key="m.id">
|
||||||
|
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
|
||||||
|
<div class="uk-card uk-card-default">
|
||||||
|
<div class="uk-card-media-top uk-inline">
|
||||||
|
<img uk-img :data-src="m.cover_url">
|
||||||
|
</div>
|
||||||
|
<div class="uk-card-body">
|
||||||
|
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
|
||||||
|
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="uk-margin-large-top" x-show="chapters !== undefined">
|
||||||
|
<h3 x-text="mangaTitle"></h3>
|
||||||
|
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
|
||||||
|
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
|
<button class="uk-button uk-button-primary" @click="download()">Download Selected</button>
|
||||||
|
<button class="uk-icon-button uk-margin-small-left" uk-icon="settings" @click="showFilters = !showFilters"></button>
|
||||||
|
</div>
|
||||||
|
<div uk-spinner class="uk-margin-left" x-show="adding"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-show="showFilters || (chapters && chapters.length > chaptersLimit)" class="uk-form-stacked uk-margin-bottom" id="filter-form">
|
||||||
|
<template x-for="field in filters">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">
|
||||||
|
<span x-text="field.key"></span>
|
||||||
|
<template x-if="field.type === 'number'">
|
||||||
|
<span class="uk-text-meta" x-text="`(between ${Math.min(...field.values)} and ${Math.max(...field.values)})`"></span>
|
||||||
|
</template>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div x-show="field.type === 'number'" class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="minimum value" :data-filter-key="field.key" data-filter-type="number-min">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="maximum value" :data-filter-key="field.key" data-filter-type="number-max">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input x-show="field.type === 'string'" class="uk-input" placeholder="filter text" :data-filter-key="field.key" data-filter-type="string">
|
||||||
|
|
||||||
|
<select class="uk-select" x-show="field.type === 'array'" :data-filter-key="field.key" data-filter-type="array">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<template x-for="v in field.values" :key="v">
|
||||||
|
<option x-text="v" :value="v"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
|
||||||
|
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>
|
||||||
|
<p x-show="chapters && chapters.length === 0" class="uk-text-meta">No chapters found.</p>
|
||||||
|
|
||||||
|
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<template x-for="(k, idx) in chapterKeys" :key="k">
|
||||||
|
<th :id="`th-${idx}`" @click="thClicked($event)">
|
||||||
|
<span x-text="k"></span>
|
||||||
|
<i class="fas fa-sort" x-show="![1, -1].includes(sortOptions[idx])"></i>
|
||||||
|
<i class="fas fa-sort-up" x-show="sortOptions[idx] === 1"></i>
|
||||||
|
<i class="fas fa-sort-down" x-show="sortOptions[idx] === -1"></i>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="selectable">
|
||||||
|
<template x-if="chapters !== undefined && chapters.length < chaptersLimit">
|
||||||
|
<template x-for="ch in chapters" :key="ch">
|
||||||
|
<tr class="ui-widget-content" :id="ch.id">
|
||||||
|
<template x-for="k in chapterKeys" :key="k">
|
||||||
|
<td x-text="ch[k]"></td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<dl class="uk-description-list" id="toggle" hidden>
|
|
||||||
<% plugin.not_nil!.info.each do |k, v| %>
|
|
||||||
<dt><%= k %></dt>
|
|
||||||
<dd><%= v.to_s %></dd>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div id="table" class="uk-margin-large-top" hidden>
|
|
||||||
<h3 id="title-text"></h3>
|
|
||||||
|
|
||||||
<div class="uk-margin">
|
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
|
||||||
</div>
|
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
|
||||||
<div class="uk-overflow-auto">
|
|
||||||
<table class="uk-table uk-table-striped tablesorter">
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<% if plugin %>
|
|
||||||
<script>
|
|
||||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
|
||||||
</script>
|
|
||||||
<% end %>
|
|
||||||
<%= render_component "jquery-ui" %>
|
<%= render_component "jquery-ui" %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
Reference in New Issue
Block a user