mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-06 04:45:31 -04:00
Compare commits
No commits in common. "master" and "v0.25.0" have entirely different histories.
@ -12,4 +12,3 @@ Layout/LineLength:
|
|||||||
MaxLength: 80
|
MaxLength: 80
|
||||||
Excluded:
|
Excluded:
|
||||||
- src/routes/api.cr
|
- src/routes/api.cr
|
||||||
- spec/plugin_spec.cr
|
|
||||||
|
@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
|
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
- Multi-user support
|
- Multi-user support
|
||||||
@ -54,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.27.0
|
Mango - Manga Server and Web Reader. Version 0.25.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@ -97,10 +94,9 @@ cache_log_enabled: true
|
|||||||
disable_login: false
|
disable_login: false
|
||||||
default_username: ""
|
default_username: ""
|
||||||
auth_proxy_header_name: ""
|
auth_proxy_header_name: ""
|
||||||
plugin_update_interval_hours: 24
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||||
|
@ -55,7 +55,7 @@ gulp.task('minify-css', () => {
|
|||||||
gulp.task('copy-files', () => {
|
gulp.task('copy-files', () => {
|
||||||
return gulp.src([
|
return gulp.src([
|
||||||
'public/*.*',
|
'public/*.*',
|
||||||
'public/img/**',
|
'public/img/*',
|
||||||
'public/webfonts/*',
|
'public/webfonts/*',
|
||||||
'public/js/*.min.js'
|
'public/js/*.min.js'
|
||||||
], {
|
], {
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.1 KiB |
@ -31,9 +31,6 @@ const component = () => {
|
|||||||
this.scanMs = data.milliseconds;
|
this.scanMs = data.milliseconds;
|
||||||
this.scanTitles = data.titles;
|
this.scanTitles = data.titles;
|
||||||
})
|
})
|
||||||
.catch(e => {
|
|
||||||
alert('danger', `Failed to trigger a scan. Error: ${e}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
.always(() => {
|
||||||
this.scanning = false;
|
this.scanning = false;
|
||||||
});
|
});
|
||||||
|
@ -1,452 +1,144 @@
|
|||||||
const component = () => {
|
const loadPlugin = id => {
|
||||||
return {
|
localStorage.setItem('plugin', id);
|
||||||
plugins: [],
|
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||||
subscribable: false,
|
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
|
};
|
||||||
mid: undefined, // id of the selected manga
|
|
||||||
allChapters: [],
|
|
||||||
query: "",
|
|
||||||
mangaTitle: "",
|
|
||||||
searching: false,
|
|
||||||
adding: false,
|
|
||||||
sortOptions: [],
|
|
||||||
showFilters: false,
|
|
||||||
appliedFilters: [],
|
|
||||||
chaptersLimit: 500,
|
|
||||||
listManga: false,
|
|
||||||
subscribing: false,
|
|
||||||
subscriptionName: "",
|
|
||||||
|
|
||||||
init() {
|
$(() => {
|
||||||
const tableObserver = new MutationObserver(() => {
|
var storedID = localStorage.getItem('plugin');
|
||||||
console.log("table mutated");
|
if (storedID && storedID !== pid) {
|
||||||
$("#selectable").selectable({
|
loadPlugin(storedID);
|
||||||
filter: "tr",
|
} else {
|
||||||
});
|
$('#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;
|
|
||||||
|
|
||||||
const pid = localStorage.getItem("plugin");
|
$('#search-input').keypress(event => {
|
||||||
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
if (event.which === 13) {
|
||||||
return this.loadPlugin(pid);
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#plugin-select').val(pid);
|
||||||
|
$('#plugin-select').change(() => {
|
||||||
|
const id = $('#plugin-select').val();
|
||||||
|
loadPlugin(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (this.plugins.length > 0)
|
let mangaTitle = "";
|
||||||
this.loadPlugin(this.plugins[0].id);
|
let searching = false;
|
||||||
})
|
const search = () => {
|
||||||
.catch((e) => {
|
if (searching)
|
||||||
alert(
|
return;
|
||||||
"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.subscribable = data.subscribable;
|
|
||||||
this.pid = pid;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
alert(
|
|
||||||
"danger",
|
|
||||||
`Failed to get plugin metadata. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
pluginChanged() {
|
|
||||||
this.manga = undefined;
|
|
||||||
this.chapters = undefined;
|
|
||||||
this.mid = undefined;
|
|
||||||
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.sortOptions = [];
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.allChapters = data.chapters;
|
const query = $.param({
|
||||||
this.chapters = data.chapters;
|
query: $('#search-input').val(),
|
||||||
})
|
plugin: pid
|
||||||
.catch((e) => {
|
});
|
||||||
alert("danger", `Failed to list chapters. Error: ${e}`);
|
$.ajax({
|
||||||
})
|
type: 'GET',
|
||||||
.finally(() => {
|
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||||
this.searching = false;
|
contentType: "application/json",
|
||||||
});
|
dataType: 'json'
|
||||||
},
|
})
|
||||||
searchManga(query) {
|
.done(data => {
|
||||||
this.searching = true;
|
console.log(data);
|
||||||
this.allChapters = [];
|
if (data.error) {
|
||||||
this.chapters = undefined;
|
alert('danger', `Search failed. Error: ${data.error}`);
|
||||||
this.manga = undefined;
|
|
||||||
fetch(
|
|
||||||
`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
|
||||||
plugin: this.pid,
|
|
||||||
query: 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() {
|
|
||||||
const query = this.query.trim();
|
|
||||||
if (!query) return;
|
|
||||||
|
|
||||||
this.manga = undefined;
|
|
||||||
this.mid = undefined;
|
|
||||||
if (this.info.version === 1) {
|
|
||||||
this.searchChapters(query);
|
|
||||||
} else {
|
|
||||||
this.searchManga(query);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
if (filter.type.startsWith("number") && isNaN(filter.value))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
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) => Number(ch[filter.key]) >= Number(filter.value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (filter.type === "date-max") {
|
|
||||||
ary = ary.filter(
|
|
||||||
(ch) => Number(ch[filter.key]) <= Number(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;
|
||||||
this.chapters = this.filteredChapters.sort((a, b) => {
|
$('#title-text').text(data.title);
|
||||||
const comp = this.compare(a[key], b[key]);
|
buildTable(data.chapters);
|
||||||
return option < 0 ? comp * -1 : comp;
|
})
|
||||||
});
|
.fail((jqXHR, status) => {
|
||||||
},
|
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
compare(a, b) {
|
})
|
||||||
if (a === b) return 0;
|
.always(() => {});
|
||||||
|
};
|
||||||
// try numbers (also covers dates)
|
|
||||||
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
|
const buildTable = (chapters) => {
|
||||||
|
$('#table').attr('hidden', '');
|
||||||
const preprocessString = (val) => {
|
$('table').empty();
|
||||||
if (typeof val !== "string") return val;
|
|
||||||
return val.toLowerCase().replace(/\s\s/g, " ").trim();
|
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||||
};
|
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||||
|
$('table').append(thead);
|
||||||
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
|
||||||
},
|
const rows = chapters.map(ch => {
|
||||||
fieldType(values) {
|
const tds = Object.values(ch).map(v => {
|
||||||
if (values.every((v) => this.numIsDate(v))) return "date";
|
const maxLength = 40;
|
||||||
if (values.every((v) => !isNaN(v))) return "number";
|
const shouldShrink = v && v.length > maxLength;
|
||||||
if (values.every((v) => Array.isArray(v))) return "array";
|
const content = shouldShrink ? `<span title="${v}">${v.substring(0, maxLength)}...</span><div uk-dropdown><span>${v}</span></div>` : v;
|
||||||
return "string";
|
return `<td>${content}</td>`
|
||||||
},
|
}).join('');
|
||||||
get filters() {
|
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||||
if (this.allChapters.length < 1) return [];
|
});
|
||||||
const keys = Object.keys(this.allChapters[0]).filter(
|
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||||
(k) => !["manga_title", "id"].includes(k)
|
$('table').append(tbody);
|
||||||
);
|
|
||||||
return keys.map((k) => {
|
$('#selectable').selectable({
|
||||||
let values = this.allChapters.map((c) => c[k]);
|
filter: 'tr'
|
||||||
const type = this.fieldType(values);
|
});
|
||||||
|
|
||||||
if (type === "array") {
|
$('#table table').tablesorter();
|
||||||
// if the type is an array, return the list of available elements
|
$('#table').removeAttr('hidden');
|
||||||
// example: an array of groups or authors
|
};
|
||||||
values = Array.from(
|
|
||||||
new Set(
|
const selectAll = () => {
|
||||||
values.flat().map((v) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
if (typeof v === "string")
|
$(e).addClass('ui-selected');
|
||||||
return v.toLowerCase();
|
});
|
||||||
})
|
};
|
||||||
)
|
|
||||||
);
|
const unselect = () => {
|
||||||
}
|
$('tbody > tr').each((i, e) => {
|
||||||
|
$(e).removeClass('ui-selected');
|
||||||
return {
|
});
|
||||||
key: k,
|
};
|
||||||
type: type,
|
|
||||||
values: values,
|
const download = () => {
|
||||||
};
|
const selected = $('tbody > tr.ui-selected');
|
||||||
});
|
if (selected.length === 0) return;
|
||||||
},
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
get filterSettings() {
|
$('#download-btn').attr('hidden', '');
|
||||||
return $("#filter-form input:visible, #filter-form select:visible")
|
$('#download-spinner').removeAttr('hidden');
|
||||||
.get()
|
const chapters = selected.map((i, e) => {
|
||||||
.map((i) => {
|
return {
|
||||||
const type = i.getAttribute("data-filter-type");
|
id: $(e).attr('data-id'),
|
||||||
let value = i.value.trim();
|
title: $(e).attr('data-title')
|
||||||
if (type.startsWith("date"))
|
}
|
||||||
value = value ? Date.parse(value).toString() : "";
|
}).get();
|
||||||
return {
|
console.log(chapters);
|
||||||
key: i.getAttribute("data-filter-key"),
|
$.ajax({
|
||||||
value: value,
|
type: 'POST',
|
||||||
type: type,
|
url: base_url + 'api/admin/plugin/download',
|
||||||
};
|
data: JSON.stringify({
|
||||||
});
|
plugin: pid,
|
||||||
},
|
chapters: chapters,
|
||||||
applyFilters() {
|
title: mangaTitle
|
||||||
this.appliedFilters = this.filterSettings;
|
}),
|
||||||
this.chapters = this.filteredChapters;
|
contentType: "application/json",
|
||||||
this.sortOptions = [];
|
dataType: 'json'
|
||||||
},
|
})
|
||||||
clearFilters() {
|
.done(data => {
|
||||||
$("#filter-form input")
|
console.log(data);
|
||||||
.get()
|
if (data.error) {
|
||||||
.forEach((i) => (i.value = ""));
|
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||||
$("#filter-form select").val("all");
|
return;
|
||||||
this.appliedFilters = [];
|
}
|
||||||
this.chapters = this.filteredChapters;
|
const successCount = parseInt(data.success);
|
||||||
this.sortOptions = [];
|
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>.`);
|
||||||
mangaSelected(event) {
|
})
|
||||||
const mid = event.currentTarget.getAttribute("data-id");
|
.fail((jqXHR, status) => {
|
||||||
this.mid = mid;
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
this.searchChapters(mid);
|
})
|
||||||
},
|
.always(() => {
|
||||||
subscribe(modal) {
|
$('#download-spinner').attr('hidden', '');
|
||||||
this.subscribing = true;
|
$('#download-btn').removeAttr('hidden');
|
||||||
fetch(`${base_url}api/admin/plugin/subscriptions`, {
|
});
|
||||||
method: "POST",
|
});
|
||||||
body: JSON.stringify({
|
|
||||||
filters: this.filterSettings,
|
|
||||||
plugin: this.pid,
|
|
||||||
name: this.subscriptionName.trim(),
|
|
||||||
manga: this.mangaTitle,
|
|
||||||
manga_id: this.mid,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.success) throw new Error(data.error);
|
|
||||||
alert("success", "Subscription created");
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
alert("danger", `Failed to subscribe. Error: ${e}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.subscribing = false;
|
|
||||||
UIkit.modal(modal).hide();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
numIsDate(num) {
|
|
||||||
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
|
|
||||||
},
|
|
||||||
renderCell(value) {
|
|
||||||
if (this.numIsDate(value))
|
|
||||||
return `<span>${moment(Number(value)).format(
|
|
||||||
"MMM D, YYYY"
|
|
||||||
)}</span>`;
|
|
||||||
const maxLength = 40;
|
|
||||||
if (value && value.length > maxLength)
|
|
||||||
return `<span>${value.substr(
|
|
||||||
0,
|
|
||||||
maxLength
|
|
||||||
)}...</span><div uk-dropdown>${value}</div>`;
|
|
||||||
return `<span>${value}</span>`;
|
|
||||||
},
|
|
||||||
renderFilterRow(ft) {
|
|
||||||
const key = ft.key;
|
|
||||||
let type = ft.type;
|
|
||||||
switch (type) {
|
|
||||||
case "number-min":
|
|
||||||
type = "number (minimum value)";
|
|
||||||
break;
|
|
||||||
case "number-max":
|
|
||||||
type = "number (maximum value)";
|
|
||||||
break;
|
|
||||||
case "date-min":
|
|
||||||
type = "minimum date";
|
|
||||||
break;
|
|
||||||
case "date-max":
|
|
||||||
type = "maximum date";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let value = ft.value;
|
|
||||||
|
|
||||||
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
|
||||||
else if (ft.type.startsWith("date") && value)
|
|
||||||
value = moment(Number(value)).format("MMM D, YYYY");
|
|
||||||
|
|
||||||
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@ -14,7 +14,6 @@ const readerComponent = () => {
|
|||||||
margin: 30,
|
margin: 30,
|
||||||
preloadLookahead: 3,
|
preloadLookahead: 3,
|
||||||
enableRightToLeft: false,
|
enableRightToLeft: false,
|
||||||
fitType: 'vert',
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@ -30,16 +29,14 @@ const readerComponent = () => {
|
|||||||
return {
|
return {
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
width: d.width == 0 ? "100%" : d.width,
|
width: d.width,
|
||||||
height: d.height == 0 ? "100%" : d.height,
|
height: d.height,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
|
const avgRatio = this.items.reduce((acc, cur) => {
|
||||||
// TODO: support more image types in image_size.cr
|
|
||||||
const avgRatio = dimensions.reduce((acc, cur) => {
|
|
||||||
return acc + cur.height / cur.width
|
return acc + cur.height / cur.width
|
||||||
}, 0) / dimensions.length;
|
}, 0) / this.items.length;
|
||||||
|
|
||||||
console.log(avgRatio);
|
console.log(avgRatio);
|
||||||
this.longPages = avgRatio > 2;
|
this.longPages = avgRatio > 2;
|
||||||
@ -61,16 +58,11 @@ const readerComponent = () => {
|
|||||||
|
|
||||||
// Preload Images
|
// Preload Images
|
||||||
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||||
const limit = Math.min(page + this.preloadLookahead, this.items.length);
|
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||||
for (let idx = page + 1; idx <= limit; idx++) {
|
for (let idx = page + 1; idx <= limit; idx++) {
|
||||||
this.preloadImage(this.items[idx - 1].url);
|
this.preloadImage(this.items[idx - 1].url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedFitType = localStorage.getItem('fitType');
|
|
||||||
if (savedFitType) {
|
|
||||||
this.fitType = savedFitType;
|
|
||||||
$('#fit-select').val(savedFitType);
|
|
||||||
}
|
|
||||||
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||||
|
|
||||||
@ -143,11 +135,7 @@ const readerComponent = () => {
|
|||||||
const idx = parseInt(this.curItem.id);
|
const idx = parseInt(this.curItem.id);
|
||||||
const newIdx = idx + (isNext ? 1 : -1);
|
const newIdx = idx + (isNext ? 1 : -1);
|
||||||
|
|
||||||
if (newIdx <= 0) return;
|
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||||
if (newIdx > this.items.length) {
|
|
||||||
this.showControl(idx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||||
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||||
@ -265,20 +253,12 @@ const readerComponent = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Handles clicked image
|
* Shows the control modal
|
||||||
*
|
*
|
||||||
* @param {Event} event - The triggering event
|
* @param {Event} event - The triggering event
|
||||||
*/
|
*/
|
||||||
clickImage(event) {
|
showControl(event) {
|
||||||
const idx = event.currentTarget.id;
|
const idx = event.currentTarget.id;
|
||||||
this.showControl(idx);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Shows the control modal
|
|
||||||
*
|
|
||||||
* @param {number} idx - selected page index
|
|
||||||
*/
|
|
||||||
showControl(idx) {
|
|
||||||
this.selectedIndex = idx;
|
this.selectedIndex = idx;
|
||||||
UIkit.modal($('#modal-sections')).show();
|
UIkit.modal($('#modal-sections')).show();
|
||||||
},
|
},
|
||||||
@ -341,11 +321,6 @@ const readerComponent = () => {
|
|||||||
this.toPage(this.selectedIndex);
|
this.toPage(this.selectedIndex);
|
||||||
},
|
},
|
||||||
|
|
||||||
fitChanged(){
|
|
||||||
this.fitType = $('#fit-select').val();
|
|
||||||
localStorage.setItem('fitType', this.fitType);
|
|
||||||
},
|
|
||||||
|
|
||||||
preloadLookaheadChanged() {
|
preloadLookaheadChanged() {
|
||||||
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||||
},
|
},
|
||||||
|
@ -1,147 +0,0 @@
|
|||||||
const component = () => {
|
|
||||||
return {
|
|
||||||
subscriptions: [],
|
|
||||||
plugins: [],
|
|
||||||
pid: undefined,
|
|
||||||
subscription: undefined, // selected subscription
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
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))
|
|
||||||
this.pid = pid;
|
|
||||||
else if (this.plugins.length > 0)
|
|
||||||
this.pid = this.plugins[0].id;
|
|
||||||
|
|
||||||
this.list(pid);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
alert(
|
|
||||||
"danger",
|
|
||||||
`Failed to list the available plugins. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
pluginChanged() {
|
|
||||||
localStorage.setItem("plugin", this.pid);
|
|
||||||
this.list(this.pid);
|
|
||||||
},
|
|
||||||
list(pid) {
|
|
||||||
if (!pid) return;
|
|
||||||
fetch(
|
|
||||||
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
|
|
||||||
{
|
|
||||||
plugin: pid,
|
|
||||||
}
|
|
||||||
)}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.success) throw new Error(data.error);
|
|
||||||
this.subscriptions = data.subscriptions;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
alert(
|
|
||||||
"danger",
|
|
||||||
`Failed to list subscriptions. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
renderStrCell(str) {
|
|
||||||
const maxLength = 40;
|
|
||||||
if (str.length > maxLength)
|
|
||||||
return `<td><span>${str.substring(
|
|
||||||
0,
|
|
||||||
maxLength
|
|
||||||
)}...</span><div uk-dropdown>${str}</div></td>`;
|
|
||||||
return `<td>${str}</td>`;
|
|
||||||
},
|
|
||||||
renderDateCell(timestamp) {
|
|
||||||
return `<td>${moment
|
|
||||||
.duration(moment.unix(timestamp).diff(moment()))
|
|
||||||
.humanize(true)}</td>`;
|
|
||||||
},
|
|
||||||
selected(event, modal) {
|
|
||||||
const id = event.currentTarget.getAttribute("sid");
|
|
||||||
this.subscription = this.subscriptions.find((s) => s.id === id);
|
|
||||||
UIkit.modal(modal).show();
|
|
||||||
},
|
|
||||||
renderFilterRow(ft) {
|
|
||||||
const key = ft.key;
|
|
||||||
let type = ft.type;
|
|
||||||
switch (type) {
|
|
||||||
case "number-min":
|
|
||||||
type = "number (minimum value)";
|
|
||||||
break;
|
|
||||||
case "number-max":
|
|
||||||
type = "number (maximum value)";
|
|
||||||
break;
|
|
||||||
case "date-min":
|
|
||||||
type = "minimum date";
|
|
||||||
break;
|
|
||||||
case "date-max":
|
|
||||||
type = "maximum date";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let value = ft.value;
|
|
||||||
|
|
||||||
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
|
||||||
else if (ft.type.startsWith("date") && value)
|
|
||||||
value = moment(Number(value)).format("MMM D, YYYY");
|
|
||||||
|
|
||||||
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
|
||||||
},
|
|
||||||
actionHandler(event, type) {
|
|
||||||
const id = $(event.currentTarget).closest("tr").attr("sid");
|
|
||||||
if (type !== 'delete') return this.action(id, type);
|
|
||||||
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
|
|
||||||
labels: {
|
|
||||||
ok: 'Yes, delete it',
|
|
||||||
cancel: 'Cancel'
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
this.action(id, type);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
action(id, type) {
|
|
||||||
if (this.loading) return;
|
|
||||||
this.loading = true;
|
|
||||||
fetch(
|
|
||||||
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
|
|
||||||
{
|
|
||||||
plugin: this.pid,
|
|
||||||
subscription: id,
|
|
||||||
}
|
|
||||||
)}`,
|
|
||||||
{
|
|
||||||
method: type === 'delete' ? "DELETE" : 'POST'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.success) throw new Error(data.error);
|
|
||||||
if (type === 'update')
|
|
||||||
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
alert(
|
|
||||||
"danger",
|
|
||||||
`Failed to ${type} subscription. Error: ${e}`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.loading = false;
|
|
||||||
this.list(this.pid);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Mango",
|
|
||||||
"description": "Mango: A self-hosted manga server and web reader",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/img/icons/icon_x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/img/icons/icon_x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/img/icons/icon_x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"display": "fullscreen",
|
|
||||||
"start_url": "/"
|
|
||||||
}
|
|
@ -50,7 +50,7 @@ shards:
|
|||||||
|
|
||||||
koa:
|
koa:
|
||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.9.0
|
version: 0.8.0
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
@ -68,10 +68,6 @@ shards:
|
|||||||
git: https://github.com/luislavena/radix.git
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
|
|
||||||
sanitize:
|
|
||||||
git: https://github.com/hkalexling/sanitize.git
|
|
||||||
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.27.0
|
version: 0.25.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@ -42,5 +42,3 @@ dependencies:
|
|||||||
branch: master
|
branch: master
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
sanitize:
|
|
||||||
github: hkalexling/sanitize
|
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "test",
|
|
||||||
"title": "Test Plugin",
|
|
||||||
"placeholder": "placeholder",
|
|
||||||
"wait_seconds": 1
|
|
||||||
}
|
|
@ -1,31 +1,14 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Config do
|
describe Config do
|
||||||
it "creates default config if it does not exist" do
|
it "creates config if it does not exist" do
|
||||||
with_default_config do |config, path|
|
with_default_config do |_, path|
|
||||||
File.exists?(path).should be_true
|
File.exists?(path).should be_true
|
||||||
config.port.should eq 9000
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "correctly loads config" do
|
it "correctly loads config" do
|
||||||
config = Config.load "spec/asset/test-config.yml"
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
config.port.should eq 3000
|
config.port.should eq 3000
|
||||||
config.base_url.should eq "/"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "correctly reads config defaults from ENV" do
|
|
||||||
ENV["LOG_LEVEL"] = "debug"
|
|
||||||
config = Config.load "spec/asset/test-config.yml"
|
|
||||||
config.log_level.should eq "debug"
|
|
||||||
config.base_url.should eq "/"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "correctly handles ENV truthiness" do
|
|
||||||
ENV["CACHE_ENABLED"] = "false"
|
|
||||||
config = Config.load "spec/asset/test-config.yml"
|
|
||||||
config.cache_enabled.should be_false
|
|
||||||
config.cache_log_enabled.should be_true
|
|
||||||
config.disable_login.should be_false
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
require "./spec_helper"
|
|
||||||
|
|
||||||
describe Plugin do
|
|
||||||
describe "helper functions" do
|
|
||||||
it "mango.text" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.text('<a href="https://github.com">Click Me<a>');
|
|
||||||
JS
|
|
||||||
res.should eq "Click Me"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "mango.text returns empty string when no text" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.text('<img src="https://github.com" />');
|
|
||||||
JS
|
|
||||||
res.should eq ""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "mango.css" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
|
|
||||||
|
|
||||||
JS
|
|
||||||
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "mango.css returns empty array when no match" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
|
|
||||||
JS
|
|
||||||
res.should eq [] of String
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "mango.attribute" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
|
|
||||||
JS
|
|
||||||
res.should eq "https://github.com"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it "mango.attribute returns undefined when no match" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.attribute('<div />', 'href') === undefined;
|
|
||||||
JS
|
|
||||||
res.should be_true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# https://github.com/hkalexling/Mango/issues/320
|
|
||||||
it "mango.attribute handles tags in attribute values" do
|
|
||||||
with_plugin do |plugin|
|
|
||||||
res = plugin.eval <<-JS
|
|
||||||
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
|
|
||||||
JS
|
|
||||||
res.should eq "test"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -3,7 +3,6 @@ require "../src/queue"
|
|||||||
require "../src/server"
|
require "../src/server"
|
||||||
require "../src/config"
|
require "../src/config"
|
||||||
require "../src/main_fiber"
|
require "../src/main_fiber"
|
||||||
require "../src/plugin/plugin"
|
|
||||||
|
|
||||||
class State
|
class State
|
||||||
@@hash = {} of String => String
|
@@hash = {} of String => String
|
||||||
@ -55,10 +54,3 @@ def with_storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_plugin
|
|
||||||
with_default_config do
|
|
||||||
plugin = Plugin.new "test", "spec/asset/plugins"
|
|
||||||
yield plugin
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
@ -1,51 +1,30 @@
|
|||||||
require "yaml"
|
require "yaml"
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
private OPTIONS = {
|
|
||||||
"host" => "0.0.0.0",
|
|
||||||
"port" => 9000,
|
|
||||||
"base_url" => "/",
|
|
||||||
"session_secret" => "mango-session-secret",
|
|
||||||
"library_path" => "~/mango/library",
|
|
||||||
"library_cache_path" => "~/mango/library.yml.gz",
|
|
||||||
"db_path" => "~/mango.db",
|
|
||||||
"queue_db_path" => "~/mango/queue.db",
|
|
||||||
"scan_interval_minutes" => 5,
|
|
||||||
"thumbnail_generation_interval_hours" => 24,
|
|
||||||
"log_level" => "info",
|
|
||||||
"upload_path" => "~/mango/uploads",
|
|
||||||
"plugin_path" => "~/mango/plugins",
|
|
||||||
"download_timeout_seconds" => 30,
|
|
||||||
"cache_enabled" => true,
|
|
||||||
"cache_size_mbs" => 50,
|
|
||||||
"cache_log_enabled" => true,
|
|
||||||
"disable_login" => false,
|
|
||||||
"default_username" => "",
|
|
||||||
"auth_proxy_header_name" => "",
|
|
||||||
"plugin_update_interval_hours" => 24,
|
|
||||||
}
|
|
||||||
|
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
property path : String = ""
|
property path = ""
|
||||||
|
property host = "0.0.0.0"
|
||||||
# Go through the options constant above and define them as properties.
|
property port : Int32 = 9000
|
||||||
# Allow setting the default values through environment variables.
|
property base_url = "/"
|
||||||
# Overall precedence: config file > environment variable > default value
|
property session_secret = "mango-session-secret"
|
||||||
{% begin %}
|
property library_path = "~/mango/library"
|
||||||
{% for k, v in OPTIONS %}
|
property library_cache_path = "~/mango/library.yml.gz"
|
||||||
{% if v.is_a? StringLiteral %}
|
property db_path = "~/mango/mango.db"
|
||||||
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
|
property queue_db_path = "~/mango/queue.db"
|
||||||
{% elsif v.is_a? NumberLiteral %}
|
property scan_interval_minutes : Int32 = 5
|
||||||
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
|
property thumbnail_generation_interval_hours : Int32 = 24
|
||||||
{% elsif v.is_a? BoolLiteral %}
|
property log_level = "info"
|
||||||
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
|
property upload_path = "~/mango/uploads"
|
||||||
{% else %}
|
property plugin_path = "~/mango/plugins"
|
||||||
raise "Unknown type in config option: {{ v.class_name.id }}"
|
property download_timeout_seconds : Int32 = 30
|
||||||
{% end %}
|
property cache_enabled = true
|
||||||
{% end %}
|
property cache_size_mbs = 50
|
||||||
{% end %}
|
property cache_log_enabled = true
|
||||||
|
property disable_login = false
|
||||||
|
property default_username = ""
|
||||||
|
property auth_proxy_header_name = ""
|
||||||
|
|
||||||
@@singlet : Config?
|
@@singlet : Config?
|
||||||
|
|
||||||
@ -58,7 +37,7 @@ class Config
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.load(path : String?)
|
def self.load(path : String?)
|
||||||
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
|
path = "~/.config/mango/config.yml" if path.nil?
|
||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
|
@ -6,7 +6,6 @@ class AuthHandler < Kemal::Handler
|
|||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|
||||||
BASIC = "Basic"
|
BASIC = "Basic"
|
||||||
BEARER = "Bearer"
|
|
||||||
AUTH = "Authorization"
|
AUTH = "Authorization"
|
||||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||||
"You have to login with proper credentials"
|
"You have to login with proper credentials"
|
||||||
@ -19,14 +18,8 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
def require_auth(env)
|
def require_auth(env)
|
||||||
if request_path_startswith env, ["/api"]
|
env.session.string "callback", env.request.path
|
||||||
# Do not redirect API requests
|
redirect env, "/login"
|
||||||
env.response.status_code = 401
|
|
||||||
send_text env, "Unauthorized"
|
|
||||||
else
|
|
||||||
env.session.string "callback", env.request.path
|
|
||||||
redirect env, "/login"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_token(env)
|
def validate_token(env)
|
||||||
@ -42,18 +35,13 @@ class AuthHandler < Kemal::Handler
|
|||||||
def validate_auth_header(env)
|
def validate_auth_header(env)
|
||||||
if env.request.headers[AUTH]?
|
if env.request.headers[AUTH]?
|
||||||
if value = env.request.headers[AUTH]
|
if value = env.request.headers[AUTH]
|
||||||
if value.starts_with? BASIC
|
if value.size > 0 && value.starts_with?(BASIC)
|
||||||
token = verify_user value
|
token = verify_user value
|
||||||
return false if token.nil?
|
return false if token.nil?
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if value.starts_with? BEARER
|
|
||||||
session_id = value.split(" ")[1]
|
|
||||||
token = Kemal::Session.get(session_id).try &.string? "token"
|
|
||||||
return !token.nil? && Storage.default.verify_token token
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
false
|
false
|
||||||
@ -66,10 +54,6 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
# OPTIONS requests do not require authentication
|
|
||||||
if env.request.method === "OPTIONS"
|
|
||||||
return call_next(env)
|
|
||||||
end
|
|
||||||
# Skip all authentication if requesting /login, /logout, /api/login,
|
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||||
# or a static file
|
# or a static file
|
||||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||||
@ -78,8 +62,8 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Check user is logged in
|
# Check user is logged in
|
||||||
if validate_token(env) || validate_auth_header(env)
|
if validate_token env
|
||||||
# Skip if the request has a valid token (either from cookies or header)
|
# Skip if the request has a valid token
|
||||||
elsif Config.current.disable_login
|
elsif Config.current.disable_login
|
||||||
# Check default username if login is disabled
|
# Check default username if login is disabled
|
||||||
unless Storage.default.username_exists Config.current.default_username
|
unless Storage.default.username_exists Config.current.default_username
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
class CORSHandler < Kemal::Handler
|
|
||||||
def call(env)
|
|
||||||
if request_path_startswith env, ["/api"]
|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
end
|
|
||||||
call_next env
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,111 +0,0 @@
|
|||||||
require "yaml"
|
|
||||||
|
|
||||||
require "./entry"
|
|
||||||
|
|
||||||
class ArchiveEntry < Entry
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
getter zip_path : String
|
|
||||||
|
|
||||||
def initialize(@zip_path, @book)
|
|
||||||
storage = Storage.default
|
|
||||||
@path = @zip_path
|
|
||||||
@encoded_path = URI.encode @zip_path
|
|
||||||
@title = File.basename @zip_path, File.extname @zip_path
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
@size = (File.size @zip_path).humanize_bytes
|
|
||||||
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_entry_id({
|
|
||||||
path: @zip_path,
|
|
||||||
id: id,
|
|
||||||
signature: File.signature(@zip_path).to_s,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@mtime = File.info(@zip_path).modification_time
|
|
||||||
|
|
||||||
unless File.readable? @zip_path
|
|
||||||
@err_msg = "File #{@zip_path} is not readable."
|
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
|
||||||
"file permission is configured correctly."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
archive_exception = validate_archive @zip_path
|
|
||||||
unless archive_exception.nil?
|
|
||||||
@err_msg = "Archive error: #{archive_exception}"
|
|
||||||
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
|
||||||
"Ignoring it. #{@err_msg}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
file = ArchiveFile.new @zip_path
|
|
||||||
@pages = file.entries.count do |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
end
|
|
||||||
file.close
|
|
||||||
end
|
|
||||||
|
|
||||||
private def sorted_archive_entries
|
|
||||||
ArchiveFile.open @zip_path do |file|
|
|
||||||
entries = file.entries
|
|
||||||
.select { |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
}
|
|
||||||
.sort! { |a, b|
|
|
||||||
compare_numerically a.filename, b.filename
|
|
||||||
}
|
|
||||||
yield file, entries
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_page(page_num)
|
|
||||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
|
||||||
img = nil
|
|
||||||
begin
|
|
||||||
sorted_archive_entries do |file, entries|
|
|
||||||
page = entries[page_num - 1]
|
|
||||||
data = file.read_entry page
|
|
||||||
if data
|
|
||||||
img = Image.new data, MIME.from_filename(page.filename),
|
|
||||||
page.filename, data.size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
|
|
||||||
end
|
|
||||||
img
|
|
||||||
end
|
|
||||||
|
|
||||||
def page_dimensions
|
|
||||||
sizes = [] of Hash(String, Int32)
|
|
||||||
sorted_archive_entries do |file, entries|
|
|
||||||
entries.each_with_index do |e, i|
|
|
||||||
begin
|
|
||||||
data = file.read_entry(e).not_nil!
|
|
||||||
size = ImageSize.get data
|
|
||||||
sizes << {
|
|
||||||
"width" => size.width,
|
|
||||||
"height" => size.height,
|
|
||||||
}
|
|
||||||
rescue e
|
|
||||||
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
|
||||||
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
sizes
|
|
||||||
end
|
|
||||||
|
|
||||||
def examine : Bool
|
|
||||||
File.exists? @zip_path
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.is_valid?(path : String) : Bool
|
|
||||||
is_supported_file path
|
|
||||||
end
|
|
||||||
end
|
|
@ -76,8 +76,8 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
|||||||
entries : Array(Entry), opt : SortOptions?)
|
entries : Array(Entry), opt : SortOptions?)
|
||||||
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
|
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
|
||||||
(opt ? opt.to_tuple.to_s : "nil"))
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
"#{sig}:sorted_entries"
|
"#{sig}:sorted_entries"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
|||||||
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||||
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||||
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
|
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
|
||||||
(opt ? opt.to_tuple.to_s : "nil"))
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
"#{sig}:sorted_titles"
|
"#{sig}:sorted_titles"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,132 +0,0 @@
|
|||||||
require "yaml"
|
|
||||||
|
|
||||||
require "./entry"
|
|
||||||
|
|
||||||
class DirEntry < Entry
|
|
||||||
include YAML::Serializable
|
|
||||||
|
|
||||||
getter dir_path : String
|
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@sorted_files : Array(String)?
|
|
||||||
|
|
||||||
@signature : String
|
|
||||||
|
|
||||||
def initialize(@dir_path, @book)
|
|
||||||
storage = Storage.default
|
|
||||||
@path = @dir_path
|
|
||||||
@encoded_path = URI.encode @dir_path
|
|
||||||
@title = File.basename @dir_path
|
|
||||||
@encoded_title = URI.encode @title
|
|
||||||
|
|
||||||
unless File.readable? @dir_path
|
|
||||||
@err_msg = "Directory #{@dir_path} is not readable."
|
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
|
||||||
"file permission is configured correctly."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
unless DirEntry.is_valid? @dir_path
|
|
||||||
@err_msg = "Directory #{@dir_path} is not valid directory entry."
|
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
|
||||||
"directory has valid images."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
size_sum = 0
|
|
||||||
sorted_files.each do |file_path|
|
|
||||||
size_sum += File.size file_path
|
|
||||||
end
|
|
||||||
@size = size_sum.humanize_bytes
|
|
||||||
|
|
||||||
@signature = Dir.directory_entry_signature @dir_path
|
|
||||||
id = storage.get_entry_id @dir_path, @signature
|
|
||||||
if id.nil?
|
|
||||||
id = random_str
|
|
||||||
storage.insert_entry_id({
|
|
||||||
path: @dir_path,
|
|
||||||
id: id,
|
|
||||||
signature: @signature,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
|
|
||||||
@mtime = sorted_files.map do |file_path|
|
|
||||||
File.info(file_path).modification_time
|
|
||||||
end.max
|
|
||||||
@pages = sorted_files.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_page(page_num)
|
|
||||||
img = nil
|
|
||||||
begin
|
|
||||||
files = sorted_files
|
|
||||||
file_path = files[page_num - 1]
|
|
||||||
data = File.read(file_path).to_slice
|
|
||||||
if data
|
|
||||||
img = Image.new data, MIME.from_filename(file_path),
|
|
||||||
File.basename(file_path), data.size
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
|
|
||||||
end
|
|
||||||
img
|
|
||||||
end
|
|
||||||
|
|
||||||
def page_dimensions
|
|
||||||
sizes = [] of Hash(String, Int32)
|
|
||||||
sorted_files.each_with_index do |path, i|
|
|
||||||
data = File.read(path).to_slice
|
|
||||||
begin
|
|
||||||
data.not_nil!
|
|
||||||
size = ImageSize.get data
|
|
||||||
sizes << {
|
|
||||||
"width" => size.width,
|
|
||||||
"height" => size.height,
|
|
||||||
}
|
|
||||||
rescue e
|
|
||||||
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
|
|
||||||
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
sizes
|
|
||||||
end
|
|
||||||
|
|
||||||
def examine : Bool
|
|
||||||
existence = File.exists? @dir_path
|
|
||||||
return false unless existence
|
|
||||||
files = DirEntry.image_files @dir_path
|
|
||||||
signature = Dir.directory_entry_signature @dir_path
|
|
||||||
existence = files.size > 0 && @signature == signature
|
|
||||||
@sorted_files = nil unless existence
|
|
||||||
|
|
||||||
# For more efficient, update a directory entry with new property
|
|
||||||
# and return true like Title.examine
|
|
||||||
existence
|
|
||||||
end
|
|
||||||
|
|
||||||
def sorted_files
|
|
||||||
cached_sorted_files = @sorted_files
|
|
||||||
return cached_sorted_files if cached_sorted_files
|
|
||||||
@sorted_files = DirEntry.sorted_image_files @dir_path
|
|
||||||
@sorted_files.not_nil!
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.image_files(dir_path)
|
|
||||||
Dir.entries(dir_path)
|
|
||||||
.reject(&.starts_with? ".")
|
|
||||||
.map { |fn| File.join dir_path, fn }
|
|
||||||
.select { |fn| is_supported_image_file fn }
|
|
||||||
.reject { |fn| File.directory? fn }
|
|
||||||
.select { |fn| File.readable? fn }
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.sorted_image_files(dir_path)
|
|
||||||
self.image_files(dir_path)
|
|
||||||
.sort { |a, b| compare_numerically a, b }
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.is_valid?(path : String) : Bool
|
|
||||||
image_files(path).size > 0
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,57 +1,64 @@
|
|||||||
require "image_size"
|
require "image_size"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
|
class Entry
|
||||||
node.nodes
|
include YAML::Serializable
|
||||||
.map_with_index { |n, i| {n, i} }
|
|
||||||
.select(&.[1].even?)
|
|
||||||
.map(&.[0])
|
|
||||||
.select(YAML::Nodes::Scalar)
|
|
||||||
.map(&.as(YAML::Nodes::Scalar).value)
|
|
||||||
.includes? key
|
|
||||||
end
|
|
||||||
|
|
||||||
abstract class Entry
|
getter zip_path : String, book : Title, title : String,
|
||||||
getter id : String, book : Title, title : String, path : String,
|
size : String, pages : Int32, id : String, encoded_path : String,
|
||||||
size : String, pages : Int32, mtime : Time,
|
encoded_title : String, mtime : Time, err_msg : String?
|
||||||
encoded_path : String, encoded_title : String, err_msg : String?
|
|
||||||
|
|
||||||
def initialize(
|
@[YAML::Field(ignore: true)]
|
||||||
@id, @title, @book, @path,
|
@sort_title : String?
|
||||||
@size, @pages, @mtime,
|
|
||||||
@encoded_path, @encoded_title, @err_msg
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
|
def initialize(@zip_path, @book)
|
||||||
unless node.is_a? YAML::Nodes::Mapping
|
storage = Storage.default
|
||||||
raise "Unexpected node type in YAML"
|
@encoded_path = URI.encode @zip_path
|
||||||
|
@title = File.basename @zip_path, File.extname @zip_path
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@size = (File.size @zip_path).humanize_bytes
|
||||||
|
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_entry_id({
|
||||||
|
path: @zip_path,
|
||||||
|
id: id,
|
||||||
|
signature: File.signature(@zip_path).to_s,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
|
@id = id
|
||||||
# instead we are using a more hacky approach (see `node_has_key`).
|
@mtime = File.info(@zip_path).modification_time
|
||||||
# TODO: Use a more elegant approach
|
|
||||||
if node_has_key node, "zip_path"
|
unless File.readable? @zip_path
|
||||||
ArchiveEntry.new ctx, node
|
@err_msg = "File #{@zip_path} is not readable."
|
||||||
elsif node_has_key node, "dir_path"
|
Logger.warn "#{@err_msg} Please make sure the " \
|
||||||
DirEntry.new ctx, node
|
"file permission is configured correctly."
|
||||||
else
|
return
|
||||||
raise "Unknown entry found in YAML cache. Try deleting the " \
|
|
||||||
"`library.yml.gz` file"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
archive_exception = validate_archive @zip_path
|
||||||
|
unless archive_exception.nil?
|
||||||
|
@err_msg = "Archive error: #{archive_exception}"
|
||||||
|
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
||||||
|
"Ignoring it. #{@err_msg}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
file = ArchiveFile.new @zip_path
|
||||||
|
@pages = file.entries.count do |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
end
|
||||||
|
file.close
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_json(*, slim = false)
|
def build_json(*, slim = false)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in %w(path title size id) %}
|
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||||
json.field {{str}}, {{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
if err_msg
|
|
||||||
json.field "err_msg", err_msg
|
|
||||||
end
|
|
||||||
json.field "zip_path", path # for API backward compatability
|
|
||||||
json.field "path", path
|
|
||||||
json.field "title_id", @book.id
|
json.field "title_id", @book.id
|
||||||
json.field "title_title", @book.title
|
|
||||||
json.field "sort_title", sort_title
|
json.field "sort_title", sort_title
|
||||||
json.field "pages" { json.number @pages }
|
json.field "pages" { json.number @pages }
|
||||||
unless slim
|
unless slim
|
||||||
@ -63,9 +70,6 @@ abstract class Entry
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@sort_title : String?
|
|
||||||
|
|
||||||
def sort_title
|
def sort_title
|
||||||
sort_title_cached = @sort_title
|
sort_title_cached = @sort_title
|
||||||
return sort_title_cached if sort_title_cached
|
return sort_title_cached if sort_title_cached
|
||||||
@ -104,7 +108,7 @@ abstract class Entry
|
|||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg
|
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||||
|
|
||||||
unless @book.entry_cover_url_cache
|
unless @book.entry_cover_url_cache
|
||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
@ -123,6 +127,54 @@ abstract class Entry
|
|||||||
url
|
url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def sorted_archive_entries
|
||||||
|
ArchiveFile.open @zip_path do |file|
|
||||||
|
entries = file.entries
|
||||||
|
.select { |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
}
|
||||||
|
.sort! { |a, b|
|
||||||
|
compare_numerically a.filename, b.filename
|
||||||
|
}
|
||||||
|
yield file, entries
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_page(page_num)
|
||||||
|
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||||
|
img = nil
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
page = entries[page_num - 1]
|
||||||
|
data = file.read_entry page
|
||||||
|
if data
|
||||||
|
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||||
|
data.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_dimensions
|
||||||
|
sizes = [] of Hash(String, Int32)
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
entries.each_with_index do |e, i|
|
||||||
|
begin
|
||||||
|
data = file.read_entry(e).not_nil!
|
||||||
|
size = ImageSize.get data
|
||||||
|
sizes << {
|
||||||
|
"width" => size.width,
|
||||||
|
"height" => size.height,
|
||||||
|
}
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
||||||
|
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sizes
|
||||||
|
end
|
||||||
|
|
||||||
def next_entry(username)
|
def next_entry(username)
|
||||||
entries = @book.sorted_entries username
|
entries = @book.sorted_entries username
|
||||||
idx = entries.index self
|
idx = entries.index self
|
||||||
@ -137,6 +189,20 @@ abstract class Entry
|
|||||||
entries[idx - 1]
|
entries[idx - 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def date_added
|
||||||
|
date_added = nil
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_da = info.date_added[@title]?
|
||||||
|
if info_da.nil?
|
||||||
|
date_added = info.date_added[@title] = ctime @zip_path
|
||||||
|
info.save
|
||||||
|
else
|
||||||
|
date_added = info_da
|
||||||
|
end
|
||||||
|
end
|
||||||
|
date_added.not_nil! # is it ok to set not_nil! here?
|
||||||
|
end
|
||||||
|
|
||||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
# instead of IDs in info.json
|
# instead of IDs in info.json
|
||||||
def save_progress(username, page)
|
def save_progress(username, page)
|
||||||
@ -216,7 +282,7 @@ abstract class Entry
|
|||||||
end
|
end
|
||||||
Storage.default.save_thumbnail @id, img
|
Storage.default.save_thumbnail @id, img
|
||||||
rescue e
|
rescue e
|
||||||
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
|
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
|
||||||
end
|
end
|
||||||
|
|
||||||
img
|
img
|
||||||
@ -225,34 +291,4 @@ abstract class Entry
|
|||||||
def get_thumbnail : Image?
|
def get_thumbnail : Image?
|
||||||
Storage.default.get_thumbnail @id
|
Storage.default.get_thumbnail @id
|
||||||
end
|
end
|
||||||
|
|
||||||
def date_added : Time
|
|
||||||
date_added = Time::UNIX_EPOCH
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
info_da = info.date_added[@title]?
|
|
||||||
if info_da.nil?
|
|
||||||
date_added = info.date_added[@title] = ctime path
|
|
||||||
info.save
|
|
||||||
else
|
|
||||||
date_added = info_da
|
|
||||||
end
|
|
||||||
end
|
|
||||||
date_added
|
|
||||||
end
|
|
||||||
|
|
||||||
# Hack to have abstract class methods
|
|
||||||
# https://github.com/crystal-lang/crystal/issues/5956
|
|
||||||
private module ClassMethods
|
|
||||||
abstract def is_valid?(path : String) : Bool
|
|
||||||
end
|
|
||||||
|
|
||||||
macro inherited
|
|
||||||
extend ClassMethods
|
|
||||||
end
|
|
||||||
|
|
||||||
abstract def read_page(page_num)
|
|
||||||
|
|
||||||
abstract def page_dimensions
|
|
||||||
|
|
||||||
abstract def examine : Bool?
|
|
||||||
end
|
end
|
||||||
|
@ -139,31 +139,14 @@ class Library
|
|||||||
titles.flat_map &.deep_entries
|
titles.flat_map &.deep_entries
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_json(*, slim = false, depth = -1, sort_context = nil,
|
def build_json(*, slim = false, depth = -1)
|
||||||
percentage = false)
|
|
||||||
_titles = if sort_context
|
|
||||||
sorted_titles sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
else
|
|
||||||
self.titles
|
|
||||||
end
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "dir", @dir
|
json.field "dir", @dir
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.array do
|
json.array do
|
||||||
_titles.each do |title|
|
self.titles.each do |title|
|
||||||
json.raw title.build_json(slim: slim, depth: depth,
|
json.raw title.build_json(slim: slim, depth: depth)
|
||||||
sort_context: sort_context, percentage: percentage)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if percentage && sort_context
|
|
||||||
json.field "title_percentages" do
|
|
||||||
json.array do
|
|
||||||
_titles.each do |title|
|
|
||||||
json.number title.load_percentage sort_context[:username]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -49,18 +49,13 @@ class Title
|
|||||||
path = File.join dir, fn
|
path = File.join dir, fn
|
||||||
if File.directory? path
|
if File.directory? path
|
||||||
title = Title.new path, @id, cache
|
title = Title.new path, @id, cache
|
||||||
unless title.entries.size == 0 && title.titles.size == 0
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
Library.default.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
end
|
|
||||||
if DirEntry.is_valid? path
|
|
||||||
entry = DirEntry.new path, self
|
|
||||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
|
||||||
end
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if is_supported_file path
|
if is_supported_file path
|
||||||
entry = ArchiveEntry.new path, self
|
entry = Entry.new path, self
|
||||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -132,12 +127,12 @@ class Title
|
|||||||
|
|
||||||
previous_entries_size = @entries.size
|
previous_entries_size = @entries.size
|
||||||
@entries.select! do |entry|
|
@entries.select! do |entry|
|
||||||
existence = entry.examine
|
existence = File.exists? entry.zip_path
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
context["deleted_entry_ids"] << entry.id unless existence
|
context["deleted_entry_ids"] << entry.id unless existence
|
||||||
existence
|
existence
|
||||||
end
|
end
|
||||||
remained_entry_paths = @entries.map &.path
|
remained_entry_zip_paths = @entries.map &.zip_path
|
||||||
|
|
||||||
is_titles_added = false
|
is_titles_added = false
|
||||||
is_entries_added = false
|
is_entries_added = false
|
||||||
@ -145,43 +140,29 @@ class Title
|
|||||||
next if fn.starts_with? "."
|
next if fn.starts_with? "."
|
||||||
path = File.join dir, fn
|
path = File.join dir, fn
|
||||||
if File.directory? path
|
if File.directory? path
|
||||||
unless remained_entry_paths.includes? path
|
|
||||||
if DirEntry.is_valid? path
|
|
||||||
entry = DirEntry.new path, self
|
|
||||||
if entry.pages > 0 || entry.err_msg
|
|
||||||
@entries << entry
|
|
||||||
is_entries_added = true
|
|
||||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
|
||||||
entry.id != deleted_entry_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
next if remained_title_dirs.includes? path
|
next if remained_title_dirs.includes? path
|
||||||
title = Title.new path, @id, context["cached_contents_signature"]
|
title = Title.new path, @id, context["cached_contents_signature"]
|
||||||
unless title.entries.size == 0 && title.titles.size == 0
|
next if title.entries.size == 0 && title.titles.size == 0
|
||||||
Library.default.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
is_titles_added = true
|
is_titles_added = true
|
||||||
|
|
||||||
# We think they are removed, but they are here!
|
# We think they are removed, but they are here!
|
||||||
# Cancel reserved jobs
|
# Cancel reserved jobs
|
||||||
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||||
context["deleted_title_ids"].select! do |deleted_title_id|
|
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||||
!(revival_title_ids.includes? deleted_title_id)
|
!(revival_title_ids.includes? deleted_title_id)
|
||||||
end
|
end
|
||||||
revival_entry_ids = title.deep_entries.map &.id
|
revival_entry_ids = title.deep_entries.map &.id
|
||||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
!(revival_entry_ids.includes? deleted_entry_id)
|
!(revival_entry_ids.includes? deleted_entry_id)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if is_supported_file path
|
if is_supported_file path
|
||||||
next if remained_entry_paths.includes? path
|
next if remained_entry_zip_paths.includes? path
|
||||||
entry = ArchiveEntry.new path, self
|
entry = Entry.new path, self
|
||||||
if entry.pages > 0 || entry.err_msg
|
if entry.pages > 0 || entry.err_msg
|
||||||
@entries << entry
|
@entries << entry
|
||||||
is_entries_added = true
|
is_entries_added = true
|
||||||
@ -221,21 +202,7 @@ class Title
|
|||||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||||
|
|
||||||
def build_json(*, slim = false, depth = -1,
|
def build_json(*, slim = false, depth = -1,
|
||||||
sort_context : SortContext? = nil,
|
sort_context : SortContext? = nil)
|
||||||
percentage = false)
|
|
||||||
_titles = if sort_context
|
|
||||||
sorted_titles sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
else
|
|
||||||
self.titles
|
|
||||||
end
|
|
||||||
_entries = if sort_context
|
|
||||||
sorted_entries sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
else
|
|
||||||
@entries
|
|
||||||
end
|
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
@ -251,39 +218,25 @@ class Title
|
|||||||
unless depth == 0
|
unless depth == 0
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.array do
|
json.array do
|
||||||
_titles.each do |title|
|
self.titles.each do |title|
|
||||||
json.raw title.build_json(slim: slim,
|
json.raw title.build_json(slim: slim,
|
||||||
depth: depth > 0 ? depth - 1 : depth,
|
depth: depth > 0 ? depth - 1 : depth)
|
||||||
sort_context: sort_context, percentage: percentage)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
json.field "entries" do
|
json.field "entries" do
|
||||||
json.array do
|
json.array do
|
||||||
|
_entries = if sort_context
|
||||||
|
sorted_entries sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
@entries
|
||||||
|
end
|
||||||
_entries.each do |entry|
|
_entries.each do |entry|
|
||||||
json.raw entry.build_json(slim: slim)
|
json.raw entry.build_json(slim: slim)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if percentage && sort_context
|
|
||||||
json.field "title_percentages" do
|
|
||||||
json.array do
|
|
||||||
_titles.each do |t|
|
|
||||||
json.number t.load_percentage sort_context[:username]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
json.field "entry_percentages" do
|
|
||||||
json.array do
|
|
||||||
load_percentage_for_all_entries(
|
|
||||||
sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
).each do |p|
|
|
||||||
json.number p.nan? ? 0 : p
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
json.field "parents" do
|
json.field "parents" do
|
||||||
json.array do
|
json.array do
|
||||||
@ -458,7 +411,7 @@ class Title
|
|||||||
cached_cover_url = @cached_cover_url
|
cached_cover_url = @cached_cover_url
|
||||||
return cached_cover_url unless cached_cover_url.nil?
|
return cached_cover_url unless cached_cover_url.nil?
|
||||||
|
|
||||||
url = "#{Config.current.base_url}img/icons/icon_x192.png"
|
url = "#{Config.current.base_url}img/icon.png"
|
||||||
readable_entries = @entries.select &.err_msg.nil?
|
readable_entries = @entries.select &.err_msg.nil?
|
||||||
if readable_entries.size > 0
|
if readable_entries.size > 0
|
||||||
url = readable_entries[0].cover_url
|
url = readable_entries[0].cover_url
|
||||||
@ -632,16 +585,6 @@ class Title
|
|||||||
|
|
||||||
if last_read_entry && last_read_entry.finished? username
|
if last_read_entry && last_read_entry.finished? username
|
||||||
last_read_entry = last_read_entry.next_entry username
|
last_read_entry = last_read_entry.next_entry username
|
||||||
if last_read_entry.nil?
|
|
||||||
# The last entry is finished. Return the first unfinished entry
|
|
||||||
# (if any)
|
|
||||||
sorted_entries(username).each do |e|
|
|
||||||
unless e.finished? username
|
|
||||||
last_read_entry = e
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
last_read_entry
|
last_read_entry
|
||||||
@ -656,7 +599,7 @@ class Title
|
|||||||
|
|
||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
next if da.has_key? e.title
|
next if da.has_key? e.title
|
||||||
da[e.title] = ctime e.path
|
da[e.title] = ctime e.zip_path
|
||||||
end
|
end
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
|
@ -1,3 +1,13 @@
|
|||||||
|
SUPPORTED_IMG_TYPES = %w(
|
||||||
|
image/jpeg
|
||||||
|
image/png
|
||||||
|
image/webp
|
||||||
|
image/apng
|
||||||
|
image/avif
|
||||||
|
image/gif
|
||||||
|
image/svg+xml
|
||||||
|
)
|
||||||
|
|
||||||
enum SortMethod
|
enum SortMethod
|
||||||
Auto
|
Auto
|
||||||
Title
|
Title
|
||||||
@ -45,13 +55,6 @@ class SortOptions
|
|||||||
def to_tuple
|
def to_tuple
|
||||||
{@method.to_s.underscore, ascend}
|
{@method.to_s.underscore, ascend}
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json
|
|
||||||
{
|
|
||||||
"method" => method.to_s.underscore,
|
|
||||||
"ascend" => ascend,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
struct Image
|
struct Image
|
||||||
|
@ -38,7 +38,6 @@ class Logger
|
|||||||
Log.setup do |c|
|
Log.setup do |c|
|
||||||
c.bind "*", @@severity, @backend
|
c.bind "*", @@severity, @backend
|
||||||
c.bind "db.*", :error, @backend
|
c.bind "db.*", :error, @backend
|
||||||
c.bind "duktape", :none, @backend
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.27.0"
|
MANGO_VERSION = "0.25.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@ -61,7 +61,6 @@ class CLI < Clim
|
|||||||
Library.load_instance
|
Library.load_instance
|
||||||
Library.default
|
Library.default
|
||||||
Plugin::Downloader.default
|
Plugin::Downloader.default
|
||||||
Plugin::Updater.default
|
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
|
@ -2,8 +2,6 @@ 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
|
||||||
@ -18,19 +16,12 @@ 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 = 0u64
|
getter wait_seconds : UInt64 = 0
|
||||||
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"
|
||||||
|
|
||||||
@ -46,16 +37,6 @@ 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 " \
|
||||||
@ -105,10 +86,9 @@ class Plugin
|
|||||||
getter js_path = ""
|
getter js_path = ""
|
||||||
getter storage_path = ""
|
getter storage_path = ""
|
||||||
|
|
||||||
def self.build_info_ary(dir : String? = nil)
|
def self.build_info_ary
|
||||||
@@info_ary.clear
|
@@info_ary.clear
|
||||||
dir ||= Config.current.plugin_path
|
dir = Config.current.plugin_path
|
||||||
|
|
||||||
Dir.mkdir_p dir unless Dir.exists? dir
|
Dir.mkdir_p dir unless Dir.exists? dir
|
||||||
|
|
||||||
Dir.each_child dir do |f|
|
Dir.each_child dir do |f|
|
||||||
@ -134,35 +114,8 @@ class Plugin
|
|||||||
@info.not_nil!
|
@info.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscribe(subscription : Subscription)
|
def initialize(id : String)
|
||||||
list = SubscriptionList.new info.dir
|
Plugin.build_info_ary
|
||||||
list << subscription
|
|
||||||
list.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_subscriptions
|
|
||||||
SubscriptionList.new(info.dir).ary
|
|
||||||
end
|
|
||||||
|
|
||||||
def list_subscriptions_raw
|
|
||||||
SubscriptionList.new(info.dir)
|
|
||||||
end
|
|
||||||
|
|
||||||
def unsubscribe(id : String)
|
|
||||||
list = SubscriptionList.new info.dir
|
|
||||||
list.reject! &.id.== id
|
|
||||||
list.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_subscription(id : String)
|
|
||||||
list = list_subscriptions_raw
|
|
||||||
sub = list.find &.id.== id
|
|
||||||
Plugin::Updater.default.check_subscription self, sub.not_nil!
|
|
||||||
list.save
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(id : String, dir : String? = nil)
|
|
||||||
Plugin.build_info_ary dir
|
|
||||||
|
|
||||||
@info = @@info_ary.find &.id.== id
|
@info = @@info_ary.find &.id.== id
|
||||||
if @info.nil?
|
if @info.nil?
|
||||||
@ -185,12 +138,6 @@ 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
|
||||||
|
|
||||||
@ -205,71 +152,23 @@ 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 can_subscribe? : Bool
|
|
||||||
info.version > 1 && eval_exists?("newChapters")
|
|
||||||
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
|
||||||
if info.version > 1
|
check_fields ["title", "chapters"]
|
||||||
# Since v2, listChapters returns an array
|
|
||||||
json.as_a.each do |obj|
|
ary = json["chapters"].as_a
|
||||||
assert_chapter_type obj
|
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
|
end
|
||||||
else
|
|
||||||
check_fields ["title", "chapters"]
|
|
||||||
|
|
||||||
ary = json["chapters"].as_a
|
title = obj["title"]?
|
||||||
ary.each do |obj|
|
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||||
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
|
||||||
@ -280,14 +179,10 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
if info.version > 1
|
check_fields ["title", "pages"]
|
||||||
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
|
||||||
@ -299,28 +194,14 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
assert_page_type json
|
check_fields ["filename", "url"]
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_chapters(manga_id : String, after : Int64)
|
private def eval(str)
|
||||||
# Converting standard timestamp to milliseconds so plugins can easily do
|
|
||||||
# `new Date(ms_timestamp)` in JS.
|
|
||||||
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
|
|
||||||
begin
|
|
||||||
json.as_a.each do |obj|
|
|
||||||
assert_chapter_type obj
|
|
||||||
end
|
|
||||||
rescue e
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
json
|
|
||||||
end
|
|
||||||
|
|
||||||
def eval(str)
|
|
||||||
@rt.eval str
|
@rt.eval str
|
||||||
rescue e : Duktape::SyntaxError
|
rescue e : Duktape::SyntaxError
|
||||||
raise SyntaxError.new e.message
|
raise SyntaxError.new e.message
|
||||||
@ -332,15 +213,6 @@ class Plugin
|
|||||||
JSON.parse eval(str).as String
|
JSON.parse eval(str).as String
|
||||||
end
|
end
|
||||||
|
|
||||||
private def eval_exists?(str) : Bool
|
|
||||||
@rt.eval str
|
|
||||||
true
|
|
||||||
rescue e : Duktape::ReferenceError
|
|
||||||
false
|
|
||||||
rescue e : Duktape::Error
|
|
||||||
raise Error.new e.message
|
|
||||||
end
|
|
||||||
|
|
||||||
private def def_helper_functions(sbx)
|
private def def_helper_functions(sbx)
|
||||||
sbx.push_object
|
sbx.push_object
|
||||||
|
|
||||||
@ -449,15 +321,9 @@ class Plugin
|
|||||||
env = Duktape::Sandbox.new ptr
|
env = Duktape::Sandbox.new ptr
|
||||||
html = env.require_string 0
|
html = env.require_string 0
|
||||||
|
|
||||||
begin
|
str = XML.parse(html).inner_text
|
||||||
parser = Myhtml::Parser.new html
|
|
||||||
str = parser.body!.children.first.inner_text
|
|
||||||
|
|
||||||
env.push_string str
|
|
||||||
rescue
|
|
||||||
env.push_string ""
|
|
||||||
end
|
|
||||||
|
|
||||||
|
env.push_string str
|
||||||
env.call_success
|
env.call_success
|
||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "text"
|
sbx.put_prop_string -2, "text"
|
||||||
@ -468,9 +334,8 @@ class Plugin
|
|||||||
name = env.require_string 1
|
name = env.require_string 1
|
||||||
|
|
||||||
begin
|
begin
|
||||||
parser = Myhtml::Parser.new html
|
attr = XML.parse(html).first_element_child.not_nil![name]
|
||||||
attr = parser.body!.children.first.attribute_by name
|
env.push_string attr
|
||||||
env.push_string attr.not_nil!
|
|
||||||
rescue
|
rescue
|
||||||
env.push_undefined
|
env.push_undefined
|
||||||
end
|
end
|
||||||
@ -514,27 +379,6 @@ 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
|
||||||
|
@ -1,115 +0,0 @@
|
|||||||
require "uuid"
|
|
||||||
require "big"
|
|
||||||
|
|
||||||
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_i? || _value.as_i64? ||
|
|
||||||
_value.as_f32? || nil
|
|
||||||
self.new key, value, type
|
|
||||||
end
|
|
||||||
|
|
||||||
def match_chapter(obj : JSON::Any) : Bool
|
|
||||||
return true if value.nil? || value.to_s.empty?
|
|
||||||
raw_value = obj[key]
|
|
||||||
case type
|
|
||||||
when FilterType::String
|
|
||||||
raw_value.as_s.downcase == value.to_s.downcase
|
|
||||||
when FilterType::NumMin, FilterType::DateMin
|
|
||||||
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
|
|
||||||
when FilterType::NumMax, FilterType::DateMax
|
|
||||||
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
|
|
||||||
when FilterType::Array
|
|
||||||
return true if value == "all"
|
|
||||||
raw_value.as_s.downcase.split(",")
|
|
||||||
.map(&.strip).includes? value.to_s.downcase.strip
|
|
||||||
else
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# We use class instead of struct so we can update `last_checked` from
|
|
||||||
# `SubscriptionList`
|
|
||||||
class Subscription
|
|
||||||
include JSON::Serializable
|
|
||||||
|
|
||||||
property id : String
|
|
||||||
property plugin_id : String
|
|
||||||
property manga_id : String
|
|
||||||
property manga_title : String
|
|
||||||
property name : String
|
|
||||||
property created_at : Int64
|
|
||||||
property last_checked : Int64
|
|
||||||
property filters = [] of Filter
|
|
||||||
|
|
||||||
def initialize(@plugin_id, @manga_id, @manga_title, @name)
|
|
||||||
@id = UUID.random.to_s
|
|
||||||
@created_at = Time.utc.to_unix
|
|
||||||
@last_checked = Time.utc.to_unix
|
|
||||||
end
|
|
||||||
|
|
||||||
def match_chapter(obj : JSON::Any) : Bool
|
|
||||||
filters.all? &.match_chapter(obj)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
struct SubscriptionList
|
|
||||||
@dir : String
|
|
||||||
@path : String
|
|
||||||
|
|
||||||
getter ary = [] of Subscription
|
|
||||||
|
|
||||||
forward_missing_to @ary
|
|
||||||
|
|
||||||
def initialize(@dir)
|
|
||||||
@path = Path[@dir, "subscriptions.json"].to_s
|
|
||||||
if File.exists? @path
|
|
||||||
@ary = Array(Subscription).from_json File.read @path
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def save
|
|
||||||
File.write @path, @ary.to_pretty_json
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,75 +0,0 @@
|
|||||||
class Plugin
|
|
||||||
class Updater
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
interval = Config.current.plugin_update_interval_hours
|
|
||||||
return if interval <= 0
|
|
||||||
spawn do
|
|
||||||
loop do
|
|
||||||
Plugin.list.map(&.["id"]).each do |pid|
|
|
||||||
check_updates pid
|
|
||||||
end
|
|
||||||
sleep interval.hours
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_updates(plugin_id : String)
|
|
||||||
Logger.debug "Checking plugin #{plugin_id} for updates"
|
|
||||||
|
|
||||||
plugin = Plugin.new plugin_id
|
|
||||||
if plugin.info.version == 1
|
|
||||||
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
|
|
||||||
"Skipping update check"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
subscriptions = plugin.list_subscriptions_raw
|
|
||||||
subscriptions.each do |sub|
|
|
||||||
check_subscription plugin, sub
|
|
||||||
end
|
|
||||||
subscriptions.save
|
|
||||||
rescue e
|
|
||||||
Logger.error "Error checking plugin #{plugin_id} for updates: " \
|
|
||||||
"#{e.message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_subscription(plugin : Plugin, sub : Subscription)
|
|
||||||
Logger.debug "Checking subscription #{sub.name} for updates"
|
|
||||||
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
|
|
||||||
.as_a.select do |chapter|
|
|
||||||
sub.match_chapter chapter
|
|
||||||
end
|
|
||||||
if matches.empty?
|
|
||||||
Logger.debug "No new chapters found."
|
|
||||||
sub.last_checked = Time.utc.to_unix
|
|
||||||
return
|
|
||||||
end
|
|
||||||
Logger.debug "Found #{matches.size} new chapters. " \
|
|
||||||
"Pushing to download queue"
|
|
||||||
jobs = matches.map { |ch|
|
|
||||||
Queue::Job.new(
|
|
||||||
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
|
||||||
"", # manga_id
|
|
||||||
ch["title"].as_s,
|
|
||||||
sub.manga_title,
|
|
||||||
Queue::JobStatus::Pending,
|
|
||||||
Time.utc
|
|
||||||
)
|
|
||||||
}
|
|
||||||
inserted_count = Queue.default.push jobs
|
|
||||||
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
|
|
||||||
"to the download queue. Plugin ID #{plugin.info.id}, " \
|
|
||||||
"subscription name #{sub.name}"
|
|
||||||
if inserted_count != matches.size
|
|
||||||
Logger.error "Failed to add #{matches.size - inserted_count} " \
|
|
||||||
"chapters to download queue"
|
|
||||||
end
|
|
||||||
sub.last_checked = Time.utc.to_unix
|
|
||||||
rescue e
|
|
||||||
Logger.error "Error when checking updates for subscription " \
|
|
||||||
"#{sub.name}: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -70,13 +70,7 @@ class Queue
|
|||||||
ary = @id.split("-")
|
ary = @id.split("-")
|
||||||
if ary.size == 2
|
if ary.size == 2
|
||||||
@plugin_id = ary[0]
|
@plugin_id = ary[0]
|
||||||
# This begin-rescue block is for backward compatibility. In earlier
|
@plugin_chapter_id = ary[1]
|
||||||
# versions we didn't encode the chapter ID
|
|
||||||
@plugin_chapter_id = begin
|
|
||||||
Base64.decode_string ary[1]
|
|
||||||
rescue
|
|
||||||
ary[1]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
require "sanitize"
|
|
||||||
|
|
||||||
struct AdminRouter
|
struct AdminRouter
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
@ -16,13 +14,13 @@ struct AdminRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user/edit" do |env|
|
get "/admin/user/edit" do |env|
|
||||||
sanitizer = Sanitize::Policy::Text.new
|
username = env.params.query["username"]?
|
||||||
username = env.params.query["username"]?.try { |s| sanitizer.process s }
|
|
||||||
admin = env.params.query["admin"]?
|
admin = env.params.query["admin"]?
|
||||||
if admin
|
if admin
|
||||||
admin = admin == "true"
|
admin = admin == "true"
|
||||||
end
|
end
|
||||||
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
error = env.params.query["error"]?
|
||||||
|
current_user = get_username env
|
||||||
new_user = username.nil? && admin.nil?
|
new_user = username.nil? && admin.nil?
|
||||||
layout "user-edit"
|
layout "user-edit"
|
||||||
end
|
end
|
||||||
@ -71,10 +69,6 @@ struct AdminRouter
|
|||||||
layout "download-manager"
|
layout "download-manager"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/subscriptions" do |env|
|
|
||||||
layout "subscription-manager"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/admin/missing" do |env|
|
get "/admin/missing" do |env|
|
||||||
layout "missing-items"
|
layout "missing-items"
|
||||||
end
|
end
|
||||||
|
@ -40,19 +40,14 @@ struct APIRouter
|
|||||||
Koa.schema "entry", {
|
Koa.schema "entry", {
|
||||||
"pages" => Int32,
|
"pages" => Int32,
|
||||||
"mtime" => Int64,
|
"mtime" => Int64,
|
||||||
}.merge(s %w(zip_path path title size id title_id display_name cover_url)),
|
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
||||||
desc: "An entry in a book"
|
desc: "An entry in a book"
|
||||||
|
|
||||||
Koa.schema "title", {
|
Koa.schema "title", {
|
||||||
"mtime" => Int64,
|
"mtime" => Int64,
|
||||||
"entries" => ["entry"],
|
"entries" => ["entry"],
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
"parents" => [{
|
"parents" => [String],
|
||||||
"title" => String,
|
|
||||||
"id" => String,
|
|
||||||
}],
|
|
||||||
"title_percentages" => [Float64?],
|
|
||||||
"entry_percentages" => [Float64?],
|
|
||||||
}.merge(s %w(dir title id display_name cover_url)),
|
}.merge(s %w(dir title id display_name cover_url)),
|
||||||
desc: "A manga title (a collection of entries and sub-titles)"
|
desc: "A manga title (a collection of entries and sub-titles)"
|
||||||
|
|
||||||
@ -61,23 +56,6 @@ struct APIRouter
|
|||||||
"error" => String?,
|
"error" => String?,
|
||||||
}
|
}
|
||||||
|
|
||||||
Koa.schema "filter", {
|
|
||||||
"key" => String,
|
|
||||||
"type" => String,
|
|
||||||
"value" => String | Int32 | Int64 | Float32,
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.schema "subscription", {
|
|
||||||
"id" => String,
|
|
||||||
"plugin_id" => String,
|
|
||||||
"manga_id" => String,
|
|
||||||
"manga_title" => String,
|
|
||||||
"name" => String,
|
|
||||||
"created_at" => Int64,
|
|
||||||
"last_checked" => Int64,
|
|
||||||
"filters" => ["filter"],
|
|
||||||
}
|
|
||||||
|
|
||||||
Koa.describe "Authenticates a user", <<-MD
|
Koa.describe "Authenticates a user", <<-MD
|
||||||
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
||||||
MD
|
MD
|
||||||
@ -85,12 +63,6 @@ struct APIRouter
|
|||||||
"username" => String,
|
"username" => String,
|
||||||
"password" => String,
|
"password" => String,
|
||||||
}
|
}
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"session_id" => String?,
|
|
||||||
"is_admin" => Bool?,
|
|
||||||
}
|
|
||||||
Koa.tag "users"
|
Koa.tag "users"
|
||||||
post "/api/login" do |env|
|
post "/api/login" do |env|
|
||||||
begin
|
begin
|
||||||
@ -99,18 +71,11 @@ struct APIRouter
|
|||||||
token = Storage.default.verify_user(username, password).not_nil!
|
token = Storage.default.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
send_json env, {
|
"Authenticated"
|
||||||
"success" => true,
|
|
||||||
"session_id" => env.session.id,
|
|
||||||
"is_admin" => Storage.default.username_is_admin username,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
send_json env, {
|
e.message
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -142,19 +107,14 @@ struct APIRouter
|
|||||||
env.response.status_code = 304
|
env.response.status_code = 304
|
||||||
""
|
""
|
||||||
else
|
else
|
||||||
if entry.is_a? DirEntry
|
|
||||||
cache_control = "no-cache, max-age=86400"
|
|
||||||
else
|
|
||||||
cache_control = "public, max-age=86400"
|
|
||||||
end
|
|
||||||
env.response.headers["ETag"] = e_tag
|
env.response.headers["ETag"] = e_tag
|
||||||
env.response.headers["Cache-Control"] = cache_control
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
send_img env, img
|
send_img env, img
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
send_text env, e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -191,13 +151,11 @@ struct APIRouter
|
|||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
send_text env, e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||||
The entries and titles will be sorted by the default sorting method for the logged-in user.
|
|
||||||
- Supply the `percentage` query parameter to include the reading progress
|
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
||||||
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
||||||
@ -208,7 +166,8 @@ struct APIRouter
|
|||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
Koa.query "depth"
|
Koa.query "depth"
|
||||||
Koa.query "percentage"
|
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
|
||||||
|
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
|
||||||
Koa.response 200, schema: "title"
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
@ -216,104 +175,29 @@ struct APIRouter
|
|||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new
|
||||||
|
get_sort_opt
|
||||||
|
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
|
||||||
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
percentage = !env.params.query["percentage"]?.nil?
|
|
||||||
|
|
||||||
send_json env, title.build_json(slim: slim, depth: depth,
|
send_json env, title.build_json(slim: slim, depth: depth,
|
||||||
sort_context: {username: username,
|
sort_context: {username: username,
|
||||||
opt: sort_opt}, percentage: percentage)
|
opt: sort_opt})
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
send_text env, e.message
|
e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the sorting option of a title or the library", <<-MD
|
|
||||||
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
|
|
||||||
- If the query parameter `tid` is missing, returns the sorting option of the library.
|
|
||||||
MD
|
|
||||||
Koa.query "tid"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"method" => String?,
|
|
||||||
"ascend" => Bool?,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/sort_opt" do |env|
|
|
||||||
username = get_username env
|
|
||||||
|
|
||||||
tid = env.params.query["tid"]?
|
|
||||||
dir = if tid
|
|
||||||
(Library.default.get_title tid).not_nil!.dir
|
|
||||||
else
|
|
||||||
Library.default.dir
|
|
||||||
end
|
|
||||||
sort_opt = SortOptions.from_info_json dir, username
|
|
||||||
send_json env, sort_opt.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Updates the sorting option of a title or the library", <<-MD
|
|
||||||
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
|
|
||||||
- When the `tid` field is missing in the body, updates the sorting option of the library.
|
|
||||||
MD
|
|
||||||
Koa.body schema: {
|
|
||||||
"tid" => String?,
|
|
||||||
"method" => String,
|
|
||||||
"ascend" => Bool,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
put "/api/sort_opt" do |env|
|
|
||||||
username = get_username env
|
|
||||||
|
|
||||||
tid = env.params.json["tid"]?.try &.as String
|
|
||||||
dir = if tid
|
|
||||||
(Library.default.get_title tid).not_nil!.dir
|
|
||||||
else
|
|
||||||
Library.default.dir
|
|
||||||
end
|
|
||||||
|
|
||||||
method = env.params.json["sort"].as String
|
|
||||||
ascend = env.params.json["ascend"].as Bool
|
|
||||||
sort_opt = SortOptions.new method, ascend
|
|
||||||
|
|
||||||
TitleInfo.new dir do |info|
|
|
||||||
info.sort_by[username] = sort_opt.to_tuple
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||||
The titles will be sorted by the default sorting method for the logged-in user.
|
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
||||||
- Supply the `percentage` query parameter to include the reading progress
|
|
||||||
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
||||||
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
||||||
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
||||||
@ -321,162 +205,16 @@ struct APIRouter
|
|||||||
MD
|
MD
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
Koa.query "depth"
|
Koa.query "depth"
|
||||||
Koa.query "percentage"
|
|
||||||
Koa.response 200, schema: {
|
Koa.response 200, schema: {
|
||||||
"dir" => String,
|
"dir" => String,
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
"title_percentage" => [Float64?],
|
|
||||||
}
|
}
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
username = get_username env
|
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
|
||||||
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
percentage = !env.params.query["percentage"]?.nil?
|
|
||||||
|
|
||||||
send_json env, Library.default.build_json(slim: slim, depth: depth,
|
send_json env, Library.default.build_json(slim: slim, depth: depth)
|
||||||
sort_context: {username: username,
|
|
||||||
opt: sort_opt}, percentage: percentage)
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the continue reading entries"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"entries" => ["entry"],
|
|
||||||
"entry_percentages" => [Float64],
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/library/continue_reading" do |env|
|
|
||||||
username = get_username env
|
|
||||||
cr_entries = Library.default.get_continue_reading_entries username
|
|
||||||
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.object do
|
|
||||||
j.field "success" do
|
|
||||||
j.bool true
|
|
||||||
end
|
|
||||||
j.field "entries" do
|
|
||||||
j.array do
|
|
||||||
cr_entries.each do |e|
|
|
||||||
j.raw e[:entry].build_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
j.field "entry_percentages" do
|
|
||||||
j.array do
|
|
||||||
cr_entries.each do |e|
|
|
||||||
j.number e[:percentage]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
send_json env, json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the start reading titles"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"titles" => ["title"],
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/library/start_reading" do |env|
|
|
||||||
username = get_username env
|
|
||||||
titles = Library.default.get_start_reading_titles username
|
|
||||||
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.object do
|
|
||||||
j.field "success" do
|
|
||||||
j.bool true
|
|
||||||
end
|
|
||||||
j.field "titles" do
|
|
||||||
j.array do
|
|
||||||
titles.each do |t|
|
|
||||||
j.raw t.build_json depth: 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
send_json env, json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the recently added items"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"items" => [{
|
|
||||||
"item" => "title | entry",
|
|
||||||
"percentage" => Float64,
|
|
||||||
"count" => Int32,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
Koa.tag "library"
|
|
||||||
get "/api/library/recently_added" do |env|
|
|
||||||
username = get_username env
|
|
||||||
ra_entries = Library.default.get_recently_added_entries username
|
|
||||||
|
|
||||||
json = JSON.build do |j|
|
|
||||||
j.object do
|
|
||||||
j.field "success" do
|
|
||||||
j.bool true
|
|
||||||
end
|
|
||||||
j.field "items" do
|
|
||||||
j.array do
|
|
||||||
ra_entries.each do |e|
|
|
||||||
j.object do
|
|
||||||
j.field "item" do
|
|
||||||
if e[:grouped_count] === 1
|
|
||||||
j.raw e[:entry].build_json
|
|
||||||
else
|
|
||||||
j.raw e[:entry].book.build_json depth: 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
j.field "percentage" do
|
|
||||||
j.number e[:percentage]
|
|
||||||
end
|
|
||||||
j.field "count" do
|
|
||||||
j.number e[:grouped_count]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
send_json env, json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@ -512,7 +250,6 @@ struct APIRouter
|
|||||||
spawn do
|
spawn do
|
||||||
Library.default.generate_thumbnails
|
Library.default.generate_thumbnails
|
||||||
end
|
end
|
||||||
send_text env, ""
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Deletes a user with `username`"
|
Koa.describe "Deletes a user with `username`"
|
||||||
@ -830,211 +567,6 @@ 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,
|
|
||||||
},
|
|
||||||
"subscribable" => Bool,
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
"subscribable" => plugin.can_subscribe?,
|
|
||||||
}.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 "Creates a new subscription"
|
|
||||||
Koa.tags ["admin", "downloader", "subscription"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"plugin" => String,
|
|
||||||
"manga" => String,
|
|
||||||
"manga_id" => String,
|
|
||||||
"name" => String,
|
|
||||||
"filters" => ["filter"],
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
post "/api/admin/plugin/subscriptions" do |env|
|
|
||||||
begin
|
|
||||||
plugin_id = env.params.json["plugin"].as String
|
|
||||||
manga_title = env.params.json["manga"].as String
|
|
||||||
manga_id = env.params.json["manga_id"].as String
|
|
||||||
filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f|
|
|
||||||
Filter.from_json f.to_json
|
|
||||||
end
|
|
||||||
name = env.params.json["name"].as String
|
|
||||||
|
|
||||||
sub = Subscription.new plugin_id, manga_id, manga_title, name
|
|
||||||
sub.filters = filters
|
|
||||||
|
|
||||||
plugin = Plugin.new plugin_id
|
|
||||||
plugin.subscribe sub
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Returns the list of subscriptions for a plugin"
|
|
||||||
Koa.tags ["admin", "downloader", "subscription"]
|
|
||||||
Koa.query "plugin", desc: "The ID of the plugin"
|
|
||||||
Koa.response 200, schema: {
|
|
||||||
"success" => Bool,
|
|
||||||
"error" => String?,
|
|
||||||
"subscriptions" => ["subscription"],
|
|
||||||
}
|
|
||||||
get "/api/admin/plugin/subscriptions" do |env|
|
|
||||||
begin
|
|
||||||
pid = env.params.query["plugin"].as String
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
"subscriptions" => Plugin.new(pid).list_subscriptions,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Deletes a subscription"
|
|
||||||
Koa.tags ["admin", "downloader", "subscription"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"plugin" => String,
|
|
||||||
"subscription" => String,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
delete "/api/admin/plugin/subscriptions" do |env|
|
|
||||||
begin
|
|
||||||
pid = env.params.query["plugin"].as String
|
|
||||||
sid = env.params.query["subscription"].as String
|
|
||||||
|
|
||||||
Plugin.new(pid).unsubscribe sid
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Koa.describe "Checks for updates for a subscription"
|
|
||||||
Koa.tags ["admin", "downloader", "subscription"]
|
|
||||||
Koa.body schema: {
|
|
||||||
"plugin" => String,
|
|
||||||
"subscription" => String,
|
|
||||||
}
|
|
||||||
Koa.response 200, schema: "result"
|
|
||||||
post "/api/admin/plugin/subscriptions/update" do |env|
|
|
||||||
pid = env.params.query["plugin"].as String
|
|
||||||
sid = env.params.query["subscription"].as String
|
|
||||||
|
|
||||||
Plugin.new(pid).check_subscription sid
|
|
||||||
|
|
||||||
send_json env, {
|
|
||||||
"success" => true,
|
|
||||||
}.to_json
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
send_json env, {
|
|
||||||
"success" => false,
|
|
||||||
"error" => e.message,
|
|
||||||
}.to_json
|
|
||||||
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
|
||||||
@ -1043,8 +575,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?,
|
||||||
}
|
}
|
||||||
@ -1054,14 +586,8 @@ 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"]
|
||||||
if plugin.info.version == 1
|
title = json["title"]
|
||||||
chapters = json["chapters"]
|
|
||||||
title = json["title"]
|
|
||||||
else
|
|
||||||
chapters = json
|
|
||||||
title = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@ -1099,7 +625,7 @@ struct APIRouter
|
|||||||
|
|
||||||
jobs = chapters.map { |ch|
|
jobs = chapters.map { |ch|
|
||||||
Queue::Job.new(
|
Queue::Job.new(
|
||||||
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
"#{plugin.info.id}-#{ch["id"]}",
|
||||||
"", # manga_id
|
"", # manga_id
|
||||||
ch["title"].as_s,
|
ch["title"].as_s,
|
||||||
manga_title,
|
manga_title,
|
||||||
@ -1145,24 +671,15 @@ struct APIRouter
|
|||||||
entry = title.get_entry eid
|
entry = title.get_entry eid
|
||||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||||
|
|
||||||
if entry.is_a? DirEntry
|
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
||||||
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size)
|
|
||||||
else
|
|
||||||
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s)
|
|
||||||
end
|
|
||||||
e_tag = "W/#{file_hash}"
|
e_tag = "W/#{file_hash}"
|
||||||
if e_tag == prev_e_tag
|
if e_tag == prev_e_tag
|
||||||
env.response.status_code = 304
|
env.response.status_code = 304
|
||||||
send_text env, ""
|
""
|
||||||
else
|
else
|
||||||
sizes = entry.page_dimensions
|
sizes = entry.page_dimensions
|
||||||
if entry.is_a? DirEntry
|
|
||||||
cache_control = "no-cache, max-age=86400"
|
|
||||||
else
|
|
||||||
cache_control = "public, max-age=86400"
|
|
||||||
end
|
|
||||||
env.response.headers["ETag"] = e_tag
|
env.response.headers["ETag"] = e_tag
|
||||||
env.response.headers["Cache-Control"] = cache_control
|
env.response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
"dimensions" => sizes,
|
"dimensions" => sizes,
|
||||||
@ -1188,11 +705,10 @@ struct APIRouter
|
|||||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||||
|
|
||||||
send_attachment env, entry.path
|
send_attachment env, entry.zip_path
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
send_text env, e.message
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -80,6 +80,16 @@ 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
|
||||||
|
@ -53,7 +53,6 @@ struct ReaderRouter
|
|||||||
render "src/views/reader.html.ecr"
|
render "src/views/reader.html.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
Logger.debug e.backtrace?
|
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -25,17 +25,6 @@ class Server
|
|||||||
APIRouter.new
|
APIRouter.new
|
||||||
OPDSRouter.new
|
OPDSRouter.new
|
||||||
|
|
||||||
{% for path in %w(/api/* /uploads/* /img/*) %}
|
|
||||||
options {{path}} do |env|
|
|
||||||
cors
|
|
||||||
halt env
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
static_headers do |response|
|
|
||||||
response.headers.add("Access-Control-Allow-Origin", "*")
|
|
||||||
end
|
|
||||||
|
|
||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new
|
||||||
add_handler AuthHandler.new
|
add_handler AuthHandler.new
|
||||||
|
83
src/subscription.cr
Normal file
83
src/subscription.cr
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
require "db"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
struct Subscription
|
||||||
|
include DB::Serializable
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
getter id : Int64 = 0
|
||||||
|
getter username : String
|
||||||
|
getter manga_id : Int64
|
||||||
|
property language : String?
|
||||||
|
property group_id : Int64?
|
||||||
|
property min_volume : Int64?
|
||||||
|
property max_volume : Int64?
|
||||||
|
property min_chapter : Int64?
|
||||||
|
property max_chapter : Int64?
|
||||||
|
@[DB::Field(key: "last_checked")]
|
||||||
|
@[JSON::Field(key: "last_checked")]
|
||||||
|
@raw_last_checked : Int64
|
||||||
|
@[DB::Field(key: "created_at")]
|
||||||
|
@[JSON::Field(key: "created_at")]
|
||||||
|
@raw_created_at : Int64
|
||||||
|
|
||||||
|
def last_checked : Time
|
||||||
|
Time.unix @raw_last_checked
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_at : Time
|
||||||
|
Time.unix @raw_created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(@manga_id, @username)
|
||||||
|
@raw_created_at = Time.utc.to_unix
|
||||||
|
@raw_last_checked = Time.utc.to_unix
|
||||||
|
end
|
||||||
|
|
||||||
|
private def in_range?(value : String, lowerbound : Int64?,
|
||||||
|
upperbound : Int64?) : Bool
|
||||||
|
lb = lowerbound.try &.to_f64
|
||||||
|
ub = upperbound.try &.to_f64
|
||||||
|
|
||||||
|
return true if lb.nil? && ub.nil?
|
||||||
|
|
||||||
|
v = value.to_f64?
|
||||||
|
return false unless v
|
||||||
|
|
||||||
|
if lb.nil?
|
||||||
|
v <= ub.not_nil!
|
||||||
|
elsif ub.nil?
|
||||||
|
v >= lb.not_nil!
|
||||||
|
else
|
||||||
|
v >= lb.not_nil! && v <= ub.not_nil!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def match?(chapter : MangaDex::Chapter) : Bool
|
||||||
|
if chapter.manga_id != manga_id ||
|
||||||
|
(language && chapter.language != language) ||
|
||||||
|
(group_id && !chapter.groups.map(&.id).includes? group_id)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
in_range?(chapter.volume, min_volume, max_volume) &&
|
||||||
|
in_range?(chapter.chapter, min_chapter, max_chapter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_for_updates : Int32
|
||||||
|
Logger.debug "Checking updates for subscription with ID #{id}"
|
||||||
|
jobs = [] of Queue::Job
|
||||||
|
get_client(username).user.updates_after last_checked do |chapter|
|
||||||
|
next unless match? chapter
|
||||||
|
jobs << chapter.to_job
|
||||||
|
end
|
||||||
|
Storage.default.update_subscription_last_checked id
|
||||||
|
count = Queue.default.push jobs
|
||||||
|
Logger.debug "#{count}/#{jobs.size} of updates added to queue"
|
||||||
|
count
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error occurred when checking updates for " \
|
||||||
|
"subscription with ID #{id}. #{e}"
|
||||||
|
0
|
||||||
|
end
|
||||||
|
end
|
@ -19,7 +19,7 @@ class File
|
|||||||
# information as long as the above changes do not happen together with
|
# information as long as the above changes do not happen together with
|
||||||
# a file/folder rename, with no library scan in between.
|
# a file/folder rename, with no library scan in between.
|
||||||
def self.signature(filename) : UInt64
|
def self.signature(filename) : UInt64
|
||||||
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
|
if is_supported_file filename
|
||||||
File.info(filename).inode
|
File.info(filename).inode
|
||||||
else
|
else
|
||||||
0u64
|
0u64
|
||||||
@ -67,9 +67,7 @@ class Dir
|
|||||||
else
|
else
|
||||||
# Only add its signature value to `signatures` when it is a
|
# Only add its signature value to `signatures` when it is a
|
||||||
# supported file
|
# supported file
|
||||||
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
|
signatures << fn if is_supported_file fn
|
||||||
signatures << fn
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
Fiber.yield
|
Fiber.yield
|
||||||
end
|
end
|
||||||
@ -78,19 +76,4 @@ class Dir
|
|||||||
cache[dirname] = hash
|
cache[dirname] = hash
|
||||||
hash
|
hash
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.directory_entry_signature(dirname, cache = {} of String => String)
|
|
||||||
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
|
|
||||||
Fiber.yield
|
|
||||||
signatures = [] of String
|
|
||||||
image_files = DirEntry.sorted_image_files dirname
|
|
||||||
if image_files.size > 0
|
|
||||||
image_files.each do |path|
|
|
||||||
signatures << File.signature(path).to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
hash = Digest::SHA1.hexdigest(signatures.join)
|
|
||||||
cache[dirname + "?entry"] = hash
|
|
||||||
hash
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@ -1,19 +1,8 @@
|
|||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
ENTRIES_IN_HOME_SECTIONS = 8
|
ENTRIES_IN_HOME_SECTIONS = 8
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt
|
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
||||||
/manifest.json)
|
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
|
||||||
SUPPORTED_IMG_TYPES = %w(
|
|
||||||
image/jpeg
|
|
||||||
image/png
|
|
||||||
image/webp
|
|
||||||
image/apng
|
|
||||||
image/avif
|
|
||||||
image/gif
|
|
||||||
image/svg+xml
|
|
||||||
image/jxl
|
|
||||||
)
|
|
||||||
|
|
||||||
def random_str
|
def random_str
|
||||||
UUID.random.to_s.gsub "-", ""
|
UUID.random.to_s.gsub "-", ""
|
||||||
@ -51,7 +40,6 @@ def register_mime_types
|
|||||||
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||||
".apng" => "image/apng",
|
".apng" => "image/apng",
|
||||||
".avif" => "image/avif",
|
".avif" => "image/avif",
|
||||||
".jxl" => "image/jxl",
|
|
||||||
}.each do |k, v|
|
}.each do |k, v|
|
||||||
MIME.register k, v
|
MIME.register k, v
|
||||||
end
|
end
|
||||||
@ -61,10 +49,6 @@ def is_supported_file(path)
|
|||||||
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_supported_image_file(path)
|
|
||||||
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
|
|
||||||
end
|
|
||||||
|
|
||||||
struct Int
|
struct Int
|
||||||
def or(other : Int)
|
def or(other : Int)
|
||||||
if self == 0
|
if self == 0
|
||||||
@ -96,9 +80,9 @@ class String
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def env_is_true?(key : String, default : Bool = false) : Bool
|
def env_is_true?(key : String) : Bool
|
||||||
val = ENV[key.upcase]? || ENV[key.downcase]?
|
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||||
return default unless val
|
return false unless val
|
||||||
val.downcase.in? "1", "true"
|
val.downcase.in? "1", "true"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -39,28 +39,13 @@ macro send_error_page(msg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
macro send_img(env, img)
|
macro send_img(env, img)
|
||||||
cors
|
|
||||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_token_from_auth_header(env) : String?
|
|
||||||
value = env.request.headers["Authorization"]
|
|
||||||
if value && value.starts_with? "Bearer"
|
|
||||||
session_id = value.split(" ")[1]
|
|
||||||
return Kemal::Session.get(session_id).try &.string? "token"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
begin
|
begin
|
||||||
# Check if we can get the session id from the cookie
|
token = env.session.string "token"
|
||||||
token = env.session.string? "token"
|
(Storage.default.verify_token token).not_nil!
|
||||||
if token.nil?
|
|
||||||
# If not, check if we can get the session id from the auth header
|
|
||||||
token = get_token_from_auth_header env
|
|
||||||
end
|
|
||||||
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
|
|
||||||
(Storage.default.verify_token token.not_nil!).not_nil!
|
|
||||||
rescue e
|
rescue e
|
||||||
if Config.current.disable_login
|
if Config.current.disable_login
|
||||||
Config.current.default_username
|
Config.current.default_username
|
||||||
@ -72,29 +57,12 @@ macro get_username(env)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
macro cors
|
|
||||||
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
|
|
||||||
"DELETE,OPTIONS"
|
|
||||||
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
|
|
||||||
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
|
|
||||||
"Authorization"
|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
cors
|
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
env.response.print json
|
env.response.print json
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_text(env, text)
|
|
||||||
cors
|
|
||||||
env.response.content_type = "text/plain"
|
|
||||||
env.response.print text
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_attachment(env, path)
|
def send_attachment(env, path)
|
||||||
cors
|
|
||||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -40,6 +40,5 @@
|
|||||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
|
||||||
<script src="<%= base_url %>js/admin.js"></script>
|
<script src="<%= base_url %>js/admin.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||||
<link rel="manifest" href="<%= base_url %>manifest.json">
|
|
||||||
|
|
||||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||||
|
162
src/views/download.html.ecr
Normal file
162
src/views/download.html.ecr
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
|
<div x-data="downloadComponent()" x-init="init()">
|
||||||
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
|
<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()">
|
||||||
|
</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">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<template x-for="manga in mangaAry" :key="manga.id">
|
||||||
|
<div class="item" :data-id="manga.id" @click="chooseManga(manga)">
|
||||||
|
<div class="uk-card uk-card-default">
|
||||||
|
<div class="uk-card-media-top uk-inline">
|
||||||
|
<img uk-img :data-src="manga.mainCover">
|
||||||
|
</div>
|
||||||
|
<div class="uk-card-body">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="data && data.chapters" x-cloak>
|
||||||
|
<div class"uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-width-1-4@s">
|
||||||
|
<img :src="data.mainCover">
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-1-4@s">
|
||||||
|
<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="`Author: ${data.author}`"></p>
|
||||||
|
</div>
|
||||||
|
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
||||||
|
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||||
|
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label">Language</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<option x-text="lang"></option>
|
||||||
|
</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 class="uk-margin">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<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()" 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>
|
||||||
|
</td>
|
||||||
|
<td x-text="chp.volume"></td>
|
||||||
|
<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 class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
|
<div class="uk-modal-header">
|
||||||
|
<h3 class="uk-modal-title break-word" x-text="candidateManga.title"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-body">
|
||||||
|
<div class="uk-grid">
|
||||||
|
<div class="uk-width-1-3@s">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="uk-width-2-3@s" uk-overflow-auto>
|
||||||
|
<p x-text="candidateManga.description"></p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<%= render_component "jquery-ui" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/download.js"></script>
|
||||||
|
<% end %>
|
@ -19,7 +19,6 @@
|
|||||||
<ul class="uk-nav-sub">
|
<ul class="uk-nav-sub">
|
||||||
<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 %>admin/subscriptions">Subscription Manager</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -37,7 +36,7 @@
|
|||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-left uk-visible@m">
|
<div class="uk-navbar-left uk-visible@m">
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icons/icon.png" style="width:90px;height:90px;"></a>
|
<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">
|
<ul class="uk-navbar-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>
|
||||||
@ -52,7 +51,6 @@
|
|||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li class="uk-nav-divider"></li>
|
<li class="uk-nav-divider"></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 %>admin/subscriptions">Subscription Manager</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -80,7 +78,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
setTheme();
|
setTheme();
|
||||||
const base_url = "<%= base_url %>";
|
const base_url = "<%= base_url %>";
|
||||||
</script>
|
</script>
|
||||||
<%= render_component "uikit" %>
|
<%= render_component "uikit" %>
|
||||||
<%= yield_content "script" %>
|
<%= yield_content "script" %>
|
||||||
|
39
src/views/mangadex.html.ecr
Normal file
39
src/views/mangadex.html.ecr
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<div x-data="component()" x-init="init()">
|
||||||
|
<h2 class="uk-title">Connect to MangaDex</h2>
|
||||||
|
<div class"uk-grid-small" uk-grid x-show="!loading" x-cloak>
|
||||||
|
<div class="uk-width-1-2@s" x-show="!expires">
|
||||||
|
<p>This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Search MangaDex by search terms in addition to manga IDs</li>
|
||||||
|
<li>Automatically download new chapters when they are available (coming soon)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-width-1-2@s" x-show="expires">
|
||||||
|
<p>
|
||||||
|
<span x-show="!expired">You have logged in to MangaDex!</span>
|
||||||
|
<span x-show="expired">You have logged in to MangaDex but the token has expired.</span>
|
||||||
|
The expiration date of your token is <code x-text="moment.unix(expires).format('MMMM Do YYYY, HH:mm:ss')"></code>.
|
||||||
|
<span x-show="!expired">If the integration is not working, you</span>
|
||||||
|
<span x-show="expired">You</span>
|
||||||
|
can log in again and the token will be updated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-width-1-2@s">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" x-model="username" @keydown.enter.debounce="login()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" x-model="password" @keydown.enter.debounce="login()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1" @click="login()" :disabled="loggingIn">Login to MangaDex</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/mangadex.js"></script>
|
||||||
|
<% end %>
|
@ -29,7 +29,7 @@
|
|||||||
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||||
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||||
|
|
||||||
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" />
|
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||||
|
|
||||||
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
|
||||||
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||||
|
@ -1,216 +1,77 @@
|
|||||||
<div x-data="component()" x-init="init()" x-cloak>
|
<% if plugins.empty? %>
|
||||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
<div class="uk-container uk-text-center">
|
||||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
<h2>No Plugins Found</h2>
|
||||||
<h2>No Plugins Found</h2>
|
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
</div>
|
||||||
|
|
||||||
|
<% else %>
|
||||||
|
<h2 class=uk-title>Download with Plugins</h2>
|
||||||
|
|
||||||
|
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||||
|
<div class="uk-width-3-4@m uk-child-width-1-1">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="search-input"> </label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="search-input" class="uk-input" type="text" placeholder="<%= plugin.not_nil!.info.placeholder %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-width-expand">
|
||||||
<div x-show="plugins.length > 0" style="width:100%">
|
<div class="uk-margin">
|
||||||
<h2 class=uk-title>Download with Plugins
|
<label class="uk-form-label" for="plugin-select">Choose a plugin</label>
|
||||||
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
<div class="uk-form-controls">
|
||||||
</h2>
|
<select id="plugin-select" class="uk-select">
|
||||||
|
<% plugins.each do |p| %>
|
||||||
<template x-if="info !== undefined">
|
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||||
<div>
|
<% end %>
|
||||||
<div class="uk-grid-small" uk-grid>
|
</select>
|
||||||
<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] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
|
|
||||||
<dd x-text="entry[1]"></dd>
|
|
||||||
</dl>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
<template x-if="manga">
|
<div class="uk-width-auto">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<p x-show="manga.length === 0">No matching manga found.</p>
|
<label class="uk-form-label" for="search-input"> </label>
|
||||||
<p x-show="manga.length > 0">
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
<span x-text="`${manga.length} manga found`"></span>
|
<span uk-icon="info" uk-toggle="target: #toggle"></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" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min">
|
|
||||||
</div>
|
|
||||||
<div class="uk-width-1-2@s">
|
|
||||||
<input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :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>
|
|
||||||
<span x-show="subscribable">
|
|
||||||
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
|
|
||||||
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
|
|
||||||
</span>
|
|
||||||
</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-html="renderCell(ch[k])"></td>
|
|
||||||
</template>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div uk-modal="container:false" x-ref="modal">
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
<div class="uk-modal-dialog">
|
<% plugin.not_nil!.info.each do |k, v| %>
|
||||||
<div class="uk-modal-header">
|
<dt><%= k %></dt>
|
||||||
<h2 class="uk-modal-title">Subscription Confirmation</h2>
|
<dd><%= v.to_s %></dd>
|
||||||
</div>
|
<% end %>
|
||||||
<div class="uk-modal-body">
|
</dl>
|
||||||
<p>A subscription with the following filters with be created. All <strong>FUTURE</strong> chapters matching the filters will be automatically downloaded.</p>
|
|
||||||
<table class="uk-table uk-table-striped">
|
<div id="table" class="uk-margin-large-top" hidden>
|
||||||
<thead>
|
<h3 id="title-text"></h3>
|
||||||
<tr>
|
|
||||||
<th>Key</th>
|
<div class="uk-margin">
|
||||||
<th>Type</th>
|
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||||
<th>Value</th>
|
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||||
</tr>
|
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||||
</thead>
|
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||||
<tbody>
|
</div>
|
||||||
<template x-for="ft in filterSettings" :key="ft">
|
<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>
|
||||||
<tr x-html="renderFilterRow(ft)"></tr>
|
<div class="uk-overflow-auto">
|
||||||
</template>
|
<table class="uk-table uk-table-striped tablesorter">
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
<p>Enter a meaningful name for the subscription to continue:</p>
|
|
||||||
<input class="uk-input" type="text" x-model="subscriptionName">
|
|
||||||
</div>
|
|
||||||
<div class="uk-modal-footer uk-text-right">
|
|
||||||
<button class="uk-button uk-button-default uk-modal-close" type="button">Cancel</button>
|
|
||||||
<button class="uk-button uk-button-primary" type="button" :disabled="subscriptionName.trim().length === 0" @click="subscribe($refs.modal)">Confirm</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" %>
|
||||||
<%= render_component "moment" %>
|
<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 %>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
|
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p>
|
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
|
||||||
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
|
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<%= render_component "head" %>
|
<%= render_component "head" %>
|
||||||
|
|
||||||
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
|
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
|
||||||
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'">
|
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
|
||||||
|
|
||||||
<div @keydown.window.debounce="keyHandler($event)"></div>
|
<div @keydown.window.debounce="keyHandler($event)"></div>
|
||||||
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;">
|
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||||
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||||
<img
|
<img
|
||||||
@ -30,7 +30,7 @@
|
|||||||
:height="item.height"
|
:height="item.height"
|
||||||
:id="item.id"
|
:id="item.id"
|
||||||
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||||
@click="clickImage($event)"
|
@click="showControl($event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<%- if next_entry_url -%>
|
<%- if next_entry_url -%>
|
||||||
@ -40,18 +40,18 @@
|
|||||||
<%- end -%>
|
<%- end -%>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`">
|
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
|
||||||
|
|
||||||
<img uk-img :class="{
|
<img uk-img :class="{
|
||||||
'uk-align-center': true,
|
'uk-align-center': true,
|
||||||
'uk-animation-slide-left': flipAnimation === 'left',
|
'uk-animation-slide-left': flipAnimation === 'left',
|
||||||
'uk-animation-slide-right': flipAnimation === 'right'
|
'uk-animation-slide-right': flipAnimation === 'right'
|
||||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
|
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
|
||||||
width:${fitType === 'horz' ? '100vw' : 'auto'};
|
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||||
height:${fitType === 'vert' ? '100vh' : 'auto'};
|
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||||
margin-bottom:0;
|
margin-bottom:0;
|
||||||
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
|
max-width:100%;
|
||||||
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
|
max-height:100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
`" />
|
`" />
|
||||||
|
|
||||||
@ -67,7 +67,7 @@
|
|||||||
<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"><%= entry.display_name %></h3>
|
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
|
||||||
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p>
|
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@ -94,17 +94,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin" x-show="mode !== 'continuous'">
|
|
||||||
<label class="uk-form-label" for="mode-select">Page fit</label>
|
|
||||||
<div class="uk-form-controls">
|
|
||||||
<select id="fit-select" class="uk-select" @change="fitChanged()">
|
|
||||||
<option value="vert">Fit height</option>
|
|
||||||
<option value="horz">Fit width</option>
|
|
||||||
<option value="real">Real size</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="uk-margin" x-show="mode === 'continuous'">
|
<div class="uk-margin" x-show="mode === 'continuous'">
|
||||||
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
|
@ -1,101 +0,0 @@
|
|||||||
<h2 class=uk-title>Subscription Manager</h2>
|
|
||||||
<div x-data="component()" x-init="init()">
|
|
||||||
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
|
||||||
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
|
||||||
<h2>No Plugins Found</h2>
|
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="plugins.length > 0" style="width:100%">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p x-show="subscriptions.length === 0" class="uk-text-meta">No subscriptions found.</p>
|
|
||||||
|
|
||||||
<div class="uk-overflow-auto" x-show="subscriptions.length > 0">
|
|
||||||
<table class="uk-table uk-table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Plugin ID</th>
|
|
||||||
<th>Manga Title</th>
|
|
||||||
<th>Created At</th>
|
|
||||||
<th>Last Checked</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="sub in subscriptions" :key="sub">
|
|
||||||
<tr :sid="sub.id" @click="selected($event, $refs.modal)">
|
|
||||||
<td x-html="renderStrCell(sub.name)"></td>
|
|
||||||
<td x-html="renderStrCell(sub.plugin_id)"></td>
|
|
||||||
<td x-html="renderStrCell(sub.manga_title)"></td>
|
|
||||||
<td x-html="renderDateCell(sub.created_at)"></td>
|
|
||||||
<td x-html="renderDateCell(sub.last_checked)"></td>
|
|
||||||
<td>
|
|
||||||
<a @click.prevent.stop="actionHandler($event, 'delete')" uk-icon="trash" uk-tooltip="Delete" :disabled="loading"></a>
|
|
||||||
<a @click.prevent.stop="actionHandler($event, 'update')" uk-icon="refresh" uk-tooltip="Check for updates" :disabled="loading"></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div uk-modal="container:false" x-ref="modal" class="uk-flex-top">
|
|
||||||
<div class="uk-modal-dialog uk-margin-auto-vertical uk-overflow-auto">
|
|
||||||
<div class="uk-modal-header">
|
|
||||||
<h2 class="uk-modal-title">Subscription Details</h2>
|
|
||||||
</div>
|
|
||||||
<div class="uk-modal-body">
|
|
||||||
<dl>
|
|
||||||
<dt>Name</dt>
|
|
||||||
<dd x-html="subscription && subscription.name"></dd>
|
|
||||||
<dt>Subscription ID</dt>
|
|
||||||
<dd x-html="subscription && subscription.id"></dd>
|
|
||||||
<dt>Plugin ID</dt>
|
|
||||||
<dd x-html="subscription && subscription.plugin_id"></dd>
|
|
||||||
<dt>Manga Title</dt>
|
|
||||||
<dd x-html="subscription && subscription.manga_title"></dd>
|
|
||||||
<dt>Manga ID</dt>
|
|
||||||
<dd x-html="subscription && subscription.manga_id"></dd>
|
|
||||||
<dt>Filters</dt>
|
|
||||||
</dl>
|
|
||||||
<table class="uk-table uk-table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Key</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template x-for="ft in (subscription && subscription.filters || [])" :key="ft">
|
|
||||||
<tr x-html="renderFilterRow(ft)"></tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="uk-text-right">
|
|
||||||
<button class="uk-button uk-button-default uk-modal-close" type="button">OK</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
|
||||||
<%= render_component "moment" %>
|
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
|
||||||
<script src="<%= base_url %>js/subscription-manager.js"></script>
|
|
||||||
<% end %>
|
|
54
src/views/subscription.html.ecr
Normal file
54
src/views/subscription.html.ecr
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<h2 class="uk-title">MangaDex Subscription Manager</h2>
|
||||||
|
|
||||||
|
<div x-data="component()" x-init="init()">
|
||||||
|
<p x-show="available === false">The subscription manager uses a MangaDex API that requires authentication. Please <a href="<%= base_url %>admin/mangadex">connect to MangaDex</a> before using this feature.</p>
|
||||||
|
|
||||||
|
<p x-show="available && subscriptions.length === 0">No subscription found. Go to the <a href="<%= base_url %>download">MangaDex download page</a> and start subscribing.</p>
|
||||||
|
|
||||||
|
<template x-if="subscriptions.length > 0">
|
||||||
|
<div class="uk-overflow-auto">
|
||||||
|
<table class="uk-table uk-table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Manga ID</th>
|
||||||
|
<th>Language</th>
|
||||||
|
<th>Group ID</th>
|
||||||
|
<th>Volume Range</th>
|
||||||
|
<th>Chapter Range</th>
|
||||||
|
<th>Creator</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="sub in subscriptions" :key="sub">
|
||||||
|
<tr>
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/manga/${sub.manga_id}`" x-text="sub.manga_id"></a></td>
|
||||||
|
<td x-text="sub.language || 'All'"></td>
|
||||||
|
<td>
|
||||||
|
<a x-show="sub.group_id" :href="`<%= mangadex_base_url %>/group/${sub.group_id}`" x-text="sub.group_id"></a>
|
||||||
|
<span x-show="!sub.group_id">All</span>
|
||||||
|
</td>
|
||||||
|
<td x-text="formatRange(sub.min_volume, sub.max_volume)"></td>
|
||||||
|
<td x-text="formatRange(sub.min_chapter, sub.max_chapter)"></td>
|
||||||
|
<td x-text="sub.username"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.last_checked).fromNow()}`"></td>
|
||||||
|
<td x-text="`${moment.unix(sub.created_at).fromNow()}`"></td>
|
||||||
|
<td :data-id="sub.id">
|
||||||
|
<a @click="check($event)" x-show="sub.username === '<%= username %>'" uk-icon="refresh" uk-tooltip="Check for updates"></a>
|
||||||
|
<a @click="rm($event)" x-show="sub.username === '<%= username %>'" uk-icon="trash" uk-tooltip="Delete"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<%= render_component "moment" %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/subscription.js"></script>
|
||||||
|
<% end %>
|
Loading…
x
Reference in New Issue
Block a user