diff --git a/public/js/plugin-download-2.js b/public/js/plugin-download-2.js deleted file mode 100644 index 3986242..0000000 --- a/public/js/plugin-download-2.js +++ /dev/null @@ -1,257 +0,0 @@ -// TODO: limit the number of chapters to list - -const component = () => { - return { - plugins: [], - info: undefined, - pid: undefined, - chapters: undefined, // undefined: not searched yet, []: empty - allChapters: [], - query: '', - mangaTitle: '', - searching: false, - adding: false, - sortOptions: [], - showFilters: false, - appliedFilters: [], - chaptersLimit: 500, - init() { - const tableObserver = new MutationObserver(() => { - console.log('table mutated'); - $('#selectable').selectable({ - filter: 'tr' - }); - }); - 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; - - const pid = localStorage.getItem('plugin'); - if (pid && this.plugins.map(p => p.id).includes(pid)) - return this.loadPlugin(pid); - - if (this.plugins.length > 0) - this.loadPlugin(this.plugins[0].id); - }) - .catch(e => { - 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)); - }, - search() { - this.searching = true; - this.allChapters = []; - this.chapters = undefined; - fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({ - plugin: this.pid, - query: this.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; - } - - data.chapters = Array(10).fill(data.chapters).flat(); - - this.allChapters = data.chapters; - this.chapters = data.chapters; - }) - .catch(e => { - alert('danger', `Failed to list chapters. Error: ${e}`); - }) - .finally(() => { - this.searching = false; - }); - }, - 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); - }) - }, - 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 => Date.parse(ch[filter.key]) >= Date.parse(filter.value)); - } - if (filter.type === 'date-max') { - ary = ary.filter(ch => Date.parse(ch[filter.key]) <= Date.parse(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; - } - - this.chapters = this.filteredChapters.sort((a, b) => { - const comp = this.compare(a[key], b[key]); - return option < 0 ? comp * -1 : comp; - }); - }, - 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(Date.parse(a)) && !isNaN(Date.parse(b))) - return Date.parse(a) - Date.parse(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(Date.parse(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; - }, - }; -}; diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index a335e03..3a846ec 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -1,139 +1,326 @@ -const loadPlugin = id => { - localStorage.setItem('plugin', id); - const url = `${location.protocol}//${location.host}${location.pathname}`; - const newURL = `${url}?${$.param({ - plugin: id - })}`; - window.location.href = newURL; -}; +const component = () => { + return { + plugins: [], + info: undefined, + pid: undefined, + chapters: undefined, // undefined: not searched yet, []: empty + manga: undefined, // undefined: not searched yet, []: empty + allChapters: [], + query: '', + mangaTitle: '', + searching: false, + adding: false, + sortOptions: [], + showFilters: false, + appliedFilters: [], + chaptersLimit: 500, + listManga: false, -$(() => { - var storedID = localStorage.getItem('plugin'); - if (storedID && storedID !== pid) { - loadPlugin(storedID); - } else { - $('#controls').removeAttr('hidden'); - } + init() { + const tableObserver = new MutationObserver(() => { + console.log('table mutated'); + $('#selectable').selectable({ + filter: 'tr' + }); + }); + 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 => { - if (event.which === 13) { - search(); - } - }); - $('#plugin-select').val(pid); - $('#plugin-select').change(() => { - const id = $('#plugin-select').val(); - loadPlugin(id); - }); -}); + const pid = localStorage.getItem('plugin'); + if (pid && this.plugins.map(p => p.id).includes(pid)) + return this.loadPlugin(pid); -let mangaTitle = ""; -let searching = false; -const search = () => { - if (searching) - return; + if (this.plugins.length > 0) + this.loadPlugin(this.plugins[0].id); + }) + .catch(e => { + 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({ - query: $('#search-input').val(), - plugin: pid - }); - $.ajax({ - type: 'GET', - url: `${base_url}api/admin/plugin/list?${query}`, - contentType: "application/json", - dataType: 'json' - }) - .done(data => { - console.log(data); - if (data.error) { - alert('danger', `Search failed. Error: ${data.error}`); + this.allChapters = data.chapters; + this.chapters = data.chapters; + }) + .catch(e => { + alert('danger', `Failed to list chapters. Error: ${e}`); + }) + .finally(() => { + this.searching = false; + }); + }, + searchManga() { + this.searching = true; + this.allChapters = []; + 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 download manager page.`); + }) + .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; } - mangaTitle = data.title; - $('#title-text').text(data.title); - buildTable(data.chapters); - }) - .fail((jqXHR, status) => { - alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }) - .always(() => {}); -}; -const buildTable = (chapters) => { - $('#table').attr('hidden', ''); - $('table').empty(); - - const keys = Object.keys(chapters[0]).map(k => `${k}`).join(''); - const thead = `${keys}`; - $('table').append(thead); - - const rows = chapters.map(ch => { - const tds = Object.values(ch).map(v => `${v}`).join(''); - return `${tds}`; - }); - const tbody = `${rows}`; - $('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 download manager page.`); - }) - .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'); + this.chapters = this.filteredChapters.sort((a, b) => { + const comp = this.compare(a[key], b[key]); + return option < 0 ? comp * -1 : comp; }); - }); + }, + 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); + } + }; }; diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index f8cb109..be08d6f 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -44,7 +44,7 @@ class Plugin @{{name.id}} = @json[{{name}}].as_s {% end %} @wait_seconds = @json["wait_seconds"].as_i.to_u64 - @version = @json["api_verson"]?.try(&.as_i.to_u64) || 1u64 + @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| diff --git a/src/routes/api.cr b/src/routes/api.cr index b1f0752..155e0dc 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -638,8 +638,8 @@ struct APIRouter "success" => Bool, "error" => String?, "chapters?" => [{ - "id" => String, - "title" => String, + "id" => String, + "title?" => String, }], "title" => String?, } @@ -649,8 +649,14 @@ struct APIRouter plugin = Plugin.new env.params.query["plugin"].as String 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, { "success" => true, diff --git a/src/routes/main.cr b/src/routes/main.cr index 79d135c..b3be181 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -79,16 +79,6 @@ struct MainRouter get "/download/plugins" do |env| begin - id = env.params.query["plugin"]? - plugins = Plugin.list - plugin = nil - - if id - plugin = Plugin.new id - elsif !plugins.empty? - plugin = Plugin.new plugins[0][:id] - end - layout "plugin-download" rescue e Logger.error e @@ -96,15 +86,6 @@ struct MainRouter end end - get "/download/plugins2" do |env| - begin - layout "plugin-download-2" - rescue e - Logger.error e - env.response.status_code = 500 - end - end - get "/download/subscription" do |env| mangadex_base_url = Config.current.mangadex["base_url"] username = get_username env diff --git a/src/views/plugin-download-2.html.ecr b/src/views/plugin-download-2.html.ecr deleted file mode 100644 index ca66478..0000000 --- a/src/views/plugin-download-2.html.ecr +++ /dev/null @@ -1,155 +0,0 @@ -
-
-
-

No Plugins Found

-

We could't find any plugins in the directory <%= Config.current.plugin_path %>.

-

You can download official plugins from the Mango plugins repository.

-
- -
-

Download with Plugins - -

- - - -
-

-

- -
-
- - - - -
-
-
- -
- - - -
- -

-

No chapters found.

- -
-

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.

-
- - - - - - - - - -
-
-
-
-
-
-
- -<% content_for "script" do %> - <%= render_component "moment" %> - <%= render_component "jquery-ui" %> - - -<% end %> diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index ece56b6..bf68325 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -1,77 +1,180 @@ -<% if plugins.empty? %> -
-

No Plugins Found

-

We could't find any plugins in the directory <%= Config.current.plugin_path %>.

-

You can download official plugins from the Mango plugins repository.

-
- -<% else %> -

Download with Plugins

- -