diff --git a/README.md b/README.md
index 53cea050..6c34c3a9 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,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.26.0
Usage:
@@ -94,9 +94,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`.
diff --git a/gulpfile.js b/gulpfile.js
index 4496b8c0..b1634e6d 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -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'
], {
diff --git a/public/img/icon.png b/public/img/icons/icon.png
similarity index 100%
rename from public/img/icon.png
rename to public/img/icons/icon.png
diff --git a/public/img/icons/icon_x192.png b/public/img/icons/icon_x192.png
new file mode 100644
index 00000000..1d3bfeae
Binary files /dev/null and b/public/img/icons/icon_x192.png differ
diff --git a/public/img/icons/icon_x512.png b/public/img/icons/icon_x512.png
new file mode 100644
index 00000000..ef62dfb9
Binary files /dev/null and b/public/img/icons/icon_x512.png differ
diff --git a/public/img/icons/icon_x96.png b/public/img/icons/icon_x96.png
new file mode 100644
index 00000000..e1b1158d
Binary files /dev/null and b/public/img/icons/icon_x96.png differ
diff --git a/public/js/admin.js b/public/js/admin.js
index a3299cc4..57926b1e 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -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;
});
diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js
index 11c047ce..2e9d0a0b 100644
--- a/public/js/plugin-download.js
+++ b/public/js/plugin-download.js
@@ -1,144 +1,446 @@
-const loadPlugin = id => {
- localStorage.setItem('plugin', id);
- const url = `${location.protocol}//${location.host}${location.pathname}`;
- const newURL = `${url}?${$.param({
- plugin: id
- })}`;
- window.location.href = newURL;
-};
+const component = () => {
+ return {
+ plugins: [],
+ info: undefined,
+ pid: undefined,
+ chapters: undefined, // undefined: not searched yet, []: empty
+ manga: undefined, // undefined: not searched yet, []: empty
+ 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);
+ })
+ .catch((e) => {
+ alert(
+ "danger",
+ `Failed to list the available plugins. Error: ${e}`
+ );
+ });
+ },
+ loadPlugin(pid) {
+ fetch(
+ `${base_url}api/admin/plugin/info?${new URLSearchParams({
+ plugin: pid,
+ })}`
+ )
+ .then((res) => res.json())
+ .then((data) => {
+ if (!data.success) throw new Error(data.error);
+ this.info = data.info;
+ this.pid = pid;
+ })
+ .catch((e) => {
+ alert(
+ "danger",
+ `Failed to get plugin metadata. Error: ${e}`
+ );
+ });
+ },
+ pluginChanged() {
+ this.loadPlugin(this.pid);
+ localStorage.setItem("plugin", this.pid);
+ },
+ get chapterKeys() {
+ if (this.allChapters.length < 1) return [];
+ return Object.keys(this.allChapters[0]).filter(
+ (k) => !["manga_title"].includes(k)
+ );
+ },
+ searchChapters(query) {
+ this.searching = true;
+ this.allChapters = [];
+ this.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;
+ 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 download manager page .`
+ );
+ })
+ .catch((e) => {
+ alert(
+ "danger",
+ `Failed to add chapters to the download queue. Error: ${e}`
+ );
+ })
+ .finally(() => {
+ this.adding = false;
+ });
+ });
+ },
+ thClicked(event) {
+ const idx = parseInt(event.currentTarget.id.split("-")[1]);
+ if (idx === undefined || isNaN(idx)) return;
+ const curOption = this.sortOptions[idx];
+ let option;
+ this.sortOptions = [];
+ switch (curOption) {
+ case 1:
+ option = -1;
+ break;
+ case -1:
+ option = 0;
+ break;
+ default:
+ option = 1;
+ }
+ this.sortOptions[idx] = option;
+ this.sort(this.chapterKeys[idx], option);
+ },
+ // Returns an array of filtered but unsorted chapters. Useful when
+ // reseting the sort options.
+ get filteredChapters() {
+ let ary = this.allChapters.slice();
+
+ console.log("initial size:", ary.length);
+ for (let filter of this.appliedFilters) {
+ if (!filter.value) continue;
+ if (filter.type === "array" && filter.value === "all") continue;
+ 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);
+ }
-$(() => {
- 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 ary;
+ },
+ // option:
+ // - 1: asending
+ // - -1: desending
+ // - 0: unsorted
+ sort(key, option) {
+ if (option === 0) {
+ this.chapters = this.filteredChapters;
return;
}
- mangaTitle = data.title;
- $('#title-text').text(data.title);
- buildTable(data.chapters);
- })
- .fail((jqXHR, status) => {
- alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
- })
- .always(() => {});
-};
-const buildTable = (chapters) => {
- $('#table').attr('hidden', '');
- $('table').empty();
+ 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;
- const keys = Object.keys(chapters[0]).map(k => `
${k} `).join('');
- const thead = `${keys} `;
- $('table').append(thead);
+ // try numbers (also covers dates)
+ if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
- const rows = chapters.map(ch => {
- const tds = Object.values(ch).map(v => {
- const maxLength = 40;
- const shouldShrink = v && v.length > maxLength;
- const content = shouldShrink ? `${v.substring(0, maxLength)}... ${v}
` : v;
- return `${content} `
- }).join('');
- return `${tds} `;
- });
- const tbody = `${rows} `;
- $('table').append(tbody);
-
- $('#selectable').selectable({
- filter: 'tr'
- });
-
- $('#table table').tablesorter();
- $('#table').removeAttr('hidden');
-};
+ const preprocessString = (val) => {
+ if (typeof val !== "string") return val;
+ return val.toLowerCase().replace(/\s\s/g, " ").trim();
+ };
-const selectAll = () => {
- $('tbody > tr').each((i, e) => {
- $(e).addClass('ui-selected');
- });
-};
+ 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);
-const unselect = () => {
- $('tbody > tr').each((i, e) => {
- $(e).removeClass('ui-selected');
- });
-};
+ 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();
+ })
+ )
+ );
+ }
-const download = () => {
- const selected = $('tbody > tr.ui-selected');
- if (selected.length === 0) return;
- UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
- $('#download-btn').attr('hidden', '');
- $('#download-spinner').removeAttr('hidden');
- const chapters = selected.map((i, e) => {
- return {
- id: $(e).attr('data-id'),
- title: $(e).attr('data-title')
- }
- }).get();
- console.log(chapters);
- $.ajax({
- type: 'POST',
- url: base_url + 'api/admin/plugin/download',
- data: JSON.stringify({
- plugin: pid,
- chapters: chapters,
- title: mangaTitle
+ 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,
}),
- contentType: "application/json",
- dataType: 'json'
- })
- .done(data => {
- console.log(data);
- if (data.error) {
- alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
- return;
- }
- const successCount = parseInt(data.success);
- const failCount = parseInt(data.fail);
- alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the download manager page .`);
- })
- .fail((jqXHR, status) => {
- alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
+ headers: {
+ "Content-Type": "application/json",
+ },
})
- .always(() => {
- $('#download-spinner').attr('hidden', '');
- $('#download-btn').removeAttr('hidden');
- });
- });
+ .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 `${moment(Number(value)).format(
+ "MMM D, YYYY"
+ )} `;
+ const maxLength = 40;
+ if (value && value.length > maxLength)
+ return `${value.substr(
+ 0,
+ maxLength
+ )}... ${value}
`;
+ return `${value} `;
+ },
+ 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 `${key} ${type} ${value} `;
+ },
+ };
};
diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js
new file mode 100644
index 00000000..fad4e56c
--- /dev/null
+++ b/public/js/subscription-manager.js
@@ -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 `${str.substring(
+ 0,
+ maxLength
+ )}... ${str}
`;
+ return `${str} `;
+ },
+ renderDateCell(timestamp) {
+ return `${moment
+ .duration(moment.unix(timestamp).diff(moment()))
+ .humanize(true)} `;
+ },
+ 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 `${key} ${type} ${value} `;
+ },
+ 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);
+ });
+ },
+ };
+};
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 00000000..b52119a1
--- /dev/null
+++ b/public/manifest.json
@@ -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": "/"
+}
diff --git a/shard.lock b/shard.lock
index df6fc4c4..292a16fc 100644
--- a/shard.lock
+++ b/shard.lock
@@ -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
diff --git a/shard.yml b/shard.yml
index 44a09242..14a49aa5 100644
--- a/shard.yml
+++ b/shard.yml
@@ -1,5 +1,5 @@
name: mango
-version: 0.25.0
+version: 0.26.0
authors:
- Alex Ling
diff --git a/src/config.cr b/src/config.cr
index b5b77dbf..807a74cb 100644
--- a/src/config.cr
+++ b/src/config.cr
@@ -25,6 +25,7 @@ class Config
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
+ property plugin_update_interval_hours : Int32 = 24
@@singlet : Config?
diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr
index bf79dc38..26a149ae 100644
--- a/src/handlers/auth_handler.cr
+++ b/src/handlers/auth_handler.cr
@@ -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,8 +19,14 @@ class AuthHandler < Kemal::Handler
end
def require_auth(env)
- env.session.string "callback", env.request.path
- redirect env, "/login"
+ 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)
@@ -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
diff --git a/src/handlers/cors_handler.cr b/src/handlers/cors_handler.cr
new file mode 100644
index 00000000..d199b129
--- /dev/null
+++ b/src/handlers/cors_handler.cr
@@ -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
diff --git a/src/library/entry.cr b/src/library/entry.cr
index ceaa5311..dd50ed34 100644
--- a/src/library/entry.cr
+++ b/src/library/entry.cr
@@ -55,10 +55,14 @@ class Entry
def build_json(*, slim = false)
JSON.build do |json|
json.object do
- {% for str in ["zip_path", "title", "size", "id"] %}
+ {% for str in %w(zip_path title size id) %}
json.field {{str}}, @{{str.id}}
{% end %}
+ if err_msg
+ json.field "err_msg", err_msg
+ end
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
@@ -108,7 +112,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|
@@ -144,13 +148,17 @@ class Entry
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
+ 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
diff --git a/src/library/library.cr b/src/library/library.cr
index 210912f4..2da4c864 100644
--- a/src/library/library.cr
+++ b/src/library/library.cr
@@ -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
diff --git a/src/library/title.cr b/src/library/title.cr
index 539f114f..e3d79d55 100644
--- a/src/library/title.cr
+++ b/src/library/title.cr
@@ -202,7 +202,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 +232,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 +439,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
diff --git a/src/library/types.cr b/src/library/types.cr
index 4c9dc937..973aa5ea 100644
--- a/src/library/types.cr
+++ b/src/library/types.cr
@@ -55,6 +55,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
diff --git a/src/mango.cr b/src/mango.cr
index 3cdafc05..e57750d4 100644
--- a/src/mango.cr
+++ b/src/mango.cr
@@ -7,7 +7,7 @@ require "option_parser"
require "clim"
require "tallboy"
-MANGO_VERSION = "0.25.0"
+MANGO_VERSION = "0.26.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
diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr
index 6bedea17..5175b3a0 100644
--- a/src/plugin/plugin.cr
+++ b/src/plugin/plugin.cr
@@ -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 " \
@@ -114,6 +133,33 @@ class Plugin
@info.not_nil!
end
+ def subscribe(subscription : Subscription)
+ list = SubscriptionList.new info.dir
+ list << subscription
+ list.save
+ end
+
+ def list_subscriptions
+ SubscriptionList.new(info.dir).ary
+ end
+
+ def 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)
Plugin.build_info_ary
@@ -138,6 +184,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,23 +204,67 @@ 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 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
- check_fields ["title", "chapters"]
-
- ary = json["chapters"].as_a
- ary.each do |obj|
- id = obj["id"]?
- raise "Field `id` missing from `listChapters` outputs" if id.nil?
-
- unless id.to_s.alphanumeric_underscore?
- raise "The `id` field can only contain alphanumeric characters " \
- "and underscores"
+ 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
+ ary.each do |obj|
+ id = obj["id"]?
+ raise "Field `id` missing from `listChapters` outputs" if id.nil?
+
+ unless id.to_s.alphanumeric_underscore?
+ raise "The `id` field can only contain alphanumeric characters " \
+ "and underscores"
+ end
+
+ title = obj["title"]?
+ if title.nil?
+ raise "Field `title` missing from `listChapters` outputs"
+ end
end
-
- title = obj["title"]?
- raise "Field `title` missing from `listChapters` outputs" if title.nil?
end
rescue e
raise Error.new e.message
@@ -179,10 +275,14 @@ class Plugin
def select_chapter(id : String)
json = eval_json "selectChapter('#{id}')"
begin
- check_fields ["title", "pages"]
+ 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"
+ 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
@@ -194,7 +294,21 @@ 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
+
+ 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
@@ -379,6 +493,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
diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr
new file mode 100644
index 00000000..153667d7
--- /dev/null
+++ b/src/plugin/subscriptions.cr
@@ -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
diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr
new file mode 100644
index 00000000..81ba8c89
--- /dev/null
+++ b/src/plugin/updater.cr
@@ -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
diff --git a/src/queue.cr b/src/queue.cr
index 01cef38c..82fdedac 100644
--- a/src/queue.cr
+++ b/src/queue.cr
@@ -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
diff --git a/src/routes/admin.cr b/src/routes/admin.cr
index a63bc0eb..c3692c99 100644
--- a/src/routes/admin.cr
+++ b/src/routes/admin.cr
@@ -69,6 +69,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
diff --git a/src/routes/api.cr b/src/routes/api.cr
index 413c318b..e664b281 100644
--- a/src/routes/api.cr
+++ b/src/routes/api.cr
@@ -47,7 +47,12 @@ struct APIRouter
"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
@@ -114,7 +149,7 @@ struct APIRouter
rescue e
Logger.error e
env.response.status_code = 500
- e.message
+ send_text env, e.message
end
end
@@ -151,11 +186,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 +203,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 +211,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 +316,162 @@ struct APIRouter
MD
Koa.query "slim"
Koa.query "depth"
+ Koa.query "percentage"
Koa.response 200, schema: {
- "dir" => String,
- "titles" => ["title"],
+ "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,
+ 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
- send_json env, Library.default.build_json(slim: slim, depth: depth)
+ 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 +507,7 @@ struct APIRouter
spawn do
Library.default.generate_thumbnails
end
+ send_text env, ""
end
Koa.describe "Deletes a user with `username`"
@@ -567,6 +825,209 @@ 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,
+ },
+ }
+ get "/api/admin/plugin/info" do |env|
+ begin
+ plugin = Plugin.new env.params.query["plugin"].as String
+ send_json env, {
+ "success" => true,
+ "info" => plugin.info,
+ }.to_json
+ rescue e
+ Logger.error e
+ send_json env, {
+ "success" => false,
+ "error" => e.message,
+ }.to_json
+ end
+ end
+
+ Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
+ Only available for plugins targeting API v2 or above.
+ MD
+ Koa.tags ["admin", "downloader"]
+ Koa.query "plugin", schema: String
+ Koa.query "query", schema: String
+ Koa.response 200, schema: {
+ "success" => Bool,
+ "error" => String?,
+ "manga" => [{
+ "id" => String,
+ "title" => String,
+ }],
+ }
+ get "/api/admin/plugin/search" do |env|
+ begin
+ query = env.params.query["query"].as String
+ plugin = Plugin.new env.params.query["plugin"].as String
+
+ manga_ary = plugin.search_manga(query).as_a
+ send_json env, {
+ "success" => true,
+ "manga" => manga_ary,
+ }.to_json
+ rescue e
+ Logger.error e
+ send_json env, {
+ "success" => false,
+ "error" => e.message,
+ }.to_json
+ end
+ end
+
+ Koa.describe "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
@@ -575,8 +1036,8 @@ struct APIRouter
"success" => Bool,
"error" => String?,
"chapters?" => [{
- "id" => String,
- "title" => String,
+ "id" => String,
+ "title?" => String,
}],
"title" => String?,
}
@@ -586,8 +1047,14 @@ struct APIRouter
plugin = Plugin.new env.params.query["plugin"].as String
json = plugin.list_chapters query
- chapters = json["chapters"]
- title = json["title"]
+
+ if plugin.info.version == 1
+ chapters = json["chapters"]
+ title = json["title"]
+ else
+ chapters = json
+ title = nil
+ end
send_json env, {
"success" => true,
@@ -625,7 +1092,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,
@@ -675,7 +1142,7 @@ struct APIRouter
e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag
env.response.status_code = 304
- ""
+ send_text env, ""
else
sizes = entry.page_dimensions
env.response.headers["ETag"] = e_tag
@@ -709,6 +1176,7 @@ struct APIRouter
rescue e
Logger.error e
env.response.status_code = 404
+ send_text env, e.message
end
end
diff --git a/src/routes/main.cr b/src/routes/main.cr
index ea2f0d8c..0e7bf341 100644
--- a/src/routes/main.cr
+++ b/src/routes/main.cr
@@ -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
diff --git a/src/server.cr b/src/server.cr
index e8dc54b0..b0a022d4 100644
--- a/src/server.cr
+++ b/src/server.cr
@@ -23,7 +23,17 @@ class Server
AdminRouter.new
ReaderRouter.new
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
diff --git a/src/subscription.cr b/src/subscription.cr
deleted file mode 100644
index e3913601..00000000
--- a/src/subscription.cr
+++ /dev/null
@@ -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
diff --git a/src/util/web.cr b/src/util/web.cr
index 5704ea88..366af041 100644
--- a/src/util/web.cr
+++ b/src/util/web.cr
@@ -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
diff --git a/src/views/admin.html.ecr b/src/views/admin.html.ecr
index fb64d3ea..47eead85 100644
--- a/src/views/admin.html.ecr
+++ b/src/views/admin.html.ecr
@@ -40,5 +40,6 @@
Log Out
<% content_for "script" do %>
+
<% end %>
diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr
index 2126ab56..abd5af51 100644
--- a/src/views/components/head.html.ecr
+++ b/src/views/components/head.html.ecr
@@ -6,6 +6,7 @@
+
diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr
deleted file mode 100644
index 0ea85276..00000000
--- a/src/views/download.html.ecr
+++ /dev/null
@@ -1,162 +0,0 @@
-Download from MangaDex
-
-
-
-
-
-
No matching manga found.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Select All
-
Clear Selections
-
Download Selected
-
-
-
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.
-
-
-
-
-
- ID
- Title
- Language
- Group
- Volume
- Chapter
- Timestamp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
- <%= render_component "jquery-ui" %>
-
-
-<% end %>
diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr
index c32bfb5b..7264ba74 100644
--- a/src/views/layout.html.ecr
+++ b/src/views/layout.html.ecr
@@ -19,6 +19,7 @@
<% end %>
@@ -36,7 +37,7 @@
-
+
@@ -78,7 +80,7 @@
<%= render_component "uikit" %>
<%= yield_content "script" %>
diff --git a/src/views/mangadex.html.ecr b/src/views/mangadex.html.ecr
deleted file mode 100644
index 764c4f4d..00000000
--- a/src/views/mangadex.html.ecr
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
Connect to MangaDex
-
-
-
This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:
-
- Search MangaDex by search terms in addition to manga IDs
- Automatically download new chapters when they are available (coming soon)
-
-
-
-
-
- You have logged in to MangaDex!
- You have logged in to MangaDex but the token has expired.
- The expiration date of your token is
.
- If the integration is not working, you
- You
- can log in again and the token will be updated.
-
-
-
-
-
-
-
Login to MangaDex
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
-
-
-<% end %>
diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr
index ece56b6f..7c3b4d55 100644
--- a/src/views/plugin-download.html.ecr
+++ b/src/views/plugin-download.html.ecr
@@ -1,77 +1,214 @@
-<% if plugins.empty? %>
-
-
No Plugins Found
-
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
-
You can download official plugins from the Mango plugins repository .
-
+
+
+
+
No Plugins Found
+
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
+
You can download official plugins from the Mango plugins repository .
+
-<% else %>
-
Download with Plugins
+
+
Download with Plugins
+
+
-
-
-
-
-
-
-
-
- <% plugin.not_nil!.info.each do |k, v| %>
- <%= k %>
- <%= v.to_s %>
- <% end %>
-
-
-
-
-
-
-
Select All
-
Clear Selections
-
Download Selected
-
-
-
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.
-
-
+
+
+
+
+
A subscription with the following filters with be created. All FUTURE chapters matching the filters will be automatically downloaded.
+
+
+
+ Key
+ Type
+ Value
+
+
+
+
+
+
+
+
+
Enter a meaningful name for the subscription to continue:
+
+
+
-<% end %>
-
+
<% content_for "script" do %>
- <% if plugin %>
-
- <% end %>
<%= render_component "jquery-ui" %>
-
+ <%= render_component "moment" %>
<% end %>
diff --git a/src/views/subscription-manager.html.ecr b/src/views/subscription-manager.html.ecr
new file mode 100644
index 00000000..45774c16
--- /dev/null
+++ b/src/views/subscription-manager.html.ecr
@@ -0,0 +1,101 @@
+
Subscription Manager
+
+
+
+
No Plugins Found
+
We could't find any plugins in the directory <%= Config.current.plugin_path %>
.
+
You can download official plugins from the Mango plugins repository .
+
+
+
+
+
Choose a plugin
+
+
+
+
+
+
+
+
+
+
No subscriptions found.
+
+
+
+
+
+ Name
+ Plugin ID
+ Manga Title
+ Created At
+ Last Checked
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+ Subscription ID
+
+ Plugin ID
+
+ Manga Title
+
+ Manga ID
+
+ Filters
+
+
+
+
+ Key
+ Type
+ Value
+
+
+
+
+
+
+
+
+
+ OK
+
+
+
+
+
+
+<% content_for "script" do %>
+ <%= render_component "moment" %>
+
+
+<% end %>
diff --git a/src/views/subscription.html.ecr b/src/views/subscription.html.ecr
deleted file mode 100644
index cc96c471..00000000
--- a/src/views/subscription.html.ecr
+++ /dev/null
@@ -1,54 +0,0 @@
-
MangaDex Subscription Manager
-
-
-
The subscription manager uses a MangaDex API that requires authentication. Please connect to MangaDex before using this feature.
-
-
No subscription found. Go to the MangaDex download page and start subscribing.
-
-
-
-
-
-
- Manga ID
- Language
- Group ID
- Volume Range
- Chapter Range
- Creator
- Last Checked
- Created At
- Actions
-
-
-
-
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-<% content_for "script" do %>
- <%= render_component "moment" %>
-
-
-<% end %>