mirror of
https://github.com/hkalexling/Mango.git
synced 2026-01-25 00:00:36 -05:00
Compare commits
80 Commits
feature/lo
...
fix/saniti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe2c8efed | ||
|
|
d1de8b7a4e | ||
|
|
7ae0577e4e | ||
|
|
e9b1bccbc9 | ||
|
|
293fb84e1d | ||
|
|
9c07944390 | ||
|
|
173d69eb26 | ||
|
|
21d8d0e8a7 | ||
|
|
61e85dd49f | ||
|
|
c778364ca2 | ||
|
|
7ecdb1c0dd | ||
|
|
a5a7396edd | ||
|
|
461398d219 | ||
|
|
0d52544617 | ||
|
|
c3736d222c | ||
|
|
2091053221 | ||
|
|
703e6d076b | ||
|
|
1817efe608 | ||
|
|
8814778c22 | ||
|
|
6ab885499c | ||
|
|
91561ecd6b | ||
|
|
3c399fac4e | ||
|
|
ab3386546d | ||
|
|
857c11be85 | ||
|
|
b3ea3c6154 | ||
|
|
84168b4f53 | ||
|
|
59528de44d | ||
|
|
a29d6754e8 | ||
|
|
167e207fad | ||
|
|
3b52d72ebf | ||
|
|
dc5edc0c1b | ||
|
|
7fa8ffa0bd | ||
|
|
85b57672e6 | ||
|
|
9b111b0ee8 | ||
|
|
8b1c301950 | ||
|
|
3df4675dd7 | ||
|
|
312de0e7b5 | ||
|
|
d57ccc8f81 | ||
|
|
fea6c04c4f | ||
|
|
77df418390 | ||
|
|
750fbbb8fe | ||
|
|
cfe46b435d | ||
|
|
b2329a79b4 | ||
|
|
2007f13ed6 | ||
|
|
f70be435f9 | ||
|
|
1b32dc3de9 | ||
|
|
b83ccf1ccc | ||
|
|
a68783aa21 | ||
|
|
86beed0c5f | ||
|
|
b6c8386caf | ||
|
|
27cc669012 | ||
|
|
4b302af2a1 | ||
|
|
ab29a9eb80 | ||
|
|
e7538bb7f2 | ||
|
|
ecaec307d6 | ||
|
|
b711072492 | ||
|
|
0f94288bab | ||
|
|
bd2ed1b338 | ||
|
|
1cd777d27d | ||
|
|
1ec8dcbfda | ||
|
|
8fea35fa51 | ||
|
|
234b29bbdd | ||
|
|
edfef80e5c | ||
|
|
45ffa3d428 | ||
|
|
162318cf4a | ||
|
|
d4b58e91d1 | ||
|
|
546bd0138c | ||
|
|
ab799af866 | ||
|
|
3a932d7b0a | ||
|
|
57683d1cfb | ||
|
|
d7afd0969a | ||
|
|
4eda55552b | ||
|
|
f9254c49a1 | ||
|
|
6d834e9164 | ||
|
|
70259d8e50 | ||
|
|
0fa2bfa744 | ||
|
|
cc33fa6595 | ||
|
|
921628ba6d | ||
|
|
1199eb7a03 | ||
|
|
f075511847 |
@@ -113,6 +113,24 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"infra"
|
"infra"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "BradleyDS2",
|
||||||
|
"name": "BradleyDS2",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4",
|
||||||
|
"profile": "https://github.com/BradleyDS2",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "nduja",
|
||||||
|
"name": "Robbo",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4",
|
||||||
|
"profile": "https://github.com/nduja",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -13,7 +13,7 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
- Thumbnail generation
|
- Thumbnail generation
|
||||||
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
|
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from third-party sites
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.24.0
|
Mango - Manga Server and Web Reader. Version 0.25.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ base_url: /
|
|||||||
session_secret: mango-session-secret
|
session_secret: mango-session-secret
|
||||||
library_path: ~/mango/library
|
library_path: ~/mango/library
|
||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
|
queue_db_path: ~/mango/queue.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
thumbnail_generation_interval_hours: 24
|
thumbnail_generation_interval_hours: 24
|
||||||
log_level: info
|
log_level: info
|
||||||
@@ -93,17 +94,9 @@ cache_log_enabled: true
|
|||||||
disable_login: false
|
disable_login: false
|
||||||
default_username: ""
|
default_username: ""
|
||||||
auth_proxy_header_name: ""
|
auth_proxy_header_name: ""
|
||||||
mangadex:
|
|
||||||
base_url: https://mangadex.org
|
|
||||||
api_url: https://api.mangadex.org/v2
|
|
||||||
download_wait_seconds: 5
|
|
||||||
download_retries: 4
|
|
||||||
download_queue_db_path: ~/mango/queue.db
|
|
||||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
|
||||||
manga_rename_rule: '{title}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` and `db_optimization_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||||
@@ -179,6 +172,8 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
|
|||||||
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">đź’»</a></td>
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">đź’»</a></td>
|
||||||
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/BradleyDS2"><img src="https://avatars.githubusercontent.com/u/2174921?v=4?s=100" width="100px;" alt=""/><br /><sub><b>BradleyDS2</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=BradleyDS2" title="Documentation">đź“–</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/nduja"><img src="https://avatars.githubusercontent.com/u/69299134?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robbo</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=nduja" title="Code">đź’»</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ gulp.task('minify-css', () => {
|
|||||||
gulp.task('copy-files', () => {
|
gulp.task('copy-files', () => {
|
||||||
return gulp.src([
|
return gulp.src([
|
||||||
'public/*.*',
|
'public/*.*',
|
||||||
'public/img/*',
|
'public/img/**',
|
||||||
'public/webfonts/*',
|
'public/webfonts/*',
|
||||||
'public/js/*.min.js'
|
'public/js/*.min.js'
|
||||||
], {
|
], {
|
||||||
|
|||||||
94
migration/sort_title.12.cr
Normal file
94
migration/sort_title.12.cr
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
class SortTitle < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add sort_title column to ids and titles
|
||||||
|
ALTER TABLE ids ADD COLUMN sort_title TEXT;
|
||||||
|
ALTER TABLE titles ADD COLUMN sort_title TEXT;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove sort_title column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
unavailable INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, signature, unavailable
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove sort_title column from titles
|
||||||
|
ALTER TABLE titles RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
unavailable INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT id, path, signature, unavailable
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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.scanMs = data.milliseconds;
|
||||||
this.scanTitles = data.titles;
|
this.scanTitles = data.titles;
|
||||||
})
|
})
|
||||||
|
.catch(e => {
|
||||||
|
alert('danger', `Failed to trigger a scan. Error: ${e}`);
|
||||||
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
this.scanning = false;
|
this.scanning = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const component = () => {
|
|||||||
jobAction(action, event) {
|
jobAction(action, event) {
|
||||||
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
if (event) {
|
if (event) {
|
||||||
const id = event.currentTarget.closest('tr').id.split('-')[1];
|
const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
|
||||||
url = `${url}?${$.param({
|
url = `${url}?${$.param({
|
||||||
id: id
|
id: id
|
||||||
})}`;
|
})}`;
|
||||||
|
|||||||
@@ -1,139 +1,446 @@
|
|||||||
const loadPlugin = id => {
|
const component = () => {
|
||||||
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 => `<td>${v}</td>`).join('');
|
|
||||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
|
||||||
});
|
|
||||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
|
||||||
$('table').append(tbody);
|
|
||||||
|
|
||||||
$('#selectable').selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
|
||||||
$('#table').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
return {
|
||||||
id: $(e).attr('data-id'),
|
plugins: [],
|
||||||
title: $(e).attr('data-title')
|
info: undefined,
|
||||||
}
|
pid: undefined,
|
||||||
}).get();
|
chapters: undefined, // undefined: not searched yet, []: empty
|
||||||
console.log(chapters);
|
manga: undefined, // undefined: not searched yet, []: empty
|
||||||
$.ajax({
|
mid: undefined, // id of the selected manga
|
||||||
type: 'POST',
|
allChapters: [],
|
||||||
url: base_url + 'api/admin/plugin/download',
|
query: "",
|
||||||
data: JSON.stringify({
|
mangaTitle: "",
|
||||||
plugin: pid,
|
searching: false,
|
||||||
chapters: chapters,
|
adding: false,
|
||||||
title: mangaTitle
|
sortOptions: [],
|
||||||
}),
|
showFilters: false,
|
||||||
contentType: "application/json",
|
appliedFilters: [],
|
||||||
dataType: 'json'
|
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 => {
|
.catch((e) => {
|
||||||
console.log(data);
|
alert(
|
||||||
if (data.error) {
|
"danger",
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
`Failed to list the available plugins. Error: ${e}`
|
||||||
return;
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 successCount = parseInt(data.success);
|
||||||
const failCount = parseInt(data.fail);
|
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) => {
|
.catch((e) => {
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to add chapters to the download queue. Error: ${e}`
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.finally(() => {
|
||||||
$('#download-spinner').attr('hidden', '');
|
this.adding = false;
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
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>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const readerComponent = () => {
|
|||||||
selectedIndex: 0, // 0: not selected; 1: the first page
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
margin: 30,
|
margin: 30,
|
||||||
preloadLookahead: 3,
|
preloadLookahead: 3,
|
||||||
|
enableRightToLeft: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component by fetching the page dimensions
|
* Initialize the component by fetching the page dimensions
|
||||||
@@ -64,6 +65,13 @@ const readerComponent = () => {
|
|||||||
|
|
||||||
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||||
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||||
|
|
||||||
|
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
|
||||||
|
if (savedRightToLeft === null) {
|
||||||
|
this.enableRightToLeft = false;
|
||||||
|
} else {
|
||||||
|
this.enableRightToLeft = (savedRightToLeft === 'true');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const errMsg = `Failed to get the page dimensions. ${e}`;
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
@@ -114,9 +122,9 @@ const readerComponent = () => {
|
|||||||
if (this.mode === 'continuous') return;
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'k')
|
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||||
this.flipPage(false);
|
this.flipPage(false ^ this.enableRightToLeft);
|
||||||
if (event.key === 'ArrowRight' || event.key === 'j')
|
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||||
this.flipPage(true);
|
this.flipPage(true ^ this.enableRightToLeft);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Flips to the next or the previous page
|
* Flips to the next or the previous page
|
||||||
@@ -136,7 +144,7 @@ const readerComponent = () => {
|
|||||||
this.toPage(newIdx);
|
this.toPage(newIdx);
|
||||||
|
|
||||||
if (this.enableFlipAnimation) {
|
if (this.enableFlipAnimation) {
|
||||||
if (isNext)
|
if (isNext ^ this.enableRightToLeft)
|
||||||
this.flipAnimation = 'right';
|
this.flipAnimation = 'right';
|
||||||
else
|
else
|
||||||
this.flipAnimation = 'left';
|
this.flipAnimation = 'left';
|
||||||
@@ -320,5 +328,9 @@ const readerComponent = () => {
|
|||||||
enableFlipAnimationChanged() {
|
enableFlipAnimationChanged() {
|
||||||
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
enableRightToLeftChanged() {
|
||||||
|
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -60,6 +60,11 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UIkit.util.on(document, 'hidden', '#modal', () => {
|
||||||
|
$('#read-btn').off('click');
|
||||||
|
$('#unread-btn').off('click');
|
||||||
|
});
|
||||||
|
|
||||||
const updateProgress = (tid, eid, page) => {
|
const updateProgress = (tid, eid, page) => {
|
||||||
let url = `${base_url}api/progress/${tid}/${page}`
|
let url = `${base_url}api/progress/${tid}/${page}`
|
||||||
const query = $.param({
|
const query = $.param({
|
||||||
@@ -90,8 +95,6 @@ const renameSubmit = (name, eid) => {
|
|||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
const titleId = upload.attr('data-title-id');
|
const titleId = upload.attr('data-title-id');
|
||||||
|
|
||||||
console.log(name);
|
|
||||||
|
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
alert('danger', 'The display name should not be empty');
|
alert('danger', 'The display name should not be empty');
|
||||||
return;
|
return;
|
||||||
@@ -122,15 +125,47 @@ const renameSubmit = (name, eid) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renameSortNameSubmit = (name, eid) => {
|
||||||
|
const upload = $('.upload-field');
|
||||||
|
const titleId = upload.attr('data-title-id');
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (eid) params.eid = eid;
|
||||||
|
if (name) params.name = name;
|
||||||
|
const query = $.param(params);
|
||||||
|
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
type: 'PUT',
|
||||||
|
url,
|
||||||
|
contentType: 'application/json',
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to update sort title. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const edit = (eid) => {
|
const edit = (eid) => {
|
||||||
const cover = $('#edit-modal #cover');
|
const cover = $('#edit-modal #cover');
|
||||||
let url = cover.attr('data-title-cover');
|
let url = cover.attr('data-title-cover');
|
||||||
let displayName = $('h2.uk-title > span').text();
|
let displayName = $('h2.uk-title > span').text();
|
||||||
|
let fileTitle = $('h2.uk-title').attr('data-file-title');
|
||||||
|
let sortTitle = $('h2.uk-title').attr('data-sort-title');
|
||||||
|
|
||||||
if (eid) {
|
if (eid) {
|
||||||
const item = $(`#${eid}`);
|
const item = $(`#${eid}`);
|
||||||
url = item.find('img').attr('data-src');
|
url = item.find('img').attr('data-src');
|
||||||
displayName = item.find('.uk-card-title').attr('data-title');
|
displayName = item.find('.uk-card-title').attr('data-title');
|
||||||
|
fileTitle = item.find('.uk-card-title').attr('data-file-title');
|
||||||
|
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
|
||||||
$('#title-progress-control').attr('hidden', '');
|
$('#title-progress-control').attr('hidden', '');
|
||||||
} else {
|
} else {
|
||||||
$('#title-progress-control').removeAttr('hidden');
|
$('#title-progress-control').removeAttr('hidden');
|
||||||
@@ -140,14 +175,26 @@ const edit = (eid) => {
|
|||||||
|
|
||||||
const displayNameField = $('#display-name-field');
|
const displayNameField = $('#display-name-field');
|
||||||
displayNameField.attr('value', displayName);
|
displayNameField.attr('value', displayName);
|
||||||
console.log(displayNameField);
|
displayNameField.attr('placeholder', fileTitle);
|
||||||
displayNameField.keyup(event => {
|
displayNameField.keyup(event => {
|
||||||
if (event.keyCode === 13) {
|
if (event.keyCode === 13) {
|
||||||
renameSubmit(displayNameField.val(), eid);
|
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
displayNameField.siblings('a.uk-form-icon').click(() => {
|
displayNameField.siblings('a.uk-form-icon').click(() => {
|
||||||
renameSubmit(displayNameField.val(), eid);
|
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortTitleField = $('#sort-title-field');
|
||||||
|
sortTitleField.val(sortTitle);
|
||||||
|
sortTitleField.attr('placeholder', fileTitle);
|
||||||
|
sortTitleField.keyup(event => {
|
||||||
|
if (event.keyCode === 13) {
|
||||||
|
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sortTitleField.siblings('a.uk-form-icon').click(() => {
|
||||||
|
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||||
});
|
});
|
||||||
|
|
||||||
setupUpload(eid);
|
setupUpload(eid);
|
||||||
@@ -155,6 +202,16 @@ const edit = (eid) => {
|
|||||||
UIkit.modal($('#edit-modal')).show();
|
UIkit.modal($('#edit-modal')).show();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
|
||||||
|
const displayNameField = $('#display-name-field');
|
||||||
|
displayNameField.off('keyup');
|
||||||
|
displayNameField.off('click');
|
||||||
|
|
||||||
|
const sortTitleField = $('#sort-title-field');
|
||||||
|
sortTitleField.off('keyup');
|
||||||
|
sortTitleField.off('click');
|
||||||
|
});
|
||||||
|
|
||||||
const setupUpload = (eid) => {
|
const setupUpload = (eid) => {
|
||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
const bar = $('#upload-progress').get(0);
|
const bar = $('#upload-progress').get(0);
|
||||||
@@ -166,7 +223,6 @@ const setupUpload = (eid) => {
|
|||||||
queryObj['eid'] = eid;
|
queryObj['eid'] = eid;
|
||||||
const query = $.param(queryObj);
|
const query = $.param(queryObj);
|
||||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||||
console.log(url);
|
|
||||||
UIkit.upload('.upload-field', {
|
UIkit.upload('.upload-field', {
|
||||||
url: url,
|
url: url,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
|
|||||||
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:
|
koa:
|
||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.8.0
|
version: 0.9.0
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
@@ -68,6 +68,10 @@ shards:
|
|||||||
git: https://github.com/luislavena/radix.git
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.4.1
|
version: 0.4.1
|
||||||
|
|
||||||
|
sanitize:
|
||||||
|
git: https://github.com/hkalexling/sanitize.git
|
||||||
|
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.24.0
|
version: 0.25.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -42,3 +42,5 @@ dependencies:
|
|||||||
branch: master
|
branch: master
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
|
sanitize:
|
||||||
|
github: hkalexling/sanitize
|
||||||
|
|||||||
@@ -4,43 +4,28 @@ class Config
|
|||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
property path : String = ""
|
property path = ""
|
||||||
property host : String = "0.0.0.0"
|
property host = "0.0.0.0"
|
||||||
property port : Int32 = 9000
|
property port : Int32 = 9000
|
||||||
property base_url : String = "/"
|
property base_url = "/"
|
||||||
property session_secret : String = "mango-session-secret"
|
property session_secret = "mango-session-secret"
|
||||||
property library_path : String = File.expand_path "~/mango/library",
|
property library_path = "~/mango/library"
|
||||||
home: true
|
property library_cache_path = "~/mango/library.yml.gz"
|
||||||
property library_cache_path = File.expand_path "~/mango/library.yml.gz",
|
property db_path = "~/mango/mango.db"
|
||||||
home: true
|
property queue_db_path = "~/mango/queue.db"
|
||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
|
||||||
property scan_interval_minutes : Int32 = 5
|
property scan_interval_minutes : Int32 = 5
|
||||||
property thumbnail_generation_interval_hours : Int32 = 24
|
property thumbnail_generation_interval_hours : Int32 = 24
|
||||||
property log_level : String = "info"
|
property log_level = "info"
|
||||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
property upload_path = "~/mango/uploads"
|
||||||
home: true
|
property plugin_path = "~/mango/plugins"
|
||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
|
||||||
home: true
|
|
||||||
property download_timeout_seconds : Int32 = 30
|
property download_timeout_seconds : Int32 = 30
|
||||||
property cache_enabled = false
|
property cache_enabled = true
|
||||||
property cache_size_mbs = 50
|
property cache_size_mbs = 50
|
||||||
property cache_log_enabled = true
|
property cache_log_enabled = true
|
||||||
property disable_login = false
|
property disable_login = false
|
||||||
property default_username = ""
|
property default_username = ""
|
||||||
property auth_proxy_header_name = ""
|
property auth_proxy_header_name = ""
|
||||||
property mangadex = Hash(String, String | Int32).new
|
property plugin_update_interval_hours : Int32 = 24
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
|
||||||
@mangadex_defaults = {
|
|
||||||
"base_url" => "https://mangadex.org",
|
|
||||||
"api_url" => "https://api.mangadex.org/v2",
|
|
||||||
"download_wait_seconds" => 5,
|
|
||||||
"download_retries" => 4,
|
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
|
||||||
home: true),
|
|
||||||
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
|
||||||
"manga_rename_rule" => "{title}",
|
|
||||||
}
|
|
||||||
|
|
||||||
@@singlet : Config?
|
@@singlet : Config?
|
||||||
|
|
||||||
@@ -58,7 +43,7 @@ class Config
|
|||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
config.path = path
|
config.path = path
|
||||||
config.fill_defaults
|
config.expand_paths
|
||||||
config.preprocess
|
config.preprocess
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
@@ -66,7 +51,7 @@ class Config
|
|||||||
"Dumping the default config there."
|
"Dumping the default config there."
|
||||||
default = self.allocate
|
default = self.allocate
|
||||||
default.path = path
|
default.path = path
|
||||||
default.fill_defaults
|
default.expand_paths
|
||||||
cfg_dir = File.dirname cfg_path
|
cfg_dir = File.dirname cfg_path
|
||||||
unless Dir.exists? cfg_dir
|
unless Dir.exists? cfg_dir
|
||||||
Dir.mkdir_p cfg_dir
|
Dir.mkdir_p cfg_dir
|
||||||
@@ -76,13 +61,9 @@ class Config
|
|||||||
default
|
default
|
||||||
end
|
end
|
||||||
|
|
||||||
def fill_defaults
|
def expand_paths
|
||||||
{% for hash_name in ["mangadex"] %}
|
{% for p in %w(library library_cache db queue_db upload plugin) %}
|
||||||
@{{hash_name.id}}_defaults.map do |k, v|
|
@{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true
|
||||||
if @{{hash_name.id}}[k]?.nil?
|
|
||||||
@{{hash_name.id}}[k] = v
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -97,24 +78,5 @@ class Config
|
|||||||
raise "Login is disabled, but default username is not set. " \
|
raise "Login is disabled, but default username is not set. " \
|
||||||
"Please set a default username"
|
"Please set a default username"
|
||||||
end
|
end
|
||||||
|
|
||||||
# `Logger.default` is not available yet
|
|
||||||
Log.setup :debug
|
|
||||||
unless mangadex["api_url"] =~ /\/v2/
|
|
||||||
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
|
||||||
"v1 in your config file. Please update it to " \
|
|
||||||
"https://api.mangadex.org/v2 to suppress this warning." }
|
|
||||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
|
||||||
end
|
|
||||||
if mangadex["api_url"] =~ /\/api\/v2/
|
|
||||||
Log.warn { "It looks like you are using the outdated MangaDex API " \
|
|
||||||
"url (mangadex.org/api/v2) in your config file. Please " \
|
|
||||||
"update it to https://api.mangadex.org/v2 to suppress this " \
|
|
||||||
"warning." }
|
|
||||||
mangadex["api_url"] = "https://api.mangadex.org/v2"
|
|
||||||
end
|
|
||||||
|
|
||||||
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
|
||||||
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler
|
|||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|
||||||
BASIC = "Basic"
|
BASIC = "Basic"
|
||||||
|
BEARER = "Bearer"
|
||||||
AUTH = "Authorization"
|
AUTH = "Authorization"
|
||||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||||
"You have to login with proper credentials"
|
"You have to login with proper credentials"
|
||||||
@@ -18,9 +19,15 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
def require_auth(env)
|
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
|
env.session.string "callback", env.request.path
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def validate_token(env)
|
def validate_token(env)
|
||||||
token = env.session.string? "token"
|
token = env.session.string? "token"
|
||||||
@@ -35,13 +42,18 @@ class AuthHandler < Kemal::Handler
|
|||||||
def validate_auth_header(env)
|
def validate_auth_header(env)
|
||||||
if env.request.headers[AUTH]?
|
if env.request.headers[AUTH]?
|
||||||
if value = env.request.headers[AUTH]
|
if value = env.request.headers[AUTH]
|
||||||
if value.size > 0 && value.starts_with?(BASIC)
|
if value.starts_with? BASIC
|
||||||
token = verify_user value
|
token = verify_user value
|
||||||
return false if token.nil?
|
return false if token.nil?
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if value.starts_with? BEARER
|
||||||
|
session_id = value.split(" ")[1]
|
||||||
|
token = Kemal::Session.get(session_id).try &.string? "token"
|
||||||
|
return !token.nil? && Storage.default.verify_token token
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
false
|
false
|
||||||
@@ -54,6 +66,10 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
|
# OPTIONS requests do not require authentication
|
||||||
|
if env.request.method === "OPTIONS"
|
||||||
|
return call_next(env)
|
||||||
|
end
|
||||||
# Skip all authentication if requesting /login, /logout, /api/login,
|
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||||
# or a static file
|
# or a static file
|
||||||
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||||
@@ -62,8 +78,8 @@ class AuthHandler < Kemal::Handler
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Check user is logged in
|
# Check user is logged in
|
||||||
if validate_token env
|
if validate_token(env) || validate_auth_header(env)
|
||||||
# Skip if the request has a valid token
|
# Skip if the request has a valid token (either from cookies or header)
|
||||||
elsif Config.current.disable_login
|
elsif Config.current.disable_login
|
||||||
# Check default username if login is disabled
|
# Check default username if login is disabled
|
||||||
unless Storage.default.username_exists Config.current.default_username
|
unless Storage.default.username_exists Config.current.default_username
|
||||||
|
|||||||
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
require "digest"
|
require "digest"
|
||||||
|
|
||||||
require "./entry"
|
require "./entry"
|
||||||
|
require "./title"
|
||||||
require "./types"
|
require "./types"
|
||||||
|
|
||||||
# Base class for an entry in the LRU cache.
|
# Base class for an entry in the LRU cache.
|
||||||
@@ -81,6 +82,31 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
||||||
|
def self.to_save_t(value : Array(Title))
|
||||||
|
value.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : Array(String))
|
||||||
|
value.map { |title_id| Library.default.title_hash[title_id].not_nil! }
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||||
|
@value.sum(&.bytesize) # elements in Array(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||||
|
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||||
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
|
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
|
||||||
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
|
"#{sig}:sorted_titles"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class String
|
class String
|
||||||
def instance_size
|
def instance_size
|
||||||
instance_sizeof(String) + bytesize
|
instance_sizeof(String) + bytesize
|
||||||
@@ -101,14 +127,18 @@ struct Tuple(*T)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
alias CacheableType = Array(Entry) | String | Tuple(String, Int32)
|
alias CacheableType = Array(Entry) | Array(Title) | String |
|
||||||
|
Tuple(String, Int32)
|
||||||
alias CacheEntryType = SortedEntriesCacheEntry |
|
alias CacheEntryType = SortedEntriesCacheEntry |
|
||||||
|
SortedTitlesCacheEntry |
|
||||||
CacheEntry(String, String) |
|
CacheEntry(String, String) |
|
||||||
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
||||||
|
|
||||||
def generate_cache_entry(key : String, value : CacheableType)
|
def generate_cache_entry(key : String, value : CacheableType)
|
||||||
if value.is_a? Array(Entry)
|
if value.is_a? Array(Entry)
|
||||||
SortedEntriesCacheEntry.new key, value
|
SortedEntriesCacheEntry.new key, value
|
||||||
|
elsif value.is_a? Array(Title)
|
||||||
|
SortedTitlesCacheEntry.new key, value
|
||||||
else
|
else
|
||||||
CacheEntry(typeof(value), typeof(value)).new key, value
|
CacheEntry(typeof(value), typeof(value)).new key, value
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class Entry
|
|||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
size : String, pages : Int32, id : String, encoded_path : String,
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
encoded_title : String, mtime : Time, err_msg : String?
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@sort_title : String?
|
||||||
|
|
||||||
def initialize(@zip_path, @book)
|
def initialize(@zip_path, @book)
|
||||||
storage = Storage.default
|
storage = Storage.default
|
||||||
@encoded_path = URI.encode @zip_path
|
@encoded_path = URI.encode @zip_path
|
||||||
@@ -52,10 +55,15 @@ class Entry
|
|||||||
def build_json(*, slim = false)
|
def build_json(*, slim = false)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["zip_path", "title", "size", "id"] %}
|
{% for str in %w(zip_path title size id) %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
|
if err_msg
|
||||||
|
json.field "err_msg", err_msg
|
||||||
|
end
|
||||||
json.field "title_id", @book.id
|
json.field "title_id", @book.id
|
||||||
|
json.field "title_title", @book.title
|
||||||
|
json.field "sort_title", sort_title
|
||||||
json.field "pages" { json.number @pages }
|
json.field "pages" { json.number @pages }
|
||||||
unless slim
|
unless slim
|
||||||
json.field "display_name", @book.display_name @title
|
json.field "display_name", @book.display_name @title
|
||||||
@@ -66,6 +74,35 @@ class Entry
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sort_title
|
||||||
|
sort_title_cached = @sort_title
|
||||||
|
return sort_title_cached if sort_title_cached
|
||||||
|
sort_title = @book.entry_sort_title_db id
|
||||||
|
if sort_title
|
||||||
|
@sort_title = sort_title
|
||||||
|
return sort_title
|
||||||
|
end
|
||||||
|
@sort_title = @title
|
||||||
|
@title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_sort_title(sort_title : String | Nil, username : String)
|
||||||
|
Storage.default.set_entry_sort_title id, sort_title
|
||||||
|
if sort_title == "" || sort_title.nil?
|
||||||
|
@sort_title = nil
|
||||||
|
else
|
||||||
|
@sort_title = sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
@book.entry_sort_title_cache = nil
|
||||||
|
@book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
|
||||||
|
username
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_title_db
|
||||||
|
@book.entry_sort_title_db @id
|
||||||
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
@book.display_name @title
|
@book.display_name @title
|
||||||
end
|
end
|
||||||
@@ -75,7 +112,7 @@ class Entry
|
|||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
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
|
unless @book.entry_cover_url_cache
|
||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
@@ -111,14 +148,18 @@ class Entry
|
|||||||
def read_page(page_num)
|
def read_page(page_num)
|
||||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||||
img = nil
|
img = nil
|
||||||
|
begin
|
||||||
sorted_archive_entries do |file, entries|
|
sorted_archive_entries do |file, entries|
|
||||||
page = entries[page_num - 1]
|
page = entries[page_num - 1]
|
||||||
data = file.read_entry page
|
data = file.read_entry page
|
||||||
if data
|
if data
|
||||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
img = Image.new data, MIME.from_filename(page.filename),
|
||||||
data.size
|
page.filename, data.size
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
|
||||||
|
end
|
||||||
img
|
img
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -177,11 +218,7 @@ class Entry
|
|||||||
@book.parents.each do |parent|
|
@book.parents.each do |parent|
|
||||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
end
|
end
|
||||||
[false, true].each do |ascend|
|
@book.remove_sorted_caches [SortMethod::Progress], username
|
||||||
sorted_entries_cache_key = SortedEntriesCacheEntry.gen_key @book.id,
|
|
||||||
username, @book.entries, SortOptions.new(SortMethod::Progress, ascend)
|
|
||||||
LRUCache.invalidate sorted_entries_cache_key
|
|
||||||
end
|
|
||||||
|
|
||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
if info.progress[username]?.nil?
|
if info.progress[username]?.nil?
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
class Library
|
class Library
|
||||||
|
struct ThumbnailContext
|
||||||
|
property current : Int32, total : Int32
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@current = 0
|
||||||
|
@total = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def progress
|
||||||
|
if total == 0
|
||||||
|
0
|
||||||
|
else
|
||||||
|
current / total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset
|
||||||
|
@current = 0
|
||||||
|
@total = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def increment
|
||||||
|
@current += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
getter dir : String, title_ids : Array(String),
|
getter dir : String, title_ids : Array(String),
|
||||||
title_hash : Hash(String, Title)
|
title_hash : Hash(String, Title)
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
getter thumbnail_ctx = ThumbnailContext.new
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
def save_instance
|
def save_instance
|
||||||
@@ -24,7 +53,23 @@ class Library
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Compress::Gzip::Reader.open path do |content|
|
Compress::Gzip::Reader.open path do |content|
|
||||||
@@default = Library.from_yaml content
|
loaded = Library.from_yaml content
|
||||||
|
# We will have to do a full restart in these cases. Otherwise having
|
||||||
|
# two instances of the library will cause some weirdness.
|
||||||
|
if loaded.dir != Config.current.library_path
|
||||||
|
Logger.fatal "Cached library dir #{loaded.dir} does not match " \
|
||||||
|
"current library dir #{Config.current.library_path}. " \
|
||||||
|
"Deleting cache"
|
||||||
|
delete_cache_and_exit path
|
||||||
|
end
|
||||||
|
if loaded.title_ids.size > 0 &&
|
||||||
|
Storage.default.count_titles == 0
|
||||||
|
Logger.fatal "The library cache is inconsistent with the DB. " \
|
||||||
|
"Deleting cache"
|
||||||
|
delete_cache_and_exit path
|
||||||
|
end
|
||||||
|
@@default = loaded
|
||||||
|
Logger.debug "Library cache loaded"
|
||||||
end
|
end
|
||||||
Library.default.register_jobs
|
Library.default.register_jobs
|
||||||
rescue e
|
rescue e
|
||||||
@@ -39,9 +84,6 @@ class Library
|
|||||||
@title_ids = [] of String
|
@title_ids = [] of String
|
||||||
@title_hash = {} of String => Title
|
@title_hash = {} of String => Title
|
||||||
|
|
||||||
@entries_count = 0
|
|
||||||
@thumbnails_count = 0
|
|
||||||
|
|
||||||
register_jobs
|
register_jobs
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -97,14 +139,31 @@ class Library
|
|||||||
titles.flat_map &.deep_entries
|
titles.flat_map &.deep_entries
|
||||||
end
|
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.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
json.field "dir", @dir
|
json.field "dir", @dir
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.array do
|
json.array do
|
||||||
self.titles.each do |title|
|
_titles.each do |title|
|
||||||
json.raw title.build_json(slim: slim, depth: depth)
|
json.raw title.build_json(slim: slim, depth: depth,
|
||||||
|
sort_context: sort_context, percentage: percentage)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if percentage && sort_context
|
||||||
|
json.field "title_percentages" do
|
||||||
|
json.array do
|
||||||
|
_titles.each do |title|
|
||||||
|
json.number title.load_percentage sort_context[:username]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -136,8 +195,12 @@ class Library
|
|||||||
deleted_entry_ids: [] of String,
|
deleted_entry_ids: [] of String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
library_paths = (Dir.entries @dir)
|
||||||
|
.select { |fn| !fn.starts_with? "." }
|
||||||
|
.map { |fn| File.join @dir, fn }
|
||||||
@title_ids.select! do |title_id|
|
@title_ids.select! do |title_id|
|
||||||
title = @title_hash[title_id]
|
title = @title_hash[title_id]
|
||||||
|
next false unless library_paths.includes? title.dir
|
||||||
existence = title.examine examine_context
|
existence = title.examine examine_context
|
||||||
unless existence
|
unless existence
|
||||||
examine_context["deleted_title_ids"].concat [title_id] +
|
examine_context["deleted_title_ids"].concat [title_id] +
|
||||||
@@ -152,14 +215,12 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
cache = examine_context["cached_contents_signature"]
|
cache = examine_context["cached_contents_signature"]
|
||||||
(Dir.entries @dir)
|
library_paths
|
||||||
.select { |fn| !fn.starts_with? "." }
|
|
||||||
.map { |fn| File.join @dir, fn }
|
|
||||||
.select { |path| !(remained_title_dirs.includes? path) }
|
.select { |path| !(remained_title_dirs.includes? path) }
|
||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "", cache }
|
.map { |path| Title.new path, "", cache }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort! { |a, b| a.title <=> b.title }
|
.sort! { |a, b| a.sort_title <=> b.sort_title }
|
||||||
.each do |title|
|
.each do |title|
|
||||||
@title_hash[title.id] = title
|
@title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
@@ -260,34 +321,29 @@ class Library
|
|||||||
.shuffle!
|
.shuffle!
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_generation_progress
|
|
||||||
return 0 if @entries_count == 0
|
|
||||||
@thumbnails_count / @entries_count
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_thumbnails
|
def generate_thumbnails
|
||||||
if @thumbnails_count > 0
|
if thumbnail_ctx.current > 0
|
||||||
Logger.debug "Thumbnail generation in progress"
|
Logger.debug "Thumbnail generation in progress"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Logger.info "Starting thumbnail generation"
|
Logger.info "Starting thumbnail generation"
|
||||||
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||||
@entries_count = entries.size
|
thumbnail_ctx.total = entries.size
|
||||||
@thumbnails_count = 0
|
thumbnail_ctx.current = 0
|
||||||
|
|
||||||
# Report generation progress regularly
|
# Report generation progress regularly
|
||||||
spawn do
|
spawn do
|
||||||
loop do
|
loop do
|
||||||
unless @thumbnails_count == 0
|
unless thumbnail_ctx.current == 0
|
||||||
Logger.debug "Thumbnail generation progress: " \
|
Logger.debug "Thumbnail generation progress: " \
|
||||||
"#{(thumbnail_generation_progress * 100).round 1}%"
|
"#{(thumbnail_ctx.progress * 100).round 1}%"
|
||||||
end
|
end
|
||||||
# Generation is completed. We reset the count to 0 to allow subsequent
|
# Generation is completed. We reset the count to 0 to allow subsequent
|
||||||
# calls to the function, and break from the loop to stop the progress
|
# calls to the function, and break from the loop to stop the progress
|
||||||
# report fiber
|
# report fiber
|
||||||
if thumbnail_generation_progress.to_i == 1
|
if thumbnail_ctx.progress.to_i == 1
|
||||||
@thumbnails_count = 0
|
thumbnail_ctx.reset
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
sleep 10.seconds
|
sleep 10.seconds
|
||||||
@@ -301,7 +357,7 @@ class Library
|
|||||||
# and CPU
|
# and CPU
|
||||||
sleep 1.seconds
|
sleep 1.seconds
|
||||||
end
|
end
|
||||||
@thumbnails_count += 1
|
thumbnail_ctx.increment
|
||||||
end
|
end
|
||||||
Logger.info "Thumbnail generation finished"
|
Logger.info "Thumbnail generation finished"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ class Title
|
|||||||
entries : Array(Entry), title : String, id : String,
|
entries : Array(Entry), title : String, id : String,
|
||||||
encoded_title : String, mtime : Time, signature : UInt64,
|
encoded_title : String, mtime : Time, signature : UInt64,
|
||||||
entry_cover_url_cache : Hash(String, String)?
|
entry_cover_url_cache : Hash(String, String)?
|
||||||
setter entry_cover_url_cache : Hash(String, String)?
|
setter entry_cover_url_cache : Hash(String, String)?,
|
||||||
|
entry_sort_title_cache : Hash(String, String | Nil)?
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@sort_title : String?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@entry_sort_title_cache : Hash(String, String | Nil)?
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@entry_display_name_cache : Hash(String, String)?
|
@entry_display_name_cache : Hash(String, String)?
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@@ -66,7 +71,7 @@ class Title
|
|||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
sorter.compare a.title, b.title
|
sorter.compare a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -102,7 +107,11 @@ class Title
|
|||||||
|
|
||||||
previous_titles_size = @title_ids.size
|
previous_titles_size = @title_ids.size
|
||||||
@title_ids.select! do |title_id|
|
@title_ids.select! do |title_id|
|
||||||
title = Library.default.get_title! title_id
|
title = Library.default.get_title title_id
|
||||||
|
unless title # for if data consistency broken
|
||||||
|
context["deleted_title_ids"].concat [title_id]
|
||||||
|
next false
|
||||||
|
end
|
||||||
existence = title.examine context
|
existence = title.examine context
|
||||||
unless existence
|
unless existence
|
||||||
context["deleted_title_ids"].concat [title_id] +
|
context["deleted_title_ids"].concat [title_id] +
|
||||||
@@ -137,6 +146,18 @@ class Title
|
|||||||
Library.default.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
is_titles_added = true
|
is_titles_added = true
|
||||||
|
|
||||||
|
# We think they are removed, but they are here!
|
||||||
|
# Cancel reserved jobs
|
||||||
|
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||||
|
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||||
|
!(revival_title_ids.includes? deleted_title_id)
|
||||||
|
end
|
||||||
|
revival_entry_ids = title.deep_entries.map &.id
|
||||||
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
|
!(revival_entry_ids.includes? deleted_entry_id)
|
||||||
|
end
|
||||||
|
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if is_supported_file path
|
if is_supported_file path
|
||||||
@@ -145,6 +166,9 @@ class Title
|
|||||||
if entry.pages > 0 || entry.err_msg
|
if entry.pages > 0 || entry.err_msg
|
||||||
@entries << entry
|
@entries << entry
|
||||||
is_entries_added = true
|
is_entries_added = true
|
||||||
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
|
entry.id != deleted_entry_id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -161,25 +185,45 @@ class Title
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if is_entries_added || previous_entries_size != @entries.size
|
if is_entries_added || previous_entries_size != @entries.size
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
sorter.compare a.title, b.title
|
sorter.compare a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if @title_ids.size > 0 || @entries.size > 0
|
||||||
true
|
true
|
||||||
|
else
|
||||||
|
context["deleted_title_ids"].concat [@id]
|
||||||
|
false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||||
|
|
||||||
def build_json(*, slim = false, depth = -1,
|
def build_json(*, slim = false, depth = -1,
|
||||||
sort_context : SortContext? = nil)
|
sort_context : SortContext? = nil,
|
||||||
|
percentage = false)
|
||||||
|
_titles = if sort_context
|
||||||
|
sorted_titles sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
self.titles
|
||||||
|
end
|
||||||
|
_entries = if sort_context
|
||||||
|
sorted_entries sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
@entries
|
||||||
|
end
|
||||||
|
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for str in ["dir", "title", "id"] %}
|
{% for str in ["dir", "title", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "signature" { json.number @signature }
|
json.field "signature" { json.number @signature }
|
||||||
|
json.field "sort_title", sort_title
|
||||||
unless slim
|
unless slim
|
||||||
json.field "display_name", display_name
|
json.field "display_name", display_name
|
||||||
json.field "cover_url", cover_url
|
json.field "cover_url", cover_url
|
||||||
@@ -188,25 +232,39 @@ class Title
|
|||||||
unless depth == 0
|
unless depth == 0
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.array do
|
json.array do
|
||||||
self.titles.each do |title|
|
_titles.each do |title|
|
||||||
json.raw title.build_json(slim: slim,
|
json.raw title.build_json(slim: slim,
|
||||||
depth: depth > 0 ? depth - 1 : depth)
|
depth: depth > 0 ? depth - 1 : depth,
|
||||||
|
sort_context: sort_context, percentage: percentage)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
json.field "entries" do
|
json.field "entries" do
|
||||||
json.array do
|
json.array do
|
||||||
_entries = if sort_context
|
|
||||||
sorted_entries sort_context[:username],
|
|
||||||
sort_context[:opt]
|
|
||||||
else
|
|
||||||
@entries
|
|
||||||
end
|
|
||||||
_entries.each do |entry|
|
_entries.each do |entry|
|
||||||
json.raw entry.build_json(slim: slim)
|
json.raw entry.build_json(slim: slim)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if percentage && sort_context
|
||||||
|
json.field "title_percentages" do
|
||||||
|
json.array do
|
||||||
|
_titles.each do |t|
|
||||||
|
json.number t.load_percentage sort_context[:username]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "entry_percentages" do
|
||||||
|
json.array do
|
||||||
|
load_percentage_for_all_entries(
|
||||||
|
sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
).each do |p|
|
||||||
|
json.number p.nan? ? 0 : p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
json.field "parents" do
|
json.field "parents" do
|
||||||
json.array do
|
json.array do
|
||||||
@@ -226,6 +284,15 @@ class Title
|
|||||||
@title_ids.map { |tid| Library.default.get_title! tid }
|
@title_ids.map { |tid| Library.default.get_title! tid }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sorted_titles(username, opt : SortOptions? = nil)
|
||||||
|
if opt.nil?
|
||||||
|
opt = SortOptions.from_info_json @dir, username
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function from src/util/util.cr
|
||||||
|
sort_titles titles, opt.not_nil!, username
|
||||||
|
end
|
||||||
|
|
||||||
# Get all entries, including entries in nested titles
|
# Get all entries, including entries in nested titles
|
||||||
def deep_entries
|
def deep_entries
|
||||||
return @entries if title_ids.empty?
|
return @entries if title_ids.empty?
|
||||||
@@ -262,6 +329,48 @@ class Title
|
|||||||
ary.join " and "
|
ary.join " and "
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sort_title
|
||||||
|
sort_title_cached = @sort_title
|
||||||
|
return sort_title_cached if sort_title_cached
|
||||||
|
sort_title = Storage.default.get_title_sort_title id
|
||||||
|
if sort_title
|
||||||
|
@sort_title = sort_title
|
||||||
|
return sort_title
|
||||||
|
end
|
||||||
|
@sort_title = @title
|
||||||
|
@title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_sort_title(sort_title : String | Nil, username : String)
|
||||||
|
Storage.default.set_title_sort_title id, sort_title
|
||||||
|
if sort_title == "" || sort_title.nil?
|
||||||
|
@sort_title = nil
|
||||||
|
else
|
||||||
|
@sort_title = sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
if parents.size > 0
|
||||||
|
target = parents[-1].titles
|
||||||
|
else
|
||||||
|
target = Library.default.titles
|
||||||
|
end
|
||||||
|
remove_sorted_titles_cache target,
|
||||||
|
[SortMethod::Auto, SortMethod::Title], username
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_title_db
|
||||||
|
Storage.default.get_title_sort_title id
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_sort_title_db(entry_id)
|
||||||
|
unless @entry_sort_title_cache
|
||||||
|
@entry_sort_title_cache =
|
||||||
|
Storage.default.get_entries_sort_title @entries.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
@entry_sort_title_cache.not_nil![entry_id]?
|
||||||
|
end
|
||||||
|
|
||||||
def tags
|
def tags
|
||||||
Storage.default.get_title_tags @id
|
Storage.default.get_title_tags @id
|
||||||
end
|
end
|
||||||
@@ -330,7 +439,7 @@ class Title
|
|||||||
cached_cover_url = @cached_cover_url
|
cached_cover_url = @cached_cover_url
|
||||||
return cached_cover_url unless cached_cover_url.nil?
|
return cached_cover_url unless cached_cover_url.nil?
|
||||||
|
|
||||||
url = "#{Config.current.base_url}img/icon.png"
|
url = "#{Config.current.base_url}img/icons/icon_x192.png"
|
||||||
readable_entries = @entries.select &.err_msg.nil?
|
readable_entries = @entries.select &.err_msg.nil?
|
||||||
if readable_entries.size > 0
|
if readable_entries.size > 0
|
||||||
url = readable_entries[0].cover_url
|
url = readable_entries[0].cover_url
|
||||||
@@ -448,28 +557,30 @@ class Title
|
|||||||
|
|
||||||
case opt.not_nil!.method
|
case opt.not_nil!.method
|
||||||
when .title?
|
when .title?
|
||||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
ary = @entries.sort do |a, b|
|
||||||
|
compare_numerically a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
when .time_modified?
|
when .time_modified?
|
||||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
compare_numerically a.title, b.title }
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
when .time_added?
|
when .time_added?
|
||||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||||
compare_numerically a.title, b.title }
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
when .progress?
|
when .progress?
|
||||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||||
ary = @entries.zip(percentage_ary)
|
ary = @entries.zip(percentage_ary)
|
||||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
compare_numerically a_tp[0].sort_title, b_tp[0].sort_title }
|
||||||
.map &.[0]
|
.map &.[0]
|
||||||
else
|
else
|
||||||
unless opt.method.auto?
|
unless opt.method.auto?
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
"Auto instead"
|
"Auto instead"
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map &.title
|
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||||
ary = @entries.sort do |a, b|
|
ary = @entries.sort do |a, b|
|
||||||
sorter.compare(a.title, b.title).or \
|
sorter.compare(a.sort_title, b.sort_title).or \
|
||||||
compare_numerically a.title, b.title
|
compare_numerically a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -536,17 +647,32 @@ class Title
|
|||||||
zip + titles.flat_map &.deep_entries_with_date_added
|
zip + titles.flat_map &.deep_entries_with_date_added
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def remove_sorted_entries_cache(sort_methods : Array(SortMethod),
|
||||||
|
username : String)
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sort_methods.each do |sort_method|
|
||||||
|
sorted_entries_cache_key =
|
||||||
|
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
||||||
|
SortOptions.new(sort_method, ascend)
|
||||||
|
LRUCache.invalidate sorted_entries_cache_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_sorted_caches(sort_methods : Array(SortMethod), username : String)
|
||||||
|
remove_sorted_entries_cache sort_methods, username
|
||||||
|
parents.each do |parent|
|
||||||
|
remove_sorted_titles_cache parent.titles, sort_methods, username
|
||||||
|
end
|
||||||
|
remove_sorted_titles_cache Library.default.titles, sort_methods, username
|
||||||
|
end
|
||||||
|
|
||||||
def bulk_progress(action, ids : Array(String), username)
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
||||||
parents.each do |parent|
|
parents.each do |parent|
|
||||||
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
end
|
end
|
||||||
[false, true].each do |ascend|
|
remove_sorted_caches [SortMethod::Progress], username
|
||||||
sorted_entries_cache_key =
|
|
||||||
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
|
||||||
SortOptions.new(SortMethod::Progress, ascend)
|
|
||||||
LRUCache.invalidate sorted_entries_cache_key
|
|
||||||
end
|
|
||||||
|
|
||||||
selected_entries = ids
|
selected_entries = ids
|
||||||
.map { |id|
|
.map { |id|
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class SortOptions
|
|||||||
def to_tuple
|
def to_tuple
|
||||||
{@method.to_s.underscore, ascend}
|
{@method.to_s.underscore, ascend}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_json
|
||||||
|
{
|
||||||
|
"method" => method.to_s.underscore,
|
||||||
|
"ascend" => ascend,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
struct Image
|
struct Image
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.24.0"
|
MANGO_VERSION = "0.25.0"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
@@ -61,6 +61,7 @@ class CLI < Clim
|
|||||||
Library.load_instance
|
Library.load_instance
|
||||||
Library.default
|
Library.default
|
||||||
Plugin::Downloader.default
|
Plugin::Downloader.default
|
||||||
|
Plugin::Updater.default
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
begin
|
begin
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ require "duktape/runtime"
|
|||||||
require "myhtml"
|
require "myhtml"
|
||||||
require "xml"
|
require "xml"
|
||||||
|
|
||||||
|
require "./subscriptions"
|
||||||
|
|
||||||
class Plugin
|
class Plugin
|
||||||
class Error < ::Exception
|
class Error < ::Exception
|
||||||
end
|
end
|
||||||
@@ -16,12 +18,19 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct Info
|
struct Info
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
{% for name in ["id", "title", "placeholder"] %}
|
||||||
getter {{name.id}} = ""
|
getter {{name.id}} = ""
|
||||||
{% end %}
|
{% end %}
|
||||||
getter wait_seconds : UInt64 = 0
|
getter wait_seconds = 0u64
|
||||||
|
getter version = 0u64
|
||||||
|
getter settings = {} of String => String?
|
||||||
getter dir : String
|
getter dir : String
|
||||||
|
|
||||||
|
@[JSON::Field(ignore: true)]
|
||||||
|
@json : JSON::Any
|
||||||
|
|
||||||
def initialize(@dir)
|
def initialize(@dir)
|
||||||
info_path = File.join @dir, "info.json"
|
info_path = File.join @dir, "info.json"
|
||||||
|
|
||||||
@@ -37,6 +46,16 @@ class Plugin
|
|||||||
@{{name.id}} = @json[{{name}}].as_s
|
@{{name.id}} = @json[{{name}}].as_s
|
||||||
{% end %}
|
{% end %}
|
||||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||||
|
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
||||||
|
|
||||||
|
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
||||||
|
settings_hash.each do |k, v|
|
||||||
|
unless str_value = v.as_s?
|
||||||
|
raise "The settings object can only contain strings or null"
|
||||||
|
end
|
||||||
|
@settings[k] = str_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
unless @id.alphanumeric_underscore?
|
unless @id.alphanumeric_underscore?
|
||||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||||
@@ -114,6 +133,33 @@ class Plugin
|
|||||||
@info.not_nil!
|
@info.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def subscribe(subscription : Subscription)
|
||||||
|
list = SubscriptionList.new info.dir
|
||||||
|
list << subscription
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_subscriptions
|
||||||
|
SubscriptionList.new(info.dir).ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def 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)
|
def initialize(id : String)
|
||||||
Plugin.build_info_ary
|
Plugin.build_info_ary
|
||||||
|
|
||||||
@@ -138,6 +184,12 @@ class Plugin
|
|||||||
sbx.push_string path
|
sbx.push_string path
|
||||||
sbx.put_prop_string -2, "storage_path"
|
sbx.put_prop_string -2, "storage_path"
|
||||||
|
|
||||||
|
sbx.push_pointer info.dir.as(Void*)
|
||||||
|
path = sbx.require_pointer(-1).as String
|
||||||
|
sbx.pop
|
||||||
|
sbx.push_string path
|
||||||
|
sbx.put_prop_string -2, "info_dir"
|
||||||
|
|
||||||
def_helper_functions sbx
|
def_helper_functions sbx
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -152,9 +204,50 @@ class Plugin
|
|||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assert_manga_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Manga type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_chapter_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
||||||
|
obj["manga_title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Chapter type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_page_type(obj : JSON::Any)
|
||||||
|
obj["url"].as_s && obj["filename"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Page type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_manga(query : String)
|
||||||
|
if info.version == 1
|
||||||
|
raise Error.new "Manga searching is only available for plugins " \
|
||||||
|
"targeting API v2 or above"
|
||||||
|
end
|
||||||
|
json = eval_json "searchManga('#{query}')"
|
||||||
|
begin
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_manga_type obj
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
def list_chapters(query : String)
|
def list_chapters(query : String)
|
||||||
json = eval_json "listChapters('#{query}')"
|
json = eval_json "listChapters('#{query}')"
|
||||||
begin
|
begin
|
||||||
|
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"]
|
check_fields ["title", "chapters"]
|
||||||
|
|
||||||
ary = json["chapters"].as_a
|
ary = json["chapters"].as_a
|
||||||
@@ -168,7 +261,10 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
title = obj["title"]?
|
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
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -179,11 +275,15 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
|
if info.version > 1
|
||||||
|
assert_chapter_type json
|
||||||
|
else
|
||||||
check_fields ["title", "pages"]
|
check_fields ["title", "pages"]
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
if json["title"].to_s.empty?
|
||||||
raise "The `title` field of the chapter can not be empty"
|
raise "The `title` field of the chapter can not be empty"
|
||||||
end
|
end
|
||||||
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
@@ -194,7 +294,21 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
check_fields ["filename", "url"]
|
assert_page_type json
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_chapters(manga_id : String, after : Int64)
|
||||||
|
# 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
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
@@ -379,6 +493,27 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "storage"
|
sbx.put_prop_string -2, "storage"
|
||||||
|
|
||||||
|
if info.version > 1
|
||||||
|
sbx.push_proc 1 do |ptr|
|
||||||
|
env = Duktape::Sandbox.new ptr
|
||||||
|
key = env.require_string 0
|
||||||
|
|
||||||
|
env.get_global_string "info_dir"
|
||||||
|
info_dir = env.require_string -1
|
||||||
|
env.pop
|
||||||
|
info = Info.new info_dir
|
||||||
|
|
||||||
|
if value = info.settings[key]?
|
||||||
|
env.push_string value
|
||||||
|
else
|
||||||
|
env.push_undefined
|
||||||
|
end
|
||||||
|
|
||||||
|
env.call_success
|
||||||
|
end
|
||||||
|
sbx.put_prop_string -2, "settings"
|
||||||
|
end
|
||||||
|
|
||||||
sbx.put_prop_string -2, "mango"
|
sbx.put_prop_string -2, "mango"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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
|
||||||
10
src/queue.cr
10
src/queue.cr
@@ -70,7 +70,13 @@ class Queue
|
|||||||
ary = @id.split("-")
|
ary = @id.split("-")
|
||||||
if ary.size == 2
|
if ary.size == 2
|
||||||
@plugin_id = ary[0]
|
@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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ class Queue
|
|||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize(db_path : String? = nil)
|
def initialize(db_path : String? = nil)
|
||||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
@path = db_path || Config.current.queue_db_path.to_s
|
||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require "sanitize"
|
||||||
|
|
||||||
struct AdminRouter
|
struct AdminRouter
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
@@ -14,13 +16,13 @@ struct AdminRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user/edit" do |env|
|
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"]?
|
admin = env.params.query["admin"]?
|
||||||
if admin
|
if admin
|
||||||
admin = admin == "true"
|
admin = admin == "true"
|
||||||
end
|
end
|
||||||
error = env.params.query["error"]?
|
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
||||||
current_user = get_username env
|
|
||||||
new_user = username.nil? && admin.nil?
|
new_user = username.nil? && admin.nil?
|
||||||
layout "user-edit"
|
layout "user-edit"
|
||||||
end
|
end
|
||||||
@@ -66,10 +68,13 @@ struct AdminRouter
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/downloads" do |env|
|
get "/admin/downloads" do |env|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
layout "download-manager"
|
layout "download-manager"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/admin/subscriptions" do |env|
|
||||||
|
layout "subscription-manager"
|
||||||
|
end
|
||||||
|
|
||||||
get "/admin/missing" do |env|
|
get "/admin/missing" do |env|
|
||||||
layout "missing-items"
|
layout "missing-items"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ struct APIRouter
|
|||||||
"mtime" => Int64,
|
"mtime" => Int64,
|
||||||
"entries" => ["entry"],
|
"entries" => ["entry"],
|
||||||
"titles" => ["title"],
|
"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)),
|
}.merge(s %w(dir title id display_name cover_url)),
|
||||||
desc: "A manga title (a collection of entries and sub-titles)"
|
desc: "A manga title (a collection of entries and sub-titles)"
|
||||||
|
|
||||||
@@ -56,6 +61,23 @@ struct APIRouter
|
|||||||
"error" => String?,
|
"error" => String?,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Koa.schema "filter", {
|
||||||
|
"key" => String,
|
||||||
|
"type" => String,
|
||||||
|
"value" => String | Int32 | Int64 | Float32,
|
||||||
|
}
|
||||||
|
|
||||||
|
Koa.schema "subscription", {
|
||||||
|
"id" => String,
|
||||||
|
"plugin_id" => String,
|
||||||
|
"manga_id" => String,
|
||||||
|
"manga_title" => String,
|
||||||
|
"name" => String,
|
||||||
|
"created_at" => Int64,
|
||||||
|
"last_checked" => Int64,
|
||||||
|
"filters" => ["filter"],
|
||||||
|
}
|
||||||
|
|
||||||
Koa.describe "Authenticates a user", <<-MD
|
Koa.describe "Authenticates a user", <<-MD
|
||||||
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests
|
||||||
MD
|
MD
|
||||||
@@ -63,6 +85,12 @@ struct APIRouter
|
|||||||
"username" => String,
|
"username" => String,
|
||||||
"password" => String,
|
"password" => String,
|
||||||
}
|
}
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"session_id" => String?,
|
||||||
|
"is_admin" => Bool?,
|
||||||
|
}
|
||||||
Koa.tag "users"
|
Koa.tag "users"
|
||||||
post "/api/login" do |env|
|
post "/api/login" do |env|
|
||||||
begin
|
begin
|
||||||
@@ -71,11 +99,18 @@ struct APIRouter
|
|||||||
token = Storage.default.verify_user(username, password).not_nil!
|
token = Storage.default.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
"Authenticated"
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"session_id" => env.session.id,
|
||||||
|
"is_admin" => Storage.default.username_is_admin username,
|
||||||
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
e.message
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -114,7 +149,7 @@ struct APIRouter
|
|||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
e.message
|
send_text env, e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -151,11 +186,13 @@ struct APIRouter
|
|||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
e.message
|
send_text env, e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the book with title `tid`", <<-MD
|
Koa.describe "Returns the book with title `tid`", <<-MD
|
||||||
|
The entries and titles will be sorted by the default sorting method for the logged-in user.
|
||||||
|
- Supply the `percentage` query parameter to include the reading progress
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
- Supply the `depth` query parameter to control the depth of nested titles to return.
|
||||||
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
|
||||||
@@ -166,8 +203,7 @@ struct APIRouter
|
|||||||
Koa.path "tid", desc: "Title ID"
|
Koa.path "tid", desc: "Title ID"
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
Koa.query "depth"
|
Koa.query "depth"
|
||||||
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
|
Koa.query "percentage"
|
||||||
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
|
|
||||||
Koa.response 200, schema: "title"
|
Koa.response 200, schema: "title"
|
||||||
Koa.response 404, "Title not found"
|
Koa.response 404, "Title not found"
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
@@ -175,29 +211,104 @@ struct APIRouter
|
|||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.new
|
|
||||||
get_sort_opt
|
|
||||||
|
|
||||||
tid = env.params.url["tid"]
|
tid = env.params.url["tid"]
|
||||||
title = Library.default.get_title tid
|
title = Library.default.get_title tid
|
||||||
raise "Title ID `#{tid}` not found" if title.nil?
|
raise "Title ID `#{tid}` not found" if title.nil?
|
||||||
|
|
||||||
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
|
percentage = !env.params.query["percentage"]?.nil?
|
||||||
|
|
||||||
send_json env, title.build_json(slim: slim, depth: depth,
|
send_json env, title.build_json(slim: slim, depth: depth,
|
||||||
sort_context: {username: username,
|
sort_context: {username: username,
|
||||||
opt: sort_opt})
|
opt: sort_opt}, percentage: percentage)
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
e.message
|
send_text env, e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the sorting option of a title or the library", <<-MD
|
||||||
|
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
|
||||||
|
- If the query parameter `tid` is missing, returns the sorting option of the library.
|
||||||
|
MD
|
||||||
|
Koa.query "tid"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"method" => String?,
|
||||||
|
"ascend" => Bool?,
|
||||||
|
"error" => String?,
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
|
get "/api/sort_opt" do |env|
|
||||||
|
username = get_username env
|
||||||
|
|
||||||
|
tid = env.params.query["tid"]?
|
||||||
|
dir = if tid
|
||||||
|
(Library.default.get_title tid).not_nil!.dir
|
||||||
|
else
|
||||||
|
Library.default.dir
|
||||||
|
end
|
||||||
|
sort_opt = SortOptions.from_info_json dir, username
|
||||||
|
send_json env, sort_opt.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Updates the sorting option of a title or the library", <<-MD
|
||||||
|
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
|
||||||
|
- When the `tid` field is missing in the body, updates the sorting option of the library.
|
||||||
|
MD
|
||||||
|
Koa.body schema: {
|
||||||
|
"tid" => String?,
|
||||||
|
"method" => String,
|
||||||
|
"ascend" => Bool,
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
|
put "/api/sort_opt" do |env|
|
||||||
|
username = get_username env
|
||||||
|
|
||||||
|
tid = env.params.json["tid"]?.try &.as String
|
||||||
|
dir = if tid
|
||||||
|
(Library.default.get_title tid).not_nil!.dir
|
||||||
|
else
|
||||||
|
Library.default.dir
|
||||||
|
end
|
||||||
|
|
||||||
|
method = env.params.json["sort"].as String
|
||||||
|
ascend = env.params.json["ascend"].as Bool
|
||||||
|
sort_opt = SortOptions.new method, ascend
|
||||||
|
|
||||||
|
TitleInfo.new dir do |info|
|
||||||
|
info.sort_by[username] = sort_opt.to_tuple
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
Koa.describe "Returns the entire library with all titles and entries", <<-MD
|
||||||
|
The titles will be sorted by the default sorting method for the logged-in user.
|
||||||
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
|
||||||
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
|
||||||
|
- Supply the `percentage` query parameter to include the reading progress
|
||||||
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
|
||||||
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
- When `depth` is 0, returns the requested title without its sub-titles/entries
|
||||||
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
|
||||||
@@ -205,16 +316,162 @@ struct APIRouter
|
|||||||
MD
|
MD
|
||||||
Koa.query "slim"
|
Koa.query "slim"
|
||||||
Koa.query "depth"
|
Koa.query "depth"
|
||||||
|
Koa.query "percentage"
|
||||||
Koa.response 200, schema: {
|
Koa.response 200, schema: {
|
||||||
"dir" => String,
|
"dir" => String,
|
||||||
"titles" => ["title"],
|
"titles" => ["title"],
|
||||||
|
"title_percentage" => [Float64?],
|
||||||
}
|
}
|
||||||
Koa.tag "library"
|
Koa.tag "library"
|
||||||
get "/api/library" do |env|
|
get "/api/library" do |env|
|
||||||
|
username = get_username env
|
||||||
|
|
||||||
|
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||||
|
|
||||||
slim = !env.params.query["slim"]?.nil?
|
slim = !env.params.query["slim"]?.nil?
|
||||||
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
depth = env.params.query["depth"]?.try(&.to_i?) || -1
|
||||||
|
percentage = !env.params.query["percentage"]?.nil?
|
||||||
|
|
||||||
send_json env, Library.default.build_json(slim: slim, depth: depth)
|
send_json env, Library.default.build_json(slim: slim, depth: depth,
|
||||||
|
sort_context: {username: username,
|
||||||
|
opt: sort_opt}, percentage: percentage)
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the continue reading entries"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"entries" => ["entry"],
|
||||||
|
"entry_percentages" => [Float64],
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
|
get "/api/library/continue_reading" do |env|
|
||||||
|
username = get_username env
|
||||||
|
cr_entries = Library.default.get_continue_reading_entries username
|
||||||
|
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "success" do
|
||||||
|
j.bool true
|
||||||
|
end
|
||||||
|
j.field "entries" do
|
||||||
|
j.array do
|
||||||
|
cr_entries.each do |e|
|
||||||
|
j.raw e[:entry].build_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
j.field "entry_percentages" do
|
||||||
|
j.array do
|
||||||
|
cr_entries.each do |e|
|
||||||
|
j.number e[:percentage]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
send_json env, json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the start reading titles"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"titles" => ["title"],
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
|
get "/api/library/start_reading" do |env|
|
||||||
|
username = get_username env
|
||||||
|
titles = Library.default.get_start_reading_titles username
|
||||||
|
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "success" do
|
||||||
|
j.bool true
|
||||||
|
end
|
||||||
|
j.field "titles" do
|
||||||
|
j.array do
|
||||||
|
titles.each do |t|
|
||||||
|
j.raw t.build_json depth: 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
send_json env, json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the recently added items"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"items" => [{
|
||||||
|
"item" => "title | entry",
|
||||||
|
"percentage" => Float64,
|
||||||
|
"count" => Int32,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
Koa.tag "library"
|
||||||
|
get "/api/library/recently_added" do |env|
|
||||||
|
username = get_username env
|
||||||
|
ra_entries = Library.default.get_recently_added_entries username
|
||||||
|
|
||||||
|
json = JSON.build do |j|
|
||||||
|
j.object do
|
||||||
|
j.field "success" do
|
||||||
|
j.bool true
|
||||||
|
end
|
||||||
|
j.field "items" do
|
||||||
|
j.array do
|
||||||
|
ra_entries.each do |e|
|
||||||
|
j.object do
|
||||||
|
j.field "item" do
|
||||||
|
if e[:grouped_count] === 1
|
||||||
|
j.raw e[:entry].build_json
|
||||||
|
else
|
||||||
|
j.raw e[:entry].book.build_json depth: 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
j.field "percentage" do
|
||||||
|
j.number e[:percentage]
|
||||||
|
end
|
||||||
|
j.field "count" do
|
||||||
|
j.number e[:grouped_count]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
send_json env, json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Triggers a library scan"
|
Koa.describe "Triggers a library scan"
|
||||||
@@ -240,7 +497,7 @@ struct APIRouter
|
|||||||
}
|
}
|
||||||
get "/api/admin/thumbnail_progress" do |env|
|
get "/api/admin/thumbnail_progress" do |env|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"progress" => Library.default.thumbnail_generation_progress,
|
"progress" => Library.default.thumbnail_ctx.progress,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -250,6 +507,7 @@ struct APIRouter
|
|||||||
spawn do
|
spawn do
|
||||||
Library.default.generate_thumbnails
|
Library.default.generate_thumbnails
|
||||||
end
|
end
|
||||||
|
send_text env, ""
|
||||||
end
|
end
|
||||||
|
|
||||||
Koa.describe "Deletes a user with `username`"
|
Koa.describe "Deletes a user with `username`"
|
||||||
@@ -371,6 +629,38 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Sets the sort title of a title or an entry", <<-MD
|
||||||
|
When `eid` is provided, apply the sort title to the entry. Otherwise, apply the sort title to the title identified by `tid`.
|
||||||
|
MD
|
||||||
|
Koa.tags ["admin", "library"]
|
||||||
|
Koa.path "tid", desc: "Title ID"
|
||||||
|
Koa.query "eid", desc: "Entry ID", required: false
|
||||||
|
Koa.query "name", desc: "The new sort title"
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
|
put "/api/admin/sort_title/:tid" do |env|
|
||||||
|
username = get_username env
|
||||||
|
begin
|
||||||
|
title = (Library.default.get_title env.params.url["tid"])
|
||||||
|
.not_nil!
|
||||||
|
name = env.params.query["name"]?
|
||||||
|
entry = env.params.query["eid"]?
|
||||||
|
if entry.nil?
|
||||||
|
title.set_sort_title name, username
|
||||||
|
else
|
||||||
|
eobj = title.get_entry entry
|
||||||
|
eobj.set_sort_title name, username unless eobj.nil?
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
else
|
||||||
|
send_json env, {"success" => true}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
ws "/api/admin/mangadex/queue" do |socket, env|
|
ws "/api/admin/mangadex/queue" do |socket, env|
|
||||||
interval_raw = env.params.query["interval"]?
|
interval_raw = env.params.query["interval"]?
|
||||||
interval = (interval_raw.to_i? if interval_raw) || 5
|
interval = (interval_raw.to_i? if interval_raw) || 5
|
||||||
@@ -535,6 +825,209 @@ struct APIRouter
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns a list of available plugins"
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"plugins" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin" do |env|
|
||||||
|
begin
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"plugins" => Plugin.list,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the metadata of a plugin"
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"info" => {
|
||||||
|
"dir" => String,
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
"placeholder" => String,
|
||||||
|
"wait_seconds" => Int32,
|
||||||
|
"version" => Int32,
|
||||||
|
"settings" => {} of String => String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin/info" do |env|
|
||||||
|
begin
|
||||||
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"info" => plugin.info,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Searches for manga matching the given query from a plugin", <<-MD
|
||||||
|
Only available for plugins targeting API v2 or above.
|
||||||
|
MD
|
||||||
|
Koa.tags ["admin", "downloader"]
|
||||||
|
Koa.query "plugin", schema: String
|
||||||
|
Koa.query "query", schema: String
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"manga" => [{
|
||||||
|
"id" => String,
|
||||||
|
"title" => String,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin/search" do |env|
|
||||||
|
begin
|
||||||
|
query = env.params.query["query"].as String
|
||||||
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
|
manga_ary = plugin.search_manga(query).as_a
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"manga" => manga_ary,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Creates a new subscription"
|
||||||
|
Koa.tags ["admin", "downloader", "subscription"]
|
||||||
|
Koa.body schema: {
|
||||||
|
"plugin" => String,
|
||||||
|
"manga" => String,
|
||||||
|
"manga_id" => String,
|
||||||
|
"name" => String,
|
||||||
|
"filters" => ["filter"],
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
|
post "/api/admin/plugin/subscriptions" do |env|
|
||||||
|
begin
|
||||||
|
plugin_id = env.params.json["plugin"].as String
|
||||||
|
manga_title = env.params.json["manga"].as String
|
||||||
|
manga_id = env.params.json["manga_id"].as String
|
||||||
|
filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f|
|
||||||
|
Filter.from_json f.to_json
|
||||||
|
end
|
||||||
|
name = env.params.json["name"].as String
|
||||||
|
|
||||||
|
sub = Subscription.new plugin_id, manga_id, manga_title, name
|
||||||
|
sub.filters = filters
|
||||||
|
|
||||||
|
plugin = Plugin.new plugin_id
|
||||||
|
plugin.subscribe sub
|
||||||
|
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Returns the list of subscriptions for a plugin"
|
||||||
|
Koa.tags ["admin", "downloader", "subscription"]
|
||||||
|
Koa.query "plugin", desc: "The ID of the plugin"
|
||||||
|
Koa.response 200, schema: {
|
||||||
|
"success" => Bool,
|
||||||
|
"error" => String?,
|
||||||
|
"subscriptions" => ["subscription"],
|
||||||
|
}
|
||||||
|
get "/api/admin/plugin/subscriptions" do |env|
|
||||||
|
begin
|
||||||
|
pid = env.params.query["plugin"].as String
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
"subscriptions" => Plugin.new(pid).list_subscriptions,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Deletes a subscription"
|
||||||
|
Koa.tags ["admin", "downloader", "subscription"]
|
||||||
|
Koa.body schema: {
|
||||||
|
"plugin" => String,
|
||||||
|
"subscription" => String,
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
|
delete "/api/admin/plugin/subscriptions" do |env|
|
||||||
|
begin
|
||||||
|
pid = env.params.query["plugin"].as String
|
||||||
|
sid = env.params.query["subscription"].as String
|
||||||
|
|
||||||
|
Plugin.new(pid).unsubscribe sid
|
||||||
|
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Koa.describe "Checks for updates for a subscription"
|
||||||
|
Koa.tags ["admin", "downloader", "subscription"]
|
||||||
|
Koa.body schema: {
|
||||||
|
"plugin" => String,
|
||||||
|
"subscription" => String,
|
||||||
|
}
|
||||||
|
Koa.response 200, schema: "result"
|
||||||
|
post "/api/admin/plugin/subscriptions/update" do |env|
|
||||||
|
pid = env.params.query["plugin"].as String
|
||||||
|
sid = env.params.query["subscription"].as String
|
||||||
|
|
||||||
|
Plugin.new(pid).check_subscription sid
|
||||||
|
|
||||||
|
send_json env, {
|
||||||
|
"success" => true,
|
||||||
|
}.to_json
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
Koa.describe "Lists the chapters in a title from a plugin"
|
Koa.describe "Lists the chapters in a title from a plugin"
|
||||||
Koa.tags ["admin", "downloader"]
|
Koa.tags ["admin", "downloader"]
|
||||||
Koa.query "plugin", schema: String
|
Koa.query "plugin", schema: String
|
||||||
@@ -544,7 +1037,7 @@ struct APIRouter
|
|||||||
"error" => String?,
|
"error" => String?,
|
||||||
"chapters?" => [{
|
"chapters?" => [{
|
||||||
"id" => String,
|
"id" => String,
|
||||||
"title" => String,
|
"title?" => String,
|
||||||
}],
|
}],
|
||||||
"title" => String?,
|
"title" => String?,
|
||||||
}
|
}
|
||||||
@@ -554,8 +1047,14 @@ struct APIRouter
|
|||||||
plugin = Plugin.new env.params.query["plugin"].as String
|
plugin = Plugin.new env.params.query["plugin"].as String
|
||||||
|
|
||||||
json = plugin.list_chapters query
|
json = plugin.list_chapters query
|
||||||
|
|
||||||
|
if plugin.info.version == 1
|
||||||
chapters = json["chapters"]
|
chapters = json["chapters"]
|
||||||
title = json["title"]
|
title = json["title"]
|
||||||
|
else
|
||||||
|
chapters = json
|
||||||
|
title = nil
|
||||||
|
end
|
||||||
|
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => true,
|
"success" => true,
|
||||||
@@ -593,7 +1092,7 @@ struct APIRouter
|
|||||||
|
|
||||||
jobs = chapters.map { |ch|
|
jobs = chapters.map { |ch|
|
||||||
Queue::Job.new(
|
Queue::Job.new(
|
||||||
"#{plugin.info.id}-#{ch["id"]}",
|
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
||||||
"", # manga_id
|
"", # manga_id
|
||||||
ch["title"].as_s,
|
ch["title"].as_s,
|
||||||
manga_title,
|
manga_title,
|
||||||
@@ -643,7 +1142,7 @@ struct APIRouter
|
|||||||
e_tag = "W/#{file_hash}"
|
e_tag = "W/#{file_hash}"
|
||||||
if e_tag == prev_e_tag
|
if e_tag == prev_e_tag
|
||||||
env.response.status_code = 304
|
env.response.status_code = 304
|
||||||
""
|
send_text env, ""
|
||||||
else
|
else
|
||||||
sizes = entry.page_dimensions
|
sizes = entry.page_dimensions
|
||||||
env.response.headers["ETag"] = e_tag
|
env.response.headers["ETag"] = e_tag
|
||||||
@@ -677,6 +1176,7 @@ struct APIRouter
|
|||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
|
send_text env, e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,15 @@ struct MainRouter
|
|||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
get_and_save_sort_opt title.dir
|
get_and_save_sort_opt title.dir
|
||||||
|
|
||||||
|
sorted_titles = title.sorted_titles username, sort_opt
|
||||||
entries = title.sorted_entries username, sort_opt
|
entries = title.sorted_entries username, sort_opt
|
||||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
percentage = title.load_percentage_for_all_entries username, sort_opt
|
||||||
title_percentage = title.titles.map &.load_percentage username
|
title_percentage = title.titles.map &.load_percentage username
|
||||||
|
title_percentage_map = {} of String => Float64
|
||||||
|
title_percentage.each_with_index do |tp, i|
|
||||||
|
t = title.titles[i]
|
||||||
|
title_percentage_map[t.id] = tp
|
||||||
|
end
|
||||||
|
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
@@ -74,16 +80,6 @@ struct MainRouter
|
|||||||
|
|
||||||
get "/download/plugins" do |env|
|
get "/download/plugins" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.query["plugin"]?
|
|
||||||
plugins = Plugin.list
|
|
||||||
plugin = nil
|
|
||||||
|
|
||||||
if id
|
|
||||||
plugin = Plugin.new id
|
|
||||||
elsif !plugins.empty?
|
|
||||||
plugin = Plugin.new plugins[0][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
layout "plugin-download"
|
layout "plugin-download"
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ class Server
|
|||||||
AdminRouter.new
|
AdminRouter.new
|
||||||
ReaderRouter.new
|
ReaderRouter.new
|
||||||
APIRouter.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
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new
|
||||||
|
|||||||
@@ -342,6 +342,67 @@ class Storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_title_sort_title(title_id : String)
|
||||||
|
sort_title = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
sort_title =
|
||||||
|
db.query_one? "Select sort_title from titles where id = (?)",
|
||||||
|
title_id, as: String | Nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_title_sort_title(title_id : String, sort_title : String | Nil)
|
||||||
|
sort_title = nil if sort_title == ""
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "update titles set sort_title = (?) where id = (?)",
|
||||||
|
sort_title, title_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entry_sort_title(entry_id : String)
|
||||||
|
sort_title = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
sort_title =
|
||||||
|
db.query_one? "Select sort_title from ids where id = (?)",
|
||||||
|
entry_id, as: String | Nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entries_sort_title(ids : Array(String))
|
||||||
|
results = Hash(String, String | Nil).new
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id, sort_title from ids where id in " \
|
||||||
|
"(#{ids.join "," { |id| "'#{id}'" }})" do |rs|
|
||||||
|
rs.each do
|
||||||
|
id = rs.read String
|
||||||
|
sort_title = rs.read String | Nil
|
||||||
|
results[id] = sort_title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
results
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_entry_sort_title(entry_id : String, sort_title : String | Nil)
|
||||||
|
sort_title = nil if sort_title == ""
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "update ids set sort_title = (?) where id = (?)",
|
||||||
|
sort_title, entry_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def save_thumbnail(id : String, img : Image)
|
def save_thumbnail(id : String, img : Image)
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
get_db do |db|
|
get_db do |db|
|
||||||
@@ -558,6 +619,20 @@ class Storage
|
|||||||
{token, expires}
|
{token, expires}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def count_titles : Int32
|
||||||
|
count = 0
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select count(*) from titles" do |rs|
|
||||||
|
rs.each do
|
||||||
|
count = rs.read Int32
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
count
|
||||||
|
end
|
||||||
|
|
||||||
def close
|
def close
|
||||||
MainFiber.run do
|
MainFiber.run do
|
||||||
unless @db.nil?
|
unless @db.nil?
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -87,30 +87,49 @@ def env_is_true?(key : String) : Bool
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
||||||
ary = titles
|
cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt
|
||||||
|
cached_titles = LRUCache.get cache_key
|
||||||
|
return cached_titles if cached_titles.is_a? Array(Title)
|
||||||
|
|
||||||
case opt.method
|
case opt.method
|
||||||
when .time_modified?
|
when .time_modified?
|
||||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
compare_numerically a.title, b.title }
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
when .progress?
|
when .progress?
|
||||||
ary.sort! do |a, b|
|
ary = titles.sort do |a, b|
|
||||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||||
compare_numerically a.title, b.title
|
compare_numerically a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
|
when .title?
|
||||||
|
ary = titles.sort do |a, b|
|
||||||
|
compare_numerically a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
unless opt.method.auto?
|
unless opt.method.auto?
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
"Auto instead"
|
"Auto instead"
|
||||||
end
|
end
|
||||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title }
|
||||||
end
|
end
|
||||||
|
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
LRUCache.set generate_cache_entry cache_key, ary
|
||||||
ary
|
ary
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def remove_sorted_titles_cache(titles : Array(Title),
|
||||||
|
sort_methods : Array(SortMethod),
|
||||||
|
username : String)
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sort_methods.each do |sort_method|
|
||||||
|
sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
|
||||||
|
titles, SortOptions.new(sort_method, ascend)
|
||||||
|
LRUCache.invalidate sorted_titles_cache_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class String
|
class String
|
||||||
# Returns the similarity (in [0, 1]) of two paths.
|
# Returns the similarity (in [0, 1]) of two paths.
|
||||||
# For the two paths, separate them into arrays of components, count the
|
# For the two paths, separate them into arrays of components, count the
|
||||||
@@ -144,3 +163,12 @@ def sanitize_filename(str : String) : String
|
|||||||
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
||||||
sanitized.size > 0 ? sanitized : random_str
|
sanitized.size > 0 ? sanitized : random_str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_cache_and_exit(path : String)
|
||||||
|
File.delete path
|
||||||
|
Logger.fatal "Invalid library cache deleted. Mango needs to " \
|
||||||
|
"perform a full reset to recover from this. " \
|
||||||
|
"Pleae restart Mango. This is NOT a bug."
|
||||||
|
Logger.fatal "Exiting"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|||||||
@@ -39,13 +39,28 @@ macro send_error_page(msg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
macro send_img(env, img)
|
macro send_img(env, img)
|
||||||
|
cors
|
||||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_token_from_auth_header(env) : String?
|
||||||
|
value = env.request.headers["Authorization"]
|
||||||
|
if value && value.starts_with? "Bearer"
|
||||||
|
session_id = value.split(" ")[1]
|
||||||
|
return Kemal::Session.get(session_id).try &.string? "token"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
begin
|
begin
|
||||||
token = env.session.string "token"
|
# Check if we can get the session id from the cookie
|
||||||
(Storage.default.verify_token token).not_nil!
|
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
|
rescue e
|
||||||
if Config.current.disable_login
|
if Config.current.disable_login
|
||||||
Config.current.default_username
|
Config.current.default_username
|
||||||
@@ -57,12 +72,29 @@ macro get_username(env)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro cors
|
||||||
|
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
|
||||||
|
"DELETE,OPTIONS"
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
|
||||||
|
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
|
||||||
|
"Authorization"
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
end
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
|
cors
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
env.response.print json
|
env.response.print json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_text(env, text)
|
||||||
|
cors
|
||||||
|
env.response.content_type = "text/plain"
|
||||||
|
env.response.print text
|
||||||
|
end
|
||||||
|
|
||||||
def send_attachment(env, path)
|
def send_attachment(env, path)
|
||||||
|
cors
|
||||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,6 @@
|
|||||||
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
<a class="uk-button uk-button-danger" href="<%= base_url %>logout">Log Out</a>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/admin.js"></script>
|
<script src="<%= base_url %>js/admin.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
<% if page == "home" && item.is_a? Entry %>
|
<% if page == "home" && item.is_a? Entry %>
|
||||||
<%= "uk-margin-remove-bottom" %>
|
<%= "uk-margin-remove-bottom" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
" data-title="<%= HTML.escape(item.display_name) %>"><%= HTML.escape(item.display_name) %>
|
" data-title="<%= HTML.escape(item.display_name) %>"
|
||||||
|
data-file-title="<%= HTML.escape(item.title || "") %>"
|
||||||
|
data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %>
|
||||||
</h3>
|
</h3>
|
||||||
<% if page == "home" && item.is_a? Entry %>
|
<% if page == "home" && item.is_a? Entry %>
|
||||||
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
|
<a class="uk-card-title break-word uk-margin-remove-top uk-text-meta uk-display-inline-block no-modal" data-title="<%= HTML.escape(item.book.display_name) %>" href="<%= base_url %>book/<%= item.book.id %>"><%= HTML.escape(item.book.display_name) %></a>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||||
|
<link rel="manifest" href="<%= base_url %>manifest.json">
|
||||||
|
|
||||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||||
|
|||||||
@@ -24,16 +24,10 @@
|
|||||||
<template x-if="job.plugin_id">
|
<template x-if="job.plugin_id">
|
||||||
<td x-text="job.title"></td>
|
<td x-text="job.title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
<template x-if="job.plugin_id">
|
||||||
<td x-text="job.manga_title"></td>
|
<td x-text="job.manga_title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
|
||||||
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
<td x-text="`${moment(job.time).fromNow()}`"></td>
|
||||||
|
|||||||
@@ -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">
|
<ul class="uk-nav-sub">
|
||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
|
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,11 +33,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-position-top">
|
<div class="uk-position-top">
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<div class="uk-navbar-left uk-hidden@s">
|
<div class="uk-navbar-left uk-hidden@m">
|
||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
<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">
|
<ul class="uk-navbar-nav">
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>library">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
@@ -51,13 +52,14 @@
|
|||||||
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
<li><a href="<%= base_url %>download/plugins">Plugins</a></li>
|
||||||
<li class="uk-nav-divider"></li>
|
<li class="uk-nav-divider"></li>
|
||||||
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
<li><a href="<%= base_url %>admin/downloads">Download Manager</a></li>
|
||||||
|
<li><a href="<%= base_url %>admin/subscriptions">Subscription Manager</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
<div class="uk-navbar-right uk-visible@m">
|
||||||
<ul class="uk-navbar-nav">
|
<ul class="uk-navbar-nav">
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
<% hash = {
|
<% hash = {
|
||||||
"auto" => "Auto",
|
"auto" => "Auto",
|
||||||
|
"title" => "Name",
|
||||||
"time_modified" => "Date Modified",
|
"time_modified" => "Date Modified",
|
||||||
"progress" => "Progress"
|
"progress" => "Progress"
|
||||||
} %>
|
} %>
|
||||||
|
|||||||
@@ -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 %>
|
|
||||||
@@ -1,37 +1,42 @@
|
|||||||
<% if plugins.empty? %>
|
<div x-data="component()" x-init="init()" x-cloak>
|
||||||
<div class="uk-container uk-text-center">
|
<div class="uk-grid-small" uk-grid style="margin-bottom:40px;">
|
||||||
|
<div class="uk-container uk-text-center" x-show="plugins.length === 0" style="width:100%">
|
||||||
<h2>No Plugins Found</h2>
|
<h2>No Plugins Found</h2>
|
||||||
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
<p>We could't find any plugins in the directory <code><%= Config.current.plugin_path %></code>.</p>
|
||||||
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
<p>You can download official plugins from the <a href="https://github.com/hkalexling/mango-plugins">Mango plugins repository</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% else %>
|
<div x-show="plugins.length > 0" style="width:100%">
|
||||||
<h2 class=uk-title>Download with Plugins</h2>
|
<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-width-3-4@m uk-child-width-1-1">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="search-input"> </label>
|
|
||||||
<div class="uk-form-controls">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-expand">
|
<div class="uk-width-expand">
|
||||||
<div class="uk-margin">
|
<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">
|
<div class="uk-form-controls">
|
||||||
<select id="plugin-select" class="uk-select">
|
<select class="uk-select" x-model="pid" @change="pluginChanged()">
|
||||||
<% plugins.each do |p| %>
|
<template x-for="p in plugins" :key="p">
|
||||||
<option value="<%= p[:id] %>"><%= p[:title] %></option>
|
<option :value="p.id" x-text="p.title"></option>
|
||||||
<% end %>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-auto">
|
<div class="uk-width-auto">
|
||||||
<div class="uk-margin">
|
<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;">
|
<div class="uk-form-controls" style="padding-top: 10px;">
|
||||||
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
<span uk-icon="info" uk-toggle="target: #toggle"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,39 +44,171 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-for="entry, idx in Object.entries(info).filter(tp => !['id', 'settings'].includes(tp[0]))" :key="idx">
|
||||||
<dl class="uk-description-list" id="toggle" hidden>
|
<dl class="uk-description-list" id="toggle" hidden>
|
||||||
<% plugin.not_nil!.info.each do |k, v| %>
|
<dt x-text="entry[0] === 'version' ? 'Target API Version' : entry[0].replace('_', ' ')"></dt>
|
||||||
<dt><%= k %></dt>
|
<dd x-text="entry[1]"></dd>
|
||||||
<dd><%= v.to_s %></dd>
|
|
||||||
<% end %>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div id="table" class="uk-margin-large-top" hidden>
|
<template x-if="manga">
|
||||||
<h3 id="title-text"></h3>
|
<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">
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
<div x-show="chapters && chapters.length > 0 && chapters.length <= chaptersLimit">
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<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>
|
||||||
|
<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 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>
|
||||||
|
</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>
|
<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">
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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 %>
|
<% content_for "script" do %>
|
||||||
<% if plugin %>
|
|
||||||
<script>
|
|
||||||
var pid = "<%= plugin.not_nil!.info.id %>";
|
|
||||||
</script>
|
|
||||||
<% end %>
|
|
||||||
<%= render_component "jquery-ui" %>
|
<%= render_component "jquery-ui" %>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.combined.min.js"></script>
|
<%= render_component "moment" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/plugin-download.js"></script>
|
<script src="<%= base_url %>js/plugin-download.js"></script>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
`" />
|
`" />
|
||||||
|
|
||||||
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
|
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false ^ enableRightToLeft)"></div>
|
||||||
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
|
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true ^ enableRightToLeft)"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +114,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-margin uk-form-horizontal" x-show="mode !== 'continuous'">
|
||||||
|
<label class="uk-form-label" for="enable-right-to-left">Right to Left</label>
|
||||||
|
<div class="uk-form-controls">
|
||||||
|
<input id="enable-right-to-left" class="uk-checkbox" type="checkbox" x-model="enableRightToLeft" @change="enableRightToLeftChanged()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr class="uk-divider-icon">
|
<hr class="uk-divider-icon">
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
|
|||||||
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 %>
|
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
<h2 class=uk-title data-file-title="<%= HTML.escape(title.title) %>" data-sort-title="<%= HTML.escape(title.sort_title_db || "") %>">
|
||||||
|
<span><%= title.display_name %></span>
|
||||||
|
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||||
@@ -59,8 +60,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
<% title.titles.each_with_index do |item, i| %>
|
<% sorted_titles.each do |item| %>
|
||||||
<% progress = title_percentage[i] %>
|
<% progress = title_percentage_map[item.id] %>
|
||||||
<%= render_component "card" %>
|
<%= render_component "card" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +90,13 @@
|
|||||||
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<label class="uk-form-label" for="sort-title">Sort Title</label>
|
||||||
|
<div class="uk-inline">
|
||||||
|
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
||||||
|
<input class="uk-input" type="text" name="sort-title" id="sort-title-field">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label">Cover Image</label>
|
<label class="uk-form-label">Cover Image</label>
|
||||||
<div class="uk-grid">
|
<div class="uk-grid">
|
||||||
|
|||||||
Reference in New Issue
Block a user