mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-03 03:15:31 -04:00
Compare commits
102 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7a7d9eb3a1 | ||
|
1fb48648ad | ||
|
7ceb91f051 | ||
|
9ea4ced729 | ||
|
4c2f802e2e | ||
|
7258b3cece | ||
|
bf885a8b30 | ||
|
98a0c54499 | ||
|
cb3df432d0 | ||
|
47af6ee284 | ||
|
9fe269ab13 | ||
|
75a30a88e0 | ||
|
5daeac72cb | ||
|
dc3ac42dec | ||
|
624283643c | ||
|
6ddbe8d436 | ||
|
db5e99b3f0 | ||
|
405b958deb | ||
|
e7c4123dec | ||
|
2d2486a598 | ||
|
b6a1ad889e | ||
|
f2d6d28a72 | ||
|
49425ff714 | ||
|
f3eb62a271 | ||
|
2e91028ead | ||
|
19a8f3100b | ||
|
3b5e764d36 | ||
|
32ce26a133 | ||
|
31df058f81 | ||
|
fe440d82d4 | ||
|
44636e051e | ||
|
a639392ca0 | ||
|
17a9c8ecd3 | ||
|
bbc0c2cbb7 | ||
|
be46dd1f86 | ||
|
ae583cf2a9 | ||
|
ea35faee91 | ||
|
5b58d8ac59 | ||
|
30d5ad0c19 | ||
|
d9dce4a881 | ||
|
2d97faa7c0 | ||
|
9ce8e918f0 | ||
|
8e4bb995d3 | ||
|
39a331c879 | ||
|
df618704ea | ||
|
2fb620211d | ||
|
5b23a112b2 | ||
|
e6dbeb623b | ||
|
872e6dc6d6 | ||
|
82c60ccc1d | ||
|
ae503ae099 | ||
|
648cdd772c | ||
|
238539c27d | ||
|
1f5aed64f7 | ||
|
f18f6a5418 | ||
|
0ed565519b | ||
|
3da5d9ba4e | ||
|
3a60286c3a | ||
|
9f6be70995 | ||
|
caf4cfb6cd | ||
|
137e84dfb6 | ||
|
3b3a0738e8 | ||
|
55ccd928a2 | ||
|
10587f48cb | ||
|
ea6cbbd9ce | ||
|
883e01bbdd | ||
|
5f59b7ee42 | ||
|
eac274a211 | ||
|
0e4169cb22 | ||
|
28656695c6 | ||
|
61dc92838a | ||
|
ce1dcff229 | ||
|
4f599fb719 | ||
|
c831879c23 | ||
|
171b44643c | ||
|
a353029fcd | ||
|
75e26d8624 | ||
|
ebe2c8efed | ||
|
b8ce1cc7f1 | ||
|
24c90e7283 | ||
|
9ffc34e8e6 | ||
|
d1de8b7a4e | ||
|
7ae0577e4e | ||
|
e9b1bccbc9 | ||
|
293fb84e1d | ||
|
9c07944390 | ||
|
173d69eb26 | ||
|
21d8d0e8a7 | ||
|
61e85dd49f | ||
|
c778364ca2 | ||
|
7ecdb1c0dd | ||
|
a5a7396edd | ||
|
461398d219 | ||
|
0d52544617 | ||
|
c3736d222c | ||
|
2091053221 | ||
|
703e6d076b | ||
|
1817efe608 | ||
|
8814778c22 | ||
|
6ab885499c | ||
|
91561ecd6b | ||
|
3c399fac4e |
@ -12,3 +12,4 @@ Layout/LineLength:
|
||||
MaxLength: 80
|
||||
Excluded:
|
||||
- src/routes/api.cr
|
||||
- spec/plugin_spec.cr
|
||||
|
@ -4,6 +4,9 @@
|
||||
|
||||
[](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
|
||||
|
||||
- Multi-user support
|
||||
@ -51,7 +54,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
||||
### CLI
|
||||
|
||||
```
|
||||
Mango - Manga Server and Web Reader. Version 0.25.0
|
||||
Mango - Manga Server and Web Reader. Version 0.27.0
|
||||
|
||||
Usage:
|
||||
|
||||
@ -94,9 +97,10 @@ cache_log_enabled: true
|
||||
disable_login: false
|
||||
default_username: ""
|
||||
auth_proxy_header_name: ""
|
||||
plugin_update_interval_hours: 24
|
||||
```
|
||||
|
||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||
- `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
|
||||
- `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.
|
||||
- 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', () => {
|
||||
return gulp.src([
|
||||
'public/*.*',
|
||||
'public/img/*',
|
||||
'public/img/**',
|
||||
'public/webfonts/*',
|
||||
'public/js/*.min.js'
|
||||
], {
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
BIN
public/img/icons/icon_x192.png
Normal file
BIN
public/img/icons/icon_x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
BIN
public/img/icons/icon_x512.png
Normal file
BIN
public/img/icons/icon_x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
public/img/icons/icon_x96.png
Normal file
BIN
public/img/icons/icon_x96.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
@ -31,6 +31,9 @@ const component = () => {
|
||||
this.scanMs = data.milliseconds;
|
||||
this.scanTitles = data.titles;
|
||||
})
|
||||
.catch(e => {
|
||||
alert('danger', `Failed to trigger a scan. Error: ${e}`);
|
||||
})
|
||||
.always(() => {
|
||||
this.scanning = false;
|
||||
});
|
||||
|
@ -1,144 +1,452 @@
|
||||
const loadPlugin = id => {
|
||||
localStorage.setItem('plugin', id);
|
||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
||||
const newURL = `${url}?${$.param({
|
||||
plugin: id
|
||||
})}`;
|
||||
window.location.href = newURL;
|
||||
};
|
||||
|
||||
$(() => {
|
||||
var storedID = localStorage.getItem('plugin');
|
||||
if (storedID && storedID !== pid) {
|
||||
loadPlugin(storedID);
|
||||
} else {
|
||||
$('#controls').removeAttr('hidden');
|
||||
}
|
||||
|
||||
$('#search-input').keypress(event => {
|
||||
if (event.which === 13) {
|
||||
search();
|
||||
}
|
||||
});
|
||||
$('#plugin-select').val(pid);
|
||||
$('#plugin-select').change(() => {
|
||||
const id = $('#plugin-select').val();
|
||||
loadPlugin(id);
|
||||
});
|
||||
});
|
||||
|
||||
let mangaTitle = "";
|
||||
let searching = false;
|
||||
const search = () => {
|
||||
if (searching)
|
||||
return;
|
||||
|
||||
const query = $.param({
|
||||
query: $('#search-input').val(),
|
||||
plugin: pid
|
||||
});
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `${base_url}api/admin/plugin/list?${query}`,
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Search failed. Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
mangaTitle = data.title;
|
||||
$('#title-text').text(data.title);
|
||||
buildTable(data.chapters);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
})
|
||||
.always(() => {});
|
||||
};
|
||||
|
||||
const buildTable = (chapters) => {
|
||||
$('#table').attr('hidden', '');
|
||||
$('table').empty();
|
||||
|
||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
||||
$('table').append(thead);
|
||||
|
||||
const rows = chapters.map(ch => {
|
||||
const tds = Object.values(ch).map(v => {
|
||||
const maxLength = 40;
|
||||
const shouldShrink = v && v.length > maxLength;
|
||||
const content = shouldShrink ? `<span title="${v}">${v.substring(0, maxLength)}...</span><div uk-dropdown><span>${v}</span></div>` : v;
|
||||
return `<td>${content}</td>`
|
||||
}).join('');
|
||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
||||
});
|
||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
||||
$('table').append(tbody);
|
||||
|
||||
$('#selectable').selectable({
|
||||
filter: 'tr'
|
||||
});
|
||||
|
||||
$('#table table').tablesorter();
|
||||
$('#table').removeAttr('hidden');
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).addClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const unselect = () => {
|
||||
$('tbody > tr').each((i, e) => {
|
||||
$(e).removeClass('ui-selected');
|
||||
});
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const selected = $('tbody > tr.ui-selected');
|
||||
if (selected.length === 0) return;
|
||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||
$('#download-btn').attr('hidden', '');
|
||||
$('#download-spinner').removeAttr('hidden');
|
||||
const chapters = selected.map((i, e) => {
|
||||
const component = () => {
|
||||
return {
|
||||
id: $(e).attr('data-id'),
|
||||
title: $(e).attr('data-title')
|
||||
}
|
||||
}).get();
|
||||
console.log(chapters);
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: base_url + 'api/admin/plugin/download',
|
||||
data: JSON.stringify({
|
||||
plugin: pid,
|
||||
chapters: chapters,
|
||||
title: mangaTitle
|
||||
}),
|
||||
contentType: "application/json",
|
||||
dataType: 'json'
|
||||
plugins: [],
|
||||
subscribable: false,
|
||||
info: undefined,
|
||||
pid: undefined,
|
||||
chapters: undefined, // undefined: not searched yet, []: empty
|
||||
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(() => {
|
||||
console.log("table mutated");
|
||||
$("#selectable").selectable({
|
||||
filter: "tr",
|
||||
});
|
||||
});
|
||||
tableObserver.observe($("table").get(0), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
fetch(`${base_url}api/admin/plugin`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.plugins = data.plugins;
|
||||
|
||||
const pid = localStorage.getItem("plugin");
|
||||
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
||||
return this.loadPlugin(pid);
|
||||
|
||||
if (this.plugins.length > 0)
|
||||
this.loadPlugin(this.plugins[0].id);
|
||||
})
|
||||
.done(data => {
|
||||
console.log(data);
|
||||
if (data.error) {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
||||
return;
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to list the available plugins. Error: ${e}`
|
||||
);
|
||||
});
|
||||
},
|
||||
loadPlugin(pid) {
|
||||
fetch(
|
||||
`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
||||
plugin: pid,
|
||||
})}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data.success) throw new Error(data.error);
|
||||
this.info = data.info;
|
||||
this.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;
|
||||
this.chapters = data.chapters;
|
||||
})
|
||||
.catch((e) => {
|
||||
alert("danger", `Failed to list chapters. Error: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
searchManga(query) {
|
||||
this.searching = true;
|
||||
this.allChapters = [];
|
||||
this.chapters = undefined;
|
||||
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>.`);
|
||||
alert(
|
||||
"success",
|
||||
`${successCount} of ${
|
||||
successCount + failCount
|
||||
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
|
||||
);
|
||||
})
|
||||
.fail((jqXHR, status) => {
|
||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||
.catch((e) => {
|
||||
alert(
|
||||
"danger",
|
||||
`Failed to add chapters to the download queue. Error: ${e}`
|
||||
);
|
||||
})
|
||||
.always(() => {
|
||||
$('#download-spinner').attr('hidden', '');
|
||||
$('#download-btn').removeAttr('hidden');
|
||||
.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;
|
||||
}
|
||||
|
||||
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||
const comp = this.compare(a[key], b[key]);
|
||||
return option < 0 ? comp * -1 : comp;
|
||||
});
|
||||
},
|
||||
compare(a, b) {
|
||||
if (a === b) return 0;
|
||||
|
||||
// try numbers (also covers dates)
|
||||
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
|
||||
|
||||
const preprocessString = (val) => {
|
||||
if (typeof val !== "string") return val;
|
||||
return val.toLowerCase().replace(/\s\s/g, " ").trim();
|
||||
};
|
||||
|
||||
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||
},
|
||||
fieldType(values) {
|
||||
if (values.every((v) => this.numIsDate(v))) return "date";
|
||||
if (values.every((v) => !isNaN(v))) return "number";
|
||||
if (values.every((v) => Array.isArray(v))) return "array";
|
||||
return "string";
|
||||
},
|
||||
get filters() {
|
||||
if (this.allChapters.length < 1) return [];
|
||||
const keys = Object.keys(this.allChapters[0]).filter(
|
||||
(k) => !["manga_title", "id"].includes(k)
|
||||
);
|
||||
return keys.map((k) => {
|
||||
let values = this.allChapters.map((c) => c[k]);
|
||||
const type = this.fieldType(values);
|
||||
|
||||
if (type === "array") {
|
||||
// if the type is an array, return the list of available elements
|
||||
// example: an array of groups or authors
|
||||
values = Array.from(
|
||||
new Set(
|
||||
values.flat().map((v) => {
|
||||
if (typeof v === "string")
|
||||
return v.toLowerCase();
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: k,
|
||||
type: type,
|
||||
values: values,
|
||||
};
|
||||
});
|
||||
},
|
||||
get filterSettings() {
|
||||
return $("#filter-form input:visible, #filter-form select:visible")
|
||||
.get()
|
||||
.map((i) => {
|
||||
const type = i.getAttribute("data-filter-type");
|
||||
let value = i.value.trim();
|
||||
if (type.startsWith("date"))
|
||||
value = value ? Date.parse(value).toString() : "";
|
||||
return {
|
||||
key: i.getAttribute("data-filter-key"),
|
||||
value: value,
|
||||
type: type,
|
||||
};
|
||||
});
|
||||
},
|
||||
applyFilters() {
|
||||
this.appliedFilters = this.filterSettings;
|
||||
this.chapters = this.filteredChapters;
|
||||
this.sortOptions = [];
|
||||
},
|
||||
clearFilters() {
|
||||
$("#filter-form input")
|
||||
.get()
|
||||
.forEach((i) => (i.value = ""));
|
||||
$("#filter-form select").val("all");
|
||||
this.appliedFilters = [];
|
||||
this.chapters = this.filteredChapters;
|
||||
this.sortOptions = [];
|
||||
},
|
||||
mangaSelected(event) {
|
||||
const mid = event.currentTarget.getAttribute("data-id");
|
||||
this.mid = mid;
|
||||
this.searchChapters(mid);
|
||||
},
|
||||
subscribe(modal) {
|
||||
this.subscribing = true;
|
||||
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,6 +14,7 @@ const readerComponent = () => {
|
||||
margin: 30,
|
||||
preloadLookahead: 3,
|
||||
enableRightToLeft: false,
|
||||
fitType: 'vert',
|
||||
|
||||
/**
|
||||
* Initialize the component by fetching the page dimensions
|
||||
@ -29,14 +30,16 @@ const readerComponent = () => {
|
||||
return {
|
||||
id: i + 1,
|
||||
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
width: d.width == 0 ? "100%" : d.width,
|
||||
height: d.height == 0 ? "100%" : d.height,
|
||||
};
|
||||
});
|
||||
|
||||
const avgRatio = this.items.reduce((acc, cur) => {
|
||||
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
|
||||
// TODO: support more image types in image_size.cr
|
||||
const avgRatio = dimensions.reduce((acc, cur) => {
|
||||
return acc + cur.height / cur.width
|
||||
}, 0) / this.items.length;
|
||||
}, 0) / dimensions.length;
|
||||
|
||||
console.log(avgRatio);
|
||||
this.longPages = avgRatio > 2;
|
||||
@ -58,11 +61,16 @@ const readerComponent = () => {
|
||||
|
||||
// Preload Images
|
||||
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
|
||||
const limit = Math.min(page + this.preloadLookahead, this.items.length);
|
||||
for (let idx = page + 1; idx <= limit; idx++) {
|
||||
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');
|
||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||
|
||||
@ -135,7 +143,11 @@ const readerComponent = () => {
|
||||
const idx = parseInt(this.curItem.id);
|
||||
const newIdx = idx + (isNext ? 1 : -1);
|
||||
|
||||
if (newIdx <= 0 || newIdx > this.items.length) return;
|
||||
if (newIdx <= 0) return;
|
||||
if (newIdx > this.items.length) {
|
||||
this.showControl(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||
@ -253,12 +265,20 @@ const readerComponent = () => {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Shows the control modal
|
||||
* Handles clicked image
|
||||
*
|
||||
* @param {Event} event - The triggering event
|
||||
*/
|
||||
showControl(event) {
|
||||
clickImage(event) {
|
||||
const idx = event.currentTarget.id;
|
||||
this.showControl(idx);
|
||||
},
|
||||
/**
|
||||
* Shows the control modal
|
||||
*
|
||||
* @param {number} idx - selected page index
|
||||
*/
|
||||
showControl(idx) {
|
||||
this.selectedIndex = idx;
|
||||
UIkit.modal($('#modal-sections')).show();
|
||||
},
|
||||
@ -321,6 +341,11 @@ const readerComponent = () => {
|
||||
this.toPage(this.selectedIndex);
|
||||
},
|
||||
|
||||
fitChanged(){
|
||||
this.fitType = $('#fit-select').val();
|
||||
localStorage.setItem('fitType', this.fitType);
|
||||
},
|
||||
|
||||
preloadLookaheadChanged() {
|
||||
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||
},
|
||||
|
147
public/js/subscription-manager.js
Normal file
147
public/js/subscription-manager.js
Normal file
@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
23
public/manifest.json
Normal file
23
public/manifest.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"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:
|
||||
git: https://github.com/hkalexling/koa.git
|
||||
version: 0.8.0
|
||||
version: 0.9.0
|
||||
|
||||
mg:
|
||||
git: https://github.com/hkalexling/mg.git
|
||||
@ -68,6 +68,10 @@ shards:
|
||||
git: https://github.com/luislavena/radix.git
|
||||
version: 0.4.1
|
||||
|
||||
sanitize:
|
||||
git: https://github.com/hkalexling/sanitize.git
|
||||
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
||||
|
||||
sqlite3:
|
||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||
version: 0.18.0
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: mango
|
||||
version: 0.25.0
|
||||
version: 0.27.0
|
||||
|
||||
authors:
|
||||
- Alex Ling <hkalexling@gmail.com>
|
||||
@ -42,3 +42,5 @@ dependencies:
|
||||
branch: master
|
||||
mg:
|
||||
github: hkalexling/mg
|
||||
sanitize:
|
||||
github: hkalexling/sanitize
|
||||
|
0
spec/asset/plugins/plugin/index.js
Normal file
0
spec/asset/plugins/plugin/index.js
Normal file
6
spec/asset/plugins/plugin/info.json
Normal file
6
spec/asset/plugins/plugin/info.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "test",
|
||||
"title": "Test Plugin",
|
||||
"placeholder": "placeholder",
|
||||
"wait_seconds": 1
|
||||
}
|
@ -1,14 +1,31 @@
|
||||
require "./spec_helper"
|
||||
|
||||
describe Config do
|
||||
it "creates config if it does not exist" do
|
||||
with_default_config do |_, path|
|
||||
it "creates default config if it does not exist" do
|
||||
with_default_config do |config, path|
|
||||
File.exists?(path).should be_true
|
||||
config.port.should eq 9000
|
||||
end
|
||||
end
|
||||
|
||||
it "correctly loads config" do
|
||||
config = Config.load "spec/asset/test-config.yml"
|
||||
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
|
||||
|
70
spec/plugin_spec.cr
Normal file
70
spec/plugin_spec.cr
Normal file
@ -0,0 +1,70 @@
|
||||
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,6 +3,7 @@ require "../src/queue"
|
||||
require "../src/server"
|
||||
require "../src/config"
|
||||
require "../src/main_fiber"
|
||||
require "../src/plugin/plugin"
|
||||
|
||||
class State
|
||||
@@hash = {} of String => String
|
||||
@ -54,3 +55,10 @@ def with_storage
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_plugin
|
||||
with_default_config do
|
||||
plugin = Plugin.new "test", "spec/asset/plugins"
|
||||
yield plugin
|
||||
end
|
||||
end
|
||||
|
@ -1,30 +1,51 @@
|
||||
require "yaml"
|
||||
|
||||
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
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
property path = ""
|
||||
property host = "0.0.0.0"
|
||||
property port : Int32 = 9000
|
||||
property base_url = "/"
|
||||
property session_secret = "mango-session-secret"
|
||||
property library_path = "~/mango/library"
|
||||
property library_cache_path = "~/mango/library.yml.gz"
|
||||
property db_path = "~/mango/mango.db"
|
||||
property queue_db_path = "~/mango/queue.db"
|
||||
property scan_interval_minutes : Int32 = 5
|
||||
property thumbnail_generation_interval_hours : Int32 = 24
|
||||
property log_level = "info"
|
||||
property upload_path = "~/mango/uploads"
|
||||
property plugin_path = "~/mango/plugins"
|
||||
property download_timeout_seconds : Int32 = 30
|
||||
property cache_enabled = true
|
||||
property cache_size_mbs = 50
|
||||
property cache_log_enabled = true
|
||||
property disable_login = false
|
||||
property default_username = ""
|
||||
property auth_proxy_header_name = ""
|
||||
property path : String = ""
|
||||
|
||||
# Go through the options constant above and define them as properties.
|
||||
# Allow setting the default values through environment variables.
|
||||
# Overall precedence: config file > environment variable > default value
|
||||
{% begin %}
|
||||
{% for k, v in OPTIONS %}
|
||||
{% if v.is_a? StringLiteral %}
|
||||
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
|
||||
{% elsif v.is_a? NumberLiteral %}
|
||||
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
|
||||
{% elsif v.is_a? BoolLiteral %}
|
||||
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
|
||||
{% else %}
|
||||
raise "Unknown type in config option: {{ v.class_name.id }}"
|
||||
{% end %}
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
@@singlet : Config?
|
||||
|
||||
@ -37,7 +58,7 @@ class Config
|
||||
end
|
||||
|
||||
def self.load(path : String?)
|
||||
path = "~/.config/mango/config.yml" if path.nil?
|
||||
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
|
||||
cfg_path = File.expand_path path, home: true
|
||||
if File.exists? cfg_path
|
||||
config = self.from_yaml File.read cfg_path
|
||||
|
@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler
|
||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||
|
||||
BASIC = "Basic"
|
||||
BEARER = "Bearer"
|
||||
AUTH = "Authorization"
|
||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||
"You have to login with proper credentials"
|
||||
@ -18,9 +19,15 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
def require_auth(env)
|
||||
if request_path_startswith env, ["/api"]
|
||||
# Do not redirect API requests
|
||||
env.response.status_code = 401
|
||||
send_text env, "Unauthorized"
|
||||
else
|
||||
env.session.string "callback", env.request.path
|
||||
redirect env, "/login"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_token(env)
|
||||
token = env.session.string? "token"
|
||||
@ -35,13 +42,18 @@ class AuthHandler < Kemal::Handler
|
||||
def validate_auth_header(env)
|
||||
if env.request.headers[AUTH]?
|
||||
if value = env.request.headers[AUTH]
|
||||
if value.size > 0 && value.starts_with?(BASIC)
|
||||
if value.starts_with? BASIC
|
||||
token = verify_user value
|
||||
return false if token.nil?
|
||||
|
||||
env.session.string "token", token
|
||||
return true
|
||||
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
|
||||
false
|
||||
@ -54,6 +66,10 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
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,
|
||||
# or a static file
|
||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||
@ -62,8 +78,8 @@ class AuthHandler < Kemal::Handler
|
||||
end
|
||||
|
||||
# Check user is logged in
|
||||
if validate_token env
|
||||
# Skip if the request has a valid token
|
||||
if validate_token(env) || validate_auth_header(env)
|
||||
# Skip if the request has a valid token (either from cookies or header)
|
||||
elsif Config.current.disable_login
|
||||
# Check default username if login is disabled
|
||||
unless Storage.default.username_exists Config.current.default_username
|
||||
|
8
src/handlers/cors_handler.cr
Normal file
8
src/handlers/cors_handler.cr
Normal file
@ -0,0 +1,8 @@
|
||||
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
|
111
src/library/archive_entry.cr
Normal file
111
src/library/archive_entry.cr
Normal file
@ -0,0 +1,111 @@
|
||||
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
|
132
src/library/dir_entry.cr
Normal file
132
src/library/dir_entry.cr
Normal file
@ -0,0 +1,132 @@
|
||||
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,64 +1,57 @@
|
||||
require "image_size"
|
||||
require "yaml"
|
||||
|
||||
class Entry
|
||||
include YAML::Serializable
|
||||
|
||||
getter zip_path : String, book : Title, title : String,
|
||||
size : String, pages : Int32, id : String, encoded_path : String,
|
||||
encoded_title : String, mtime : Time, err_msg : String?
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
|
||||
def initialize(@zip_path, @book)
|
||||
storage = Storage.default
|
||||
@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
|
||||
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
|
||||
node.nodes
|
||||
.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
|
||||
|
||||
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
|
||||
abstract class Entry
|
||||
getter id : String, book : Title, title : String, path : String,
|
||||
size : String, pages : Int32, mtime : Time,
|
||||
encoded_path : String, encoded_title : String, err_msg : String?
|
||||
|
||||
def initialize(
|
||||
@id, @title, @book, @path,
|
||||
@size, @pages, @mtime,
|
||||
@encoded_path, @encoded_title, @err_msg
|
||||
)
|
||||
end
|
||||
|
||||
file = ArchiveFile.new @zip_path
|
||||
@pages = file.entries.count do |e|
|
||||
SUPPORTED_IMG_TYPES.includes? \
|
||||
MIME.from_filename? e.filename
|
||||
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
|
||||
unless node.is_a? YAML::Nodes::Mapping
|
||||
raise "Unexpected node type in YAML"
|
||||
end
|
||||
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
|
||||
# instead we are using a more hacky approach (see `node_has_key`).
|
||||
# TODO: Use a more elegant approach
|
||||
if node_has_key node, "zip_path"
|
||||
ArchiveEntry.new ctx, node
|
||||
elsif node_has_key node, "dir_path"
|
||||
DirEntry.new ctx, node
|
||||
else
|
||||
raise "Unknown entry found in YAML cache. Try deleting the " \
|
||||
"`library.yml.gz` file"
|
||||
end
|
||||
file.close
|
||||
end
|
||||
|
||||
def build_json(*, slim = false)
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
||||
json.field {{str}}, @{{str.id}}
|
||||
{% for str in %w(path title size id) %}
|
||||
json.field {{str}}, {{str.id}}
|
||||
{% 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_title", @book.title
|
||||
json.field "sort_title", sort_title
|
||||
json.field "pages" { json.number @pages }
|
||||
unless slim
|
||||
@ -70,6 +63,9 @@ class Entry
|
||||
end
|
||||
end
|
||||
|
||||
@[YAML::Field(ignore: true)]
|
||||
@sort_title : String?
|
||||
|
||||
def sort_title
|
||||
sort_title_cached = @sort_title
|
||||
return sort_title_cached if sort_title_cached
|
||||
@ -108,7 +104,7 @@ class Entry
|
||||
end
|
||||
|
||||
def cover_url
|
||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
||||
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg
|
||||
|
||||
unless @book.entry_cover_url_cache
|
||||
TitleInfo.new @book.dir do |info|
|
||||
@ -127,54 +123,6 @@ class Entry
|
||||
url
|
||||
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)
|
||||
entries = @book.sorted_entries username
|
||||
idx = entries.index self
|
||||
@ -189,20 +137,6 @@ class Entry
|
||||
entries[idx - 1]
|
||||
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
|
||||
# instead of IDs in info.json
|
||||
def save_progress(username, page)
|
||||
@ -282,7 +216,7 @@ class Entry
|
||||
end
|
||||
Storage.default.save_thumbnail @id, img
|
||||
rescue e
|
||||
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
|
||||
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
|
||||
end
|
||||
|
||||
img
|
||||
@ -291,4 +225,34 @@ class Entry
|
||||
def get_thumbnail : Image?
|
||||
Storage.default.get_thumbnail @id
|
||||
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
|
||||
|
@ -139,14 +139,31 @@ class Library
|
||||
titles.flat_map &.deep_entries
|
||||
end
|
||||
|
||||
def build_json(*, slim = false, depth = -1)
|
||||
def build_json(*, slim = false, depth = -1, sort_context = nil,
|
||||
percentage = false)
|
||||
_titles = if sort_context
|
||||
sorted_titles sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
self.titles
|
||||
end
|
||||
JSON.build do |json|
|
||||
json.object do
|
||||
json.field "dir", @dir
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
json.raw title.build_json(slim: slim, depth: depth)
|
||||
_titles.each do |title|
|
||||
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
|
||||
|
@ -49,13 +49,18 @@ class Title
|
||||
path = File.join dir, fn
|
||||
if File.directory? path
|
||||
title = Title.new path, @id, cache
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
unless title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@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
|
||||
end
|
||||
if is_supported_file path
|
||||
entry = Entry.new path, self
|
||||
entry = ArchiveEntry.new path, self
|
||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||
end
|
||||
end
|
||||
@ -127,12 +132,12 @@ class Title
|
||||
|
||||
previous_entries_size = @entries.size
|
||||
@entries.select! do |entry|
|
||||
existence = File.exists? entry.zip_path
|
||||
existence = entry.examine
|
||||
Fiber.yield
|
||||
context["deleted_entry_ids"] << entry.id unless existence
|
||||
existence
|
||||
end
|
||||
remained_entry_zip_paths = @entries.map &.zip_path
|
||||
remained_entry_paths = @entries.map &.path
|
||||
|
||||
is_titles_added = false
|
||||
is_entries_added = false
|
||||
@ -140,9 +145,22 @@ class Title
|
||||
next if fn.starts_with? "."
|
||||
path = File.join dir, fn
|
||||
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
|
||||
title = Title.new path, @id, context["cached_contents_signature"]
|
||||
next if title.entries.size == 0 && title.titles.size == 0
|
||||
unless title.entries.size == 0 && title.titles.size == 0
|
||||
Library.default.title_hash[title.id] = title
|
||||
@title_ids << title.id
|
||||
is_titles_added = true
|
||||
@ -157,12 +175,13 @@ class Title
|
||||
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||
!(revival_entry_ids.includes? deleted_entry_id)
|
||||
end
|
||||
end
|
||||
|
||||
next
|
||||
end
|
||||
if is_supported_file path
|
||||
next if remained_entry_zip_paths.includes? path
|
||||
entry = Entry.new path, self
|
||||
next if remained_entry_paths.includes? path
|
||||
entry = ArchiveEntry.new path, self
|
||||
if entry.pages > 0 || entry.err_msg
|
||||
@entries << entry
|
||||
is_entries_added = true
|
||||
@ -202,7 +221,21 @@ class Title
|
||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||
|
||||
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.object do
|
||||
{% for str in ["dir", "title", "id"] %}
|
||||
@ -218,25 +251,39 @@ class Title
|
||||
unless depth == 0
|
||||
json.field "titles" do
|
||||
json.array do
|
||||
self.titles.each do |title|
|
||||
_titles.each do |title|
|
||||
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
|
||||
json.field "entries" do
|
||||
json.array do
|
||||
_entries = if sort_context
|
||||
sorted_entries sort_context[:username],
|
||||
sort_context[:opt]
|
||||
else
|
||||
@entries
|
||||
end
|
||||
_entries.each do |entry|
|
||||
json.raw entry.build_json(slim: slim)
|
||||
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
|
||||
json.field "parents" do
|
||||
json.array do
|
||||
@ -411,7 +458,7 @@ class Title
|
||||
cached_cover_url = @cached_cover_url
|
||||
return cached_cover_url unless cached_cover_url.nil?
|
||||
|
||||
url = "#{Config.current.base_url}img/icon.png"
|
||||
url = "#{Config.current.base_url}img/icons/icon_x192.png"
|
||||
readable_entries = @entries.select &.err_msg.nil?
|
||||
if readable_entries.size > 0
|
||||
url = readable_entries[0].cover_url
|
||||
@ -585,6 +632,16 @@ class Title
|
||||
|
||||
if last_read_entry && last_read_entry.finished? 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
|
||||
|
||||
last_read_entry
|
||||
@ -599,7 +656,7 @@ class Title
|
||||
|
||||
@entries.each do |e|
|
||||
next if da.has_key? e.title
|
||||
da[e.title] = ctime e.zip_path
|
||||
da[e.title] = ctime e.path
|
||||
end
|
||||
|
||||
TitleInfo.new @dir do |info|
|
||||
|
@ -1,13 +1,3 @@
|
||||
SUPPORTED_IMG_TYPES = %w(
|
||||
image/jpeg
|
||||
image/png
|
||||
image/webp
|
||||
image/apng
|
||||
image/avif
|
||||
image/gif
|
||||
image/svg+xml
|
||||
)
|
||||
|
||||
enum SortMethod
|
||||
Auto
|
||||
Title
|
||||
@ -55,6 +45,13 @@ class SortOptions
|
||||
def to_tuple
|
||||
{@method.to_s.underscore, ascend}
|
||||
end
|
||||
|
||||
def to_json
|
||||
{
|
||||
"method" => method.to_s.underscore,
|
||||
"ascend" => ascend,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
struct Image
|
||||
|
@ -38,6 +38,7 @@ class Logger
|
||||
Log.setup do |c|
|
||||
c.bind "*", @@severity, @backend
|
||||
c.bind "db.*", :error, @backend
|
||||
c.bind "duktape", :none, @backend
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -7,7 +7,7 @@ require "option_parser"
|
||||
require "clim"
|
||||
require "tallboy"
|
||||
|
||||
MANGO_VERSION = "0.25.0"
|
||||
MANGO_VERSION = "0.27.0"
|
||||
|
||||
# From http://www.network-science.de/ascii/
|
||||
BANNER = %{
|
||||
@ -61,6 +61,7 @@ class CLI < Clim
|
||||
Library.load_instance
|
||||
Library.default
|
||||
Plugin::Downloader.default
|
||||
Plugin::Updater.default
|
||||
|
||||
spawn do
|
||||
begin
|
||||
|
@ -2,6 +2,8 @@ require "duktape/runtime"
|
||||
require "myhtml"
|
||||
require "xml"
|
||||
|
||||
require "./subscriptions"
|
||||
|
||||
class Plugin
|
||||
class Error < ::Exception
|
||||
end
|
||||
@ -16,12 +18,19 @@ class Plugin
|
||||
end
|
||||
|
||||
struct Info
|
||||
include JSON::Serializable
|
||||
|
||||
{% for name in ["id", "title", "placeholder"] %}
|
||||
getter {{name.id}} = ""
|
||||
{% end %}
|
||||
getter wait_seconds : UInt64 = 0
|
||||
getter wait_seconds = 0u64
|
||||
getter version = 0u64
|
||||
getter settings = {} of String => String?
|
||||
getter dir : String
|
||||
|
||||
@[JSON::Field(ignore: true)]
|
||||
@json : JSON::Any
|
||||
|
||||
def initialize(@dir)
|
||||
info_path = File.join @dir, "info.json"
|
||||
|
||||
@ -37,6 +46,16 @@ class Plugin
|
||||
@{{name.id}} = @json[{{name}}].as_s
|
||||
{% end %}
|
||||
@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?
|
||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||
@ -86,9 +105,10 @@ class Plugin
|
||||
getter js_path = ""
|
||||
getter storage_path = ""
|
||||
|
||||
def self.build_info_ary
|
||||
def self.build_info_ary(dir : String? = nil)
|
||||
@@info_ary.clear
|
||||
dir = Config.current.plugin_path
|
||||
dir ||= Config.current.plugin_path
|
||||
|
||||
Dir.mkdir_p dir unless Dir.exists? dir
|
||||
|
||||
Dir.each_child dir do |f|
|
||||
@ -114,8 +134,35 @@ class Plugin
|
||||
@info.not_nil!
|
||||
end
|
||||
|
||||
def initialize(id : String)
|
||||
Plugin.build_info_ary
|
||||
def subscribe(subscription : Subscription)
|
||||
list = SubscriptionList.new info.dir
|
||||
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
|
||||
if @info.nil?
|
||||
@ -138,6 +185,12 @@ class Plugin
|
||||
sbx.push_string 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
|
||||
end
|
||||
|
||||
@ -152,9 +205,54 @@ class Plugin
|
||||
{% 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)
|
||||
json = eval_json "listChapters('#{query}')"
|
||||
begin
|
||||
if info.version > 1
|
||||
# Since v2, listChapters returns an array
|
||||
json.as_a.each do |obj|
|
||||
assert_chapter_type obj
|
||||
end
|
||||
else
|
||||
check_fields ["title", "chapters"]
|
||||
|
||||
ary = json["chapters"].as_a
|
||||
@ -168,7 +266,10 @@ class Plugin
|
||||
end
|
||||
|
||||
title = obj["title"]?
|
||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
||||
if title.nil?
|
||||
raise "Field `title` missing from `listChapters` outputs"
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
@ -179,11 +280,15 @@ class Plugin
|
||||
def select_chapter(id : String)
|
||||
json = eval_json "selectChapter('#{id}')"
|
||||
begin
|
||||
if info.version > 1
|
||||
assert_chapter_type json
|
||||
else
|
||||
check_fields ["title", "pages"]
|
||||
|
||||
if json["title"].to_s.empty?
|
||||
raise "The `title` field of the chapter can not be empty"
|
||||
end
|
||||
end
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
@ -194,14 +299,28 @@ class Plugin
|
||||
json = eval_json "nextPage()"
|
||||
return if json.size == 0
|
||||
begin
|
||||
check_fields ["filename", "url"]
|
||||
assert_page_type json
|
||||
rescue e
|
||||
raise Error.new e.message
|
||||
end
|
||||
json
|
||||
end
|
||||
|
||||
private def eval(str)
|
||||
def new_chapters(manga_id : String, after : Int64)
|
||||
# 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
|
||||
rescue e : Duktape::SyntaxError
|
||||
raise SyntaxError.new e.message
|
||||
@ -213,6 +332,15 @@ class Plugin
|
||||
JSON.parse eval(str).as String
|
||||
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)
|
||||
sbx.push_object
|
||||
|
||||
@ -321,9 +449,15 @@ class Plugin
|
||||
env = Duktape::Sandbox.new ptr
|
||||
html = env.require_string 0
|
||||
|
||||
str = XML.parse(html).inner_text
|
||||
begin
|
||||
parser = Myhtml::Parser.new html
|
||||
str = parser.body!.children.first.inner_text
|
||||
|
||||
env.push_string str
|
||||
rescue
|
||||
env.push_string ""
|
||||
end
|
||||
|
||||
env.call_success
|
||||
end
|
||||
sbx.put_prop_string -2, "text"
|
||||
@ -334,8 +468,9 @@ class Plugin
|
||||
name = env.require_string 1
|
||||
|
||||
begin
|
||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
||||
env.push_string attr
|
||||
parser = Myhtml::Parser.new html
|
||||
attr = parser.body!.children.first.attribute_by name
|
||||
env.push_string attr.not_nil!
|
||||
rescue
|
||||
env.push_undefined
|
||||
end
|
||||
@ -379,6 +514,27 @@ class Plugin
|
||||
end
|
||||
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"
|
||||
end
|
||||
end
|
||||
|
115
src/plugin/subscriptions.cr
Normal file
115
src/plugin/subscriptions.cr
Normal file
@ -0,0 +1,115 @@
|
||||
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
|
75
src/plugin/updater.cr
Normal file
75
src/plugin/updater.cr
Normal file
@ -0,0 +1,75 @@
|
||||
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,7 +70,13 @@ class Queue
|
||||
ary = @id.split("-")
|
||||
if ary.size == 2
|
||||
@plugin_id = ary[0]
|
||||
@plugin_chapter_id = ary[1]
|
||||
# This begin-rescue block is for backward compatibility. In earlier
|
||||
# versions we didn't encode the chapter ID
|
||||
@plugin_chapter_id = begin
|
||||
Base64.decode_string ary[1]
|
||||
rescue
|
||||
ary[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
require "sanitize"
|
||||
|
||||
struct AdminRouter
|
||||
def initialize
|
||||
get "/admin" do |env|
|
||||
@ -14,13 +16,13 @@ struct AdminRouter
|
||||
end
|
||||
|
||||
get "/admin/user/edit" do |env|
|
||||
username = env.params.query["username"]?
|
||||
sanitizer = Sanitize::Policy::Text.new
|
||||
username = env.params.query["username"]?.try { |s| sanitizer.process s }
|
||||
admin = env.params.query["admin"]?
|
||||
if admin
|
||||
admin = admin == "true"
|
||||
end
|
||||
error = env.params.query["error"]?
|
||||
current_user = get_username env
|
||||
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
||||
new_user = username.nil? && admin.nil?
|
||||
layout "user-edit"
|
||||
end
|
||||
@ -69,6 +71,10 @@ struct AdminRouter
|
||||
layout "download-manager"
|
||||
end
|
||||
|
||||
get "/admin/subscriptions" do |env|
|
||||
layout "subscription-manager"
|
||||
end
|
||||
|
||||
get "/admin/missing" do |env|
|
||||
layout "missing-items"
|
||||
end
|
||||
|
@ -40,14 +40,19 @@ struct APIRouter
|
||||
Koa.schema "entry", {
|
||||
"pages" => Int32,
|
||||
"mtime" => Int64,
|
||||
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
|
||||
}.merge(s %w(zip_path path title size id title_id display_name cover_url)),
|
||||
desc: "An entry in a book"
|
||||
|
||||
Koa.schema "title", {
|
||||
"mtime" => Int64,
|
||||
"entries" => ["entry"],
|
||||
"titles" => ["title"],
|
||||
"parents" => [String],
|
||||
"parents" => [{
|
||||
"title" => String,
|
||||
"id" => String,
|
||||
}],
|
||||
"title_percentages" => [Float64?],
|
||||
"entry_percentages" => [Float64?],
|
||||
}.merge(s %w(dir title id display_name cover_url)),
|
||||
desc: "A manga title (a collection of entries and sub-titles)"
|
||||
|
||||
@ -56,6 +61,23 @@ struct APIRouter
|
||||
"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
|
||||
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
||||
MD
|
||||
@ -63,6 +85,12 @@ struct APIRouter
|
||||
"username" => String,
|
||||
"password" => String,
|
||||
}
|
||||
Koa.response 200, schema: {
|
||||
"success" => Bool,
|
||||
"error" => String?,
|
||||
"session_id" => String?,
|
||||
"is_admin" => Bool?,
|
||||
}
|
||||
Koa.tag "users"
|
||||
post "/api/login" do |env|
|
||||
begin
|
||||
@ -71,11 +99,18 @@ struct APIRouter
|
||||
token = Storage.default.verify_user(username, password).not_nil!
|
||||
|
||||
env.session.string "token", token
|
||||
"Authenticated"
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"session_id" => env.session.id,
|
||||
"is_admin" => Storage.default.username_is_admin username,
|
||||
}.to_json
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 403
|
||||
e.message
|
||||
send_json env, {
|
||||
"success" => false,
|
||||
"error" => e.message,
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@ -107,14 +142,19 @@ struct APIRouter
|
||||
env.response.status_code = 304
|
||||
""
|
||||
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["Cache-Control"] = "public, max-age=86400"
|
||||
env.response.headers["Cache-Control"] = cache_control
|
||||
send_img env, img
|
||||
end
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 500
|
||||
e.message
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
@ -151,11 +191,13 @@ struct APIRouter
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 500
|
||||
e.message
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
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 `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
|
||||
@ -166,8 +208,7 @@ struct APIRouter
|
||||
Koa.path "tid", desc: "Title ID"
|
||||
Koa.query "slim"
|
||||
Koa.query "depth"
|
||||
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.query "percentage"
|
||||
Koa.response 200, schema: "title"
|
||||
Koa.response 404, "Title not found"
|
||||
Koa.tag "library"
|
||||
@ -175,29 +216,104 @@ struct APIRouter
|
||||
begin
|
||||
username = get_username env
|
||||
|
||||
sort_opt = SortOptions.new
|
||||
get_sort_opt
|
||||
|
||||
tid = env.params.url["tid"]
|
||||
title = Library.default.get_title tid
|
||||
raise "Title ID `#{tid}` not found" if title.nil?
|
||||
|
||||
sort_opt = SortOptions.from_info_json title.dir, username
|
||||
|
||||
slim = !env.params.query["slim"]?.nil?
|
||||
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,
|
||||
sort_context: {username: username,
|
||||
opt: sort_opt})
|
||||
opt: sort_opt}, percentage: percentage)
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 404
|
||||
e.message
|
||||
send_text env, e.message
|
||||
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
|
||||
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 `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 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
|
||||
@ -205,16 +321,162 @@ struct APIRouter
|
||||
MD
|
||||
Koa.query "slim"
|
||||
Koa.query "depth"
|
||||
Koa.query "percentage"
|
||||
Koa.response 200, schema: {
|
||||
"dir" => String,
|
||||
"titles" => ["title"],
|
||||
"title_percentage" => [Float64?],
|
||||
}
|
||||
Koa.tag "library"
|
||||
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?
|
||||
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
|
||||
|
||||
Koa.describe "Triggers a library scan"
|
||||
@ -250,6 +512,7 @@ struct APIRouter
|
||||
spawn do
|
||||
Library.default.generate_thumbnails
|
||||
end
|
||||
send_text env, ""
|
||||
end
|
||||
|
||||
Koa.describe "Deletes a user with `username`"
|
||||
@ -567,6 +830,211 @@ struct APIRouter
|
||||
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.tags ["admin", "downloader"]
|
||||
Koa.query "plugin", schema: String
|
||||
@ -576,7 +1044,7 @@ struct APIRouter
|
||||
"error" => String?,
|
||||
"chapters?" => [{
|
||||
"id" => String,
|
||||
"title" => String,
|
||||
"title?" => String,
|
||||
}],
|
||||
"title" => String?,
|
||||
}
|
||||
@ -586,8 +1054,14 @@ struct APIRouter
|
||||
plugin = Plugin.new env.params.query["plugin"].as String
|
||||
|
||||
json = plugin.list_chapters query
|
||||
|
||||
if plugin.info.version == 1
|
||||
chapters = json["chapters"]
|
||||
title = json["title"]
|
||||
else
|
||||
chapters = json
|
||||
title = nil
|
||||
end
|
||||
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
@ -625,7 +1099,7 @@ struct APIRouter
|
||||
|
||||
jobs = chapters.map { |ch|
|
||||
Queue::Job.new(
|
||||
"#{plugin.info.id}-#{ch["id"]}",
|
||||
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
||||
"", # manga_id
|
||||
ch["title"].as_s,
|
||||
manga_title,
|
||||
@ -671,15 +1145,24 @@ struct APIRouter
|
||||
entry = title.get_entry eid
|
||||
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
|
||||
|
||||
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
|
||||
if entry.is_a? DirEntry
|
||||
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}"
|
||||
if e_tag == prev_e_tag
|
||||
env.response.status_code = 304
|
||||
""
|
||||
send_text env, ""
|
||||
else
|
||||
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["Cache-Control"] = "public, max-age=86400"
|
||||
env.response.headers["Cache-Control"] = cache_control
|
||||
send_json env, {
|
||||
"success" => true,
|
||||
"dimensions" => sizes,
|
||||
@ -705,10 +1188,11 @@ struct APIRouter
|
||||
title = (Library.default.get_title env.params.url["tid"]).not_nil!
|
||||
entry = (title.get_entry env.params.url["eid"]).not_nil!
|
||||
|
||||
send_attachment env, entry.zip_path
|
||||
send_attachment env, entry.path
|
||||
rescue e
|
||||
Logger.error e
|
||||
env.response.status_code = 404
|
||||
send_text env, e.message
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -80,16 +80,6 @@ struct MainRouter
|
||||
|
||||
get "/download/plugins" do |env|
|
||||
begin
|
||||
id = env.params.query["plugin"]?
|
||||
plugins = Plugin.list
|
||||
plugin = nil
|
||||
|
||||
if id
|
||||
plugin = Plugin.new id
|
||||
elsif !plugins.empty?
|
||||
plugin = Plugin.new plugins[0][:id]
|
||||
end
|
||||
|
||||
layout "plugin-download"
|
||||
rescue e
|
||||
Logger.error e
|
||||
|
@ -53,6 +53,7 @@ struct ReaderRouter
|
||||
render "src/views/reader.html.ecr"
|
||||
rescue e
|
||||
Logger.error e
|
||||
Logger.debug e.backtrace?
|
||||
env.response.status_code = 404
|
||||
end
|
||||
end
|
||||
|
@ -25,6 +25,17 @@ class Server
|
||||
APIRouter.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
|
||||
add_handler LogHandler.new
|
||||
add_handler AuthHandler.new
|
||||
|
@ -1,83 +0,0 @@
|
||||
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
|
||||
# a file/folder rename, with no library scan in between.
|
||||
def self.signature(filename) : UInt64
|
||||
if is_supported_file filename
|
||||
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
|
||||
File.info(filename).inode
|
||||
else
|
||||
0u64
|
||||
@ -67,7 +67,9 @@ class Dir
|
||||
else
|
||||
# Only add its signature value to `signatures` when it is a
|
||||
# supported file
|
||||
signatures << fn if is_supported_file fn
|
||||
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
|
||||
signatures << fn
|
||||
end
|
||||
end
|
||||
Fiber.yield
|
||||
end
|
||||
@ -76,4 +78,19 @@ class Dir
|
||||
cache[dirname] = hash
|
||||
hash
|
||||
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
|
||||
|
@ -1,8 +1,19 @@
|
||||
IMGS_PER_PAGE = 5
|
||||
ENTRIES_IN_HOME_SECTIONS = 8
|
||||
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_IMG_TYPES = %w(
|
||||
image/jpeg
|
||||
image/png
|
||||
image/webp
|
||||
image/apng
|
||||
image/avif
|
||||
image/gif
|
||||
image/svg+xml
|
||||
image/jxl
|
||||
)
|
||||
|
||||
def random_str
|
||||
UUID.random.to_s.gsub "-", ""
|
||||
@ -40,6 +51,7 @@ def register_mime_types
|
||||
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||
".apng" => "image/apng",
|
||||
".avif" => "image/avif",
|
||||
".jxl" => "image/jxl",
|
||||
}.each do |k, v|
|
||||
MIME.register k, v
|
||||
end
|
||||
@ -49,6 +61,10 @@ def is_supported_file(path)
|
||||
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||
end
|
||||
|
||||
def is_supported_image_file(path)
|
||||
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
|
||||
end
|
||||
|
||||
struct Int
|
||||
def or(other : Int)
|
||||
if self == 0
|
||||
@ -80,9 +96,9 @@ class String
|
||||
end
|
||||
end
|
||||
|
||||
def env_is_true?(key : String) : Bool
|
||||
def env_is_true?(key : String, default : Bool = false) : Bool
|
||||
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||
return false unless val
|
||||
return default unless val
|
||||
val.downcase.in? "1", "true"
|
||||
end
|
||||
|
||||
|
@ -39,13 +39,28 @@ macro send_error_page(msg)
|
||||
end
|
||||
|
||||
macro send_img(env, img)
|
||||
cors
|
||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||
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)
|
||||
begin
|
||||
token = env.session.string "token"
|
||||
(Storage.default.verify_token token).not_nil!
|
||||
# Check if we can get the session id from the cookie
|
||||
token = env.session.string? "token"
|
||||
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
|
||||
if Config.current.disable_login
|
||||
Config.current.default_username
|
||||
@ -57,12 +72,29 @@ macro get_username(env)
|
||||
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)
|
||||
cors
|
||||
env.response.content_type = "application/json"
|
||||
env.response.print json
|
||||
end
|
||||
|
||||
def send_text(env, text)
|
||||
cors
|
||||
env.response.content_type = "text/plain"
|
||||
env.response.print text
|
||||
end
|
||||
|
||||
def send_attachment(env, path)
|
||||
cors
|
||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||
end
|
||||
|
||||
|
@ -40,5 +40,6 @@
|
||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/admin.js"></script>
|
||||
<% end %>
|
||||
|
@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||
<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://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||
|
@ -1,162 +0,0 @@
|
||||
<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,6 +19,7 @@
|
||||
<ul class="uk-nav-sub">
|
||||
<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/subscriptions">Subscription Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
@ -36,7 +37,7 @@
|
||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||
</div>
|
||||
<div class="uk-navbar-left uk-visible@m">
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png" style="width:90px;height:90px;"></a>
|
||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icons/icon.png" style="width:90px;height:90px;"></a>
|
||||
<ul class="uk-navbar-nav">
|
||||
<li><a href="<%= base_url %>">Home</a></li>
|
||||
<li><a href="<%= base_url %>library">Library</a></li>
|
||||
@ -51,6 +52,7 @@
|
||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||
<li class="uk-nav-divider"></li>
|
||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
|
@ -1,39 +0,0 @@
|
||||
<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/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.zip_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.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="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
|
||||
|
@ -1,37 +1,42 @@
|
||||
<% if plugins.empty? %>
|
||||
<div class="uk-container uk-text-center">
|
||||
<div x-data="component()" x-init="init()" x-cloak>
|
||||
<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>
|
||||
|
||||
<% else %>
|
||||
<h2 class=uk-title>Download with Plugins</h2>
|
||||
<div x-show="plugins.length > 0" style="width:100%">
|
||||
<h2 class=uk-title>Download with Plugins
|
||||
<span x-show="searching" uk-spinner class="uk-margin-left"></span>
|
||||
</h2>
|
||||
|
||||
<div id="controls" class="uk-grid-small" uk-grid hidden>
|
||||
<template x-if="info !== undefined">
|
||||
<div>
|
||||
<div class="uk-grid-small" uk-grid>
|
||||
<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 %>">
|
||||
<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" for="plugin-select">Choose a plugin</label>
|
||||
<label class="uk-form-label">Choose a plugin</label>
|
||||
<div class="uk-form-controls">
|
||||
<select id="plugin-select" class="uk-select">
|
||||
<% plugins.each do |p| %>
|
||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
||||
<% end %>
|
||||
<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" for="search-input"> </label>
|
||||
<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>
|
||||
@ -39,39 +44,173 @@
|
||||
</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>
|
||||
<% plugin.not_nil!.info.each do |k, v| %>
|
||||
<dt><%= k %></dt>
|
||||
<dd><%= v.to_s %></dd>
|
||||
<% end %>
|
||||
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
|
||||
<dd x-text="entry[1]"></dd>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="table" class="uk-margin-large-top" hidden>
|
||||
<h3 id="title-text"></h3>
|
||||
<template x-if="manga">
|
||||
<div class="uk-margin">
|
||||
<p x-show="manga.length === 0">No matching manga found.</p>
|
||||
<p x-show="manga.length > 0">
|
||||
<span x-text="`${manga.length} manga found`"></span>
|
||||
<span :uk-icon="listManga ? 'chevron-down' : 'chevron-right'" @click="listManga = !listManga"></span>
|
||||
</p>
|
||||
|
||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid x-show="listManga">
|
||||
<template x-for="m in manga" :key="m.id">
|
||||
<div class="item" :data-id="m.id" @click="mangaSelected($event)">
|
||||
<div class="uk-card uk-card-default">
|
||||
<div class="uk-card-media-top uk-inline">
|
||||
<img uk-img :data-src="m.cover_url">
|
||||
</div>
|
||||
<div class="uk-card-body">
|
||||
<h3 class="uk-card-title break-word uk-margin-remove-bottom free-height" x-text="m.title"></h3>
|
||||
<p class="uk-text-meta" x-text="`ID: ${m.id}`"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="uk-margin-large-top" x-show="chapters !== undefined">
|
||||
<h3 x-text="mangaTitle"></h3>
|
||||
<p x-text="`${chapters ? chapters.length : 0} chapters found`"></p>
|
||||
|
||||
<div class="uk-margin">
|
||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
||||
<div 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 tablesorter">
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div uk-modal="container:false" x-ref="modal">
|
||||
<div class="uk-modal-dialog">
|
||||
<div class="uk-modal-header">
|
||||
<h2 class="uk-modal-title">Subscription Confirmation</h2>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="ft in filterSettings" :key="ft">
|
||||
<tr x-html="renderFilterRow(ft)"></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</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>
|
||||
|
||||
<% content_for "script" do %>
|
||||
<% if plugin %>
|
||||
<script>
|
||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
||||
</script>
|
||||
<% end %>
|
||||
<%= render_component "jquery-ui" %>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
||||
<%= render_component "moment" %>
|
||||
<script src="<%= base_url %>js/alert.js"></script>
|
||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||
<% end %>
|
||||
|
@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
|
||||
</div>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p>
|
||||
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
|
@ -5,7 +5,7 @@
|
||||
<%= render_component "head" %>
|
||||
|
||||
<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'">
|
||||
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'">
|
||||
|
||||
<div @keydown.window.debounce="keyHandler($event)"></div>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
|
||||
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;">
|
||||
<div x-show="!loading && mode === 'continuous'" x-cloak>
|
||||
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
|
||||
<img
|
||||
@ -30,7 +30,7 @@
|
||||
:height="item.height"
|
||||
:id="item.id"
|
||||
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
|
||||
@click="showControl($event)"
|
||||
@click="clickImage($event)"
|
||||
/>
|
||||
</template>
|
||||
<%- if next_entry_url -%>
|
||||
@ -40,18 +40,18 @@
|
||||
<%- end -%>
|
||||
</div>
|
||||
|
||||
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
|
||||
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`">
|
||||
|
||||
<img uk-img :class="{
|
||||
'uk-align-center': true,
|
||||
'uk-animation-slide-left': flipAnimation === 'left',
|
||||
'uk-animation-slide-right': flipAnimation === 'right'
|
||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
|
||||
width:${mode === 'width' ? '100vw' : 'auto'};
|
||||
height:${mode === 'height' ? '100vh' : 'auto'};
|
||||
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
|
||||
width:${fitType === 'horz' ? '100vw' : 'auto'};
|
||||
height:${fitType === 'vert' ? '100vh' : 'auto'};
|
||||
margin-bottom:0;
|
||||
max-width:100%;
|
||||
max-height:100%;
|
||||
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
|
||||
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
|
||||
object-fit: contain;
|
||||
`" />
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||
<div class="uk-modal-header">
|
||||
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
|
||||
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p>
|
||||
</div>
|
||||
<div class="uk-modal-body">
|
||||
<div class="uk-margin">
|
||||
@ -94,6 +94,17 @@
|
||||
</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'">
|
||||
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
|
||||
<div class="uk-form-controls">
|
||||
|
101
src/views/subscription-manager.html.ecr
Normal file
101
src/views/subscription-manager.html.ecr
Normal file
@ -0,0 +1,101 @@
|
||||
<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 %>
|
@ -1,54 +0,0 @@
|
||||
<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