Compare commits

..

42 Commits

Author SHA1 Message Date
Alex Ling
cdfc9f3a93 Show manga title on subscription manager 2022-03-19 11:39:20 +00:00
Alex Ling
2cc1a06b4e Reset table sort options 2022-03-19 11:23:23 +00:00
Alex Ling
95eb208901 Confirmation before deleting subscriptions 2022-03-19 11:13:48 +00:00
Alex Ling
acefa00b12 Merge branch 'dev' into feature/plugin-v2 2022-03-18 11:57:49 +00:00
Alex Ling
8dfe580e39 Merge branch 'dev' into feature/plugin-v2 2022-03-15 13:58:48 +00:00
Alex Ling
c290eee90b Fix BigFloat conversion issue 2022-03-15 13:58:16 +00:00
Alex Ling
2ade6ebd8c Document the subscription endpoints 2022-02-20 06:09:16 +00:00
Alex Ling
b56a9cb50c Clean up 2022-02-20 04:48:05 +00:00
Alex Ling
fd650bdf45 Fix null pid 2022-02-20 03:58:36 +00:00
Alex Ling
c80855bb5d Merge branch 'dev' into feature/plugin-v2 2022-02-20 02:46:53 +00:00
Alex Ling
0adcd3a634 Update last checked even when no chapters found 2022-01-23 12:24:19 +00:00
Alex Ling
f6bd3fa15d Update last checked from manager page 2022-01-23 12:23:58 +00:00
Alex Ling
be3babda37 Show target API version 2022-01-23 11:06:07 +00:00
Alex Ling
cae832911d Fix timestamp precision issue in plugin 2022-01-23 09:47:00 +00:00
Alex Ling
2c7c29fef9 Trigger subscription update from manager page 2022-01-23 09:45:45 +00:00
Alex Ling
968f6246de Fix actions on download manager 2022-01-23 08:46:19 +00:00
Alex Ling
d862c386f1 Base64 encode chapter IDs 2022-01-23 06:08:20 +00:00
Alex Ling
031df3a2bc Finish plugin updater 2022-01-22 14:50:37 +00:00
Alex Ling
5fac8c6a60 Merge branch 'dev' into feature/plugin-v2 2022-01-21 13:20:23 +00:00
Alex Ling
748386f0af WIP 2021-12-31 14:49:37 +00:00
Alex Ling
031ea7ef16 Finish subscription manager page 2021-11-25 12:31:17 +00:00
Alex Ling
b76d4645cc Add subscription manager page (WIP) 2021-11-21 04:08:17 +00:00
Alex Ling
fe6f884d94 Store manga ID with subscriptions 2021-11-20 11:08:36 +00:00
Alex Ling
5442d124af Subscription management API endpoints 2021-11-20 09:47:18 +00:00
Alex Ling
352236ab65 Delete unnecessary raise for debugging 2021-11-20 08:50:06 +00:00
Alex Ling
e44213960f Refactor date filtering and use native date picker 2021-11-20 08:10:51 +00:00
Alex Ling
87e54aa89c Merge branch 'dev' into feature/plugin-v2
Fixes #244 again in this branch
2021-11-18 14:55:15 +00:00
Alex Ling
a45bea01c9 Fix linter 2021-09-04 02:32:46 +00:00
Alex Ling
198913db3e Remove MangaDex files that are no longer needed 2021-09-04 02:31:30 +00:00
Alex Ling
238860d52d Simplify subscription JSON parsing 2021-09-04 02:26:18 +00:00
Alex Ling
ae1c36263b Merge branch 'dev' into feature/plugin-v2 2021-09-04 02:25:19 +00:00
Alex Ling
b7aee1e903 WIP 2021-08-17 04:11:58 +00:00
Alex Ling
f56ce2313c WIP 2021-07-11 11:19:08 +00:00
Alex Ling
259f6cb285 Add endpoint for plugin subscription 2021-07-11 02:45:14 +00:00
Alex Ling
3b19883dde Use auto overflow tables
cherry-picked from a612500b0f
2021-06-07 07:35:44 +00:00
Alex Ling
6844860065 Revert "Subscription manager"
This reverts commit a612500b0f.
2021-06-07 07:32:32 +00:00
Alex Ling
9eb699ea3b Add plugin subscription types 2021-06-07 07:04:49 +00:00
Alex Ling
59bcb4db3b WIP 2021-06-05 10:53:45 +00:00
Alex Ling
87c479bf42 WIP 2021-05-30 10:54:27 +00:00
Alex Ling
e0713ccde8 WIP 2021-05-22 07:24:30 +00:00
Alex Ling
a571d21cba WIP 2021-05-15 13:37:11 +00:00
Alex Ling
23541f457e Add "title_title" to slim JSON 2021-05-15 06:54:12 +00:00
53 changed files with 1904 additions and 2853 deletions

View File

@@ -12,4 +12,3 @@ Layout/LineLength:
MaxLength: 80 MaxLength: 80
Excluded: Excluded:
- src/routes/api.cr - src/routes/api.cr
- spec/plugin_spec.cr

View File

@@ -1,11 +0,0 @@
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: { requireConfigFile: false },
plugins: ['prettier'],
rules: {
eqeqeq: ['error', 'always'],
'object-shorthand': ['error', 'always'],
'prettier/prettier': 'error',
'no-var': 'error',
},
};

View File

@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}

View File

@@ -29,7 +29,6 @@ test:
check: check:
crystal tool format --check crystal tool format --check
./bin/ameba ./bin/ameba
yarn lint
arm32v7: arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7 crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7

View File

@@ -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.27.0 Mango - Manga Server and Web Reader. Version 0.25.0
Usage: Usage:
@@ -88,16 +88,15 @@ upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins plugin_path: ~/mango/plugins
download_timeout_seconds: 30 download_timeout_seconds: 30
library_cache_path: ~/mango/library.yml.gz library_cache_path: ~/mango/library.yml.gz
cache_enabled: true cache_enabled: false
cache_size_mbs: 50 cache_size_mbs: 50
cache_log_enabled: true cache_log_enabled: true
disable_login: false disable_login: false
default_username: "" default_username: ""
auth_proxy_header_name: "" auth_proxy_header_name: ""
plugin_update_interval_hours: 24
``` ```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks - `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work. - You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`. - By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.

View File

@@ -5,17 +5,13 @@ const minifyCss = require('gulp-minify-css');
const less = require('gulp-less'); const less = require('gulp-less');
gulp.task('copy-img', () => { gulp.task('copy-img', () => {
return gulp return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.src('node_modules/uikit/src/images/backgrounds/*.svg') .pipe(gulp.dest('public/img'));
.pipe(gulp.dest('public/img'));
}); });
gulp.task('copy-font', () => { gulp.task('copy-font', () => {
return gulp return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.src( .pipe(gulp.dest('public/webfonts'));
'node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**',
)
.pipe(gulp.dest('public/webfonts'));
}); });
// Copy files from node_modules // Copy files from node_modules
@@ -23,60 +19,49 @@ gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less // Compile less
gulp.task('less', () => { gulp.task('less', () => {
return gulp return gulp.src([
.src(['public/css/mango.less', 'public/css/tags.less']) 'public/css/mango.less',
.pipe(less()) 'public/css/tags.less'
.pipe(gulp.dest('public/css')); ])
.pipe(less())
.pipe(gulp.dest('public/css'));
}); });
// Transpile and minify JS files and output to dist // Transpile and minify JS files and output to dist
gulp.task('babel', () => { gulp.task('babel', () => {
return gulp return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
.src(['public/js/*.js', '!public/js/*.min.js']) .pipe(babel({
.pipe( presets: [
babel({ ['@babel/preset-env', {
presets: [ targets: '>0.25%, not dead, ios>=9'
[ }]
'@babel/preset-env', ],
{ }))
targets: '>0.25%, not dead, ios>=9', .pipe(minify({
}, removeConsole: true,
], builtIns: false
], }))
}), .pipe(gulp.dest('dist/js'));
)
.pipe(
minify({
removeConsole: true,
builtIns: false,
}),
)
.pipe(gulp.dest('dist/js'));
}); });
// Minify CSS and output to dist // Minify CSS and output to dist
gulp.task('minify-css', () => { gulp.task('minify-css', () => {
return gulp return gulp.src('public/css/*.css')
.src('public/css/*.css') .pipe(minifyCss())
.pipe(minifyCss()) .pipe(gulp.dest('dist/css'));
.pipe(gulp.dest('dist/css'));
}); });
// Copy static files (includeing images) to dist // Copy static files (includeing images) to dist
gulp.task('copy-files', () => { gulp.task('copy-files', () => {
return gulp return gulp.src([
.src( 'public/*.*',
[ 'public/img/**',
'public/*.*', 'public/webfonts/*',
'public/img/**', 'public/js/*.min.js'
'public/webfonts/*', ], {
'public/js/*.min.js', base: 'public'
], })
{ .pipe(gulp.dest('dist'));
base: 'public',
},
)
.pipe(gulp.dest('dist'));
}); });
// Set up the public folder for development // Set up the public folder for development

View File

@@ -6,25 +6,20 @@
"author": "Alex Ling <hkalexling@gmail.com>", "author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.18.9",
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0", "all-contributors-cli": "^6.19.0",
"eslint": "^8.22.0",
"eslint-plugin-prettier": "^4.2.1",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0", "gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1", "gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1", "gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4", "gulp-minify-css": "^1.2.4",
"less": "^3.11.3", "less": "^3.11.3"
"prettier": "^2.7.1"
}, },
"scripts": { "scripts": {
"uglify": "gulp", "uglify": "gulp"
"lint": "eslint public/js *.js --ext .js"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0", "@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "~3.14.0" "uikit": "^3.5.4"
} }
} }

View File

@@ -1,56 +1,58 @@
const component = () => { const component = () => {
return { return {
progress: 1.0, progress: 1.0,
generating: false, generating: false,
scanning: false, scanning: false,
scanTitles: 0, scanTitles: 0,
scanMs: -1, scanMs: -1,
themeSetting: '', themeSetting: '',
init() { init() {
this.getProgress(); this.getProgress();
setInterval(() => { setInterval(() => {
this.getProgress(); this.getProgress();
}, 5000); }, 5000);
const setting = loadThemeSetting(); const setting = loadThemeSetting();
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1); this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
}, },
themeChanged(event) { themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase(); const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting); saveThemeSetting(newSetting);
setTheme(); setTheme();
}, },
scan() { scan() {
if (this.scanning) return; if (this.scanning) return;
this.scanning = true; this.scanning = true;
this.scanMs = -1; this.scanMs = -1;
this.scanTitles = 0; this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`) $.post(`${base_url}api/admin/scan`)
.then((data) => { .then(data => {
this.scanMs = data.milliseconds; this.scanMs = data.milliseconds;
this.scanTitles = data.titles; this.scanTitles = data.titles;
}) })
.catch((e) => { .catch(e => {
alert('danger', `Failed to trigger a scan. Error: ${e}`); alert('danger', `Failed to trigger a scan. Error: ${e}`);
}) })
.always(() => { .always(() => {
this.scanning = false; this.scanning = false;
}); });
}, },
generateThumbnails() { generateThumbnails() {
if (this.generating) return; if (this.generating) return;
this.generating = true; this.generating = true;
this.progress = 0.0; this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`).then(() => { $.post(`${base_url}api/admin/generate_thumbnails`)
this.getProgress(); .then(() => {
}); this.getProgress()
}, });
getProgress() { },
$.get(`${base_url}api/admin/thumbnail_progress`).then((data) => { getProgress() {
this.progress = data.progress; $.get(`${base_url}api/admin/thumbnail_progress`)
this.generating = data.progress > 0; .then(data => {
}); this.progress = data.progress;
}, this.generating = data.progress > 0;
}; });
},
};
}; };

View File

@@ -1,6 +1,6 @@
const alert = (level, text) => { const alert = (level, text) => {
$('#alert').empty(); $('#alert').empty();
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`; const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
$('#alert').append(html); $('#alert').append(html);
$('html, body').animate({ scrollTop: 0 }); $("html, body").animate({ scrollTop: 0 });
}; };

View File

@@ -11,7 +11,7 @@
* @param {string} selector - The jQuery selector to the root element * @param {string} selector - The jQuery selector to the root element
*/ */
const setProp = (key, prop, selector = '#root') => { const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop; $(selector).get(0).__x.$data[key] = prop;
}; };
/** /**
@@ -23,7 +23,7 @@ const setProp = (key, prop, selector = '#root') => {
* @return {*} The data property * @return {*} The data property
*/ */
const getProp = (key, selector = '#root') => { const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key]; return $(selector).get(0).__x.$data[key];
}; };
/** /**
@@ -41,10 +41,7 @@ const getProp = (key, selector = '#root') => {
* @return {bool} * @return {bool}
*/ */
const preferDarkMode = () => { const preferDarkMode = () => {
return ( return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
);
}; };
/** /**
@@ -55,7 +52,7 @@ const preferDarkMode = () => {
* @return {bool} * @return {bool}
*/ */
const validThemeSetting = (theme) => { const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0; return ['dark', 'light', 'system'].indexOf(theme) >= 0;
}; };
/** /**
@@ -65,9 +62,9 @@ const validThemeSetting = (theme) => {
* @return {string} A theme setting ('dark', 'light', or 'system') * @return {string} A theme setting ('dark', 'light', or 'system')
*/ */
const loadThemeSetting = () => { const loadThemeSetting = () => {
let str = localStorage.getItem('theme'); let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system'; if (!str || !validThemeSetting(str)) str = 'system';
return str; return str;
}; };
/** /**
@@ -77,11 +74,11 @@ const loadThemeSetting = () => {
* @return {string} The current theme to use ('dark' or 'light') * @return {string} The current theme to use ('dark' or 'light')
*/ */
const loadTheme = () => { const loadTheme = () => {
let setting = loadThemeSetting(); let setting = loadThemeSetting();
if (setting === 'system') { if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light'; setting = preferDarkMode() ? 'dark' : 'light';
} }
return setting; return setting;
}; };
/** /**
@@ -90,9 +87,9 @@ const loadTheme = () => {
* @function saveThemeSetting * @function saveThemeSetting
* @param {string} setting - A theme setting * @param {string} setting - A theme setting
*/ */
const saveThemeSetting = (setting) => { const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'system'; if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting); localStorage.setItem('theme', setting);
}; };
/** /**
@@ -102,10 +99,10 @@ const saveThemeSetting = (setting) => {
* @function toggleTheme * @function toggleTheme
*/ */
const toggleTheme = () => { const toggleTheme = () => {
const theme = loadTheme(); const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark'; const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme); saveThemeSetting(newTheme);
setTheme(newTheme); setTheme(newTheme);
}; };
/** /**
@@ -116,32 +113,31 @@ const toggleTheme = () => {
* `loadTheme` to get a theme and apply it. * `loadTheme` to get a theme and apply it.
*/ */
const setTheme = (theme) => { const setTheme = (theme) => {
if (!theme) theme = loadTheme(); if (!theme) theme = loadTheme();
if (theme === 'dark') { if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)'); $('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light'); $('body').addClass('uk-light');
$('.ui-widget-content').addClass('dark'); $('.ui-widget-content').addClass('dark');
} else { } else {
$('html').css('background', ''); $('html').css('background', '');
$('body').removeClass('uk-light'); $('body').removeClass('uk-light');
$('.ui-widget-content').removeClass('dark'); $('.ui-widget-content').removeClass('dark');
} }
}; };
// do it before document is ready to prevent the initial flash of white on // do it before document is ready to prevent the initial flash of white on
// most pages // most pages
setTheme(); setTheme();
$(() => { $(() => {
// hack for the reader page // hack for the reader page
setTheme(); setTheme();
// on system dark mode setting change // on system dark mode setting change
if (window.matchMedia) { if (window.matchMedia) {
window window.matchMedia('(prefers-color-scheme: dark)')
.matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', event => {
.addEventListener('change', (event) => { if (loadThemeSetting() === 'system')
if (loadThemeSetting() === 'system') setTheme(event.matches ? 'dark' : 'light');
setTheme(event.matches ? 'dark' : 'light'); });
}); }
}
}); });

View File

@@ -5,22 +5,22 @@
* @param {object} e - The title element to truncate * @param {object} e - The title element to truncate
*/ */
const truncate = (e) => { const truncate = (e) => {
$(e).dotdotdot({ $(e).dotdotdot({
truncate: 'letter', truncate: 'letter',
watch: true, watch: true,
callback: (truncated) => { callback: (truncated) => {
if (truncated) { if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title')); $(e).attr('uk-tooltip', $(e).attr('data-title'));
} else { } else {
$(e).removeAttr('uk-tooltip'); $(e).removeAttr('uk-tooltip');
} }
}, }
}); });
}; };
$('.uk-card-title').each((i, e) => { $('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view // Truncate the title when it first enters the view
$(e).one('inview', () => { $(e).one('inview', () => {
truncate(e); truncate(e);
}); });
}); });

View File

@@ -1,135 +1,116 @@
const component = () => { const component = () => {
return { return {
jobs: [], jobs: [],
paused: undefined, paused: undefined,
loading: false, loading: false,
toggling: false, toggling: false,
ws: undefined, ws: undefined,
wsConnect(secure = true) { wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${ const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
location.host console.log(`Connecting to ${url}`);
}${base_url}api/admin/mangadex/queue`; this.ws = new WebSocket(url);
console.log(`Connecting to ${url}`); this.ws.onmessage = event => {
this.ws = new WebSocket(url); const data = JSON.parse(event.data);
this.ws.onmessage = (event) => { this.jobs = data.jobs;
const data = JSON.parse(event.data); this.paused = data.paused;
this.jobs = data.jobs; };
this.paused = data.paused; this.ws.onclose = () => {
}; if (this.ws.failed)
this.ws.onclose = () => { return this.wsConnect(false);
if (this.ws.failed) return this.wsConnect(false); alert('danger', 'Socket connection closed');
alert('danger', 'Socket connection closed'); };
}; this.ws.onerror = () => {
this.ws.onerror = () => { if (secure)
if (secure) return (this.ws.failed = true); return this.ws.failed = true;
alert('danger', 'Socket connection failed'); alert('danger', 'Socket connection failed');
}; };
}, },
init() { init() {
this.wsConnect(); this.wsConnect();
this.load(); this.load();
}, },
load() { load() {
this.loading = true; this.loading = true;
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: base_url + 'api/admin/mangadex/queue', url: base_url + 'api/admin/mangadex/queue',
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (!data.success && data.error) { if (!data.success && data.error) {
alert( alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
'danger', return;
`Failed to fetch download queue. Error: ${data.error}`, }
); this.jobs = data.jobs;
return; this.paused = data.paused;
} })
this.jobs = data.jobs; .fail((jqXHR, status) => {
this.paused = data.paused; alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { .always(() => {
alert( this.loading = false;
'danger', });
`Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, },
); jobAction(action, event) {
}) let url = `${base_url}api/admin/mangadex/queue/${action}`;
.always(() => { if (event) {
this.loading = false; const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
}); url = `${url}?${$.param({
}, id: id
jobAction(action, event) { })}`;
let url = `${base_url}api/admin/mangadex/queue/${action}`; }
if (event) { console.log(url);
const id = event.currentTarget $.ajax({
.closest('tr') type: 'POST',
.id.split('-') url: url,
.slice(1) dataType: 'json'
.join('-'); })
url = `${url}?${$.param({ .done(data => {
id, if (!data.success && data.error) {
})}`; alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
} return;
console.log(url); }
$.ajax({ this.load();
type: 'POST', })
url, .fail((jqXHR, status) => {
dataType: 'json', alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) });
.done((data) => { },
if (!data.success && data.error) { toggle() {
alert( this.toggling = true;
'danger', const action = this.paused ? 'resume' : 'pause';
`Failed to ${action} job from download queue. Error: ${data.error}`, const url = `${base_url}api/admin/mangadex/queue/${action}`;
); $.ajax({
return; type: 'POST',
} url: url,
this.load(); dataType: 'json'
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'danger', })
`Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, .always(() => {
); this.load();
}); this.toggling = false;
}, });
toggle() { },
this.toggling = true; statusClass(status) {
const action = this.paused ? 'resume' : 'pause'; let cls = 'label ';
const url = `${base_url}api/admin/mangadex/queue/${action}`; switch (status) {
$.ajax({ case 'Pending':
type: 'POST', cls += 'label-pending';
url, break;
dataType: 'json', case 'Completed':
}) cls += 'label-success';
.fail((jqXHR, status) => { break;
alert( case 'Error':
'danger', cls += 'label-danger';
`Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`, break;
); case 'MissingPages':
}) cls += 'label-warning';
.always(() => { break;
this.load(); }
this.toggling = false; return cls;
}); }
}, };
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
},
};
}; };

View File

@@ -1,74 +1,60 @@
const component = () => { const component = () => {
return { return {
empty: true, empty: true,
titles: [], titles: [],
entries: [], entries: [],
loading: true, loading: true,
load() { load() {
this.loading = true; this.loading = true;
this.request('GET', `${base_url}api/admin/titles/missing`, (data) => { this.request('GET', `${base_url}api/admin/titles/missing`, data => {
this.titles = data.titles; this.titles = data.titles;
this.request('GET', `${base_url}api/admin/entries/missing`, (data) => { this.request('GET', `${base_url}api/admin/entries/missing`, data => {
this.entries = data.entries; this.entries = data.entries;
this.loading = false; this.loading = false;
this.empty = this.entries.length === 0 && this.titles.length === 0; this.empty = this.entries.length === 0 && this.titles.length === 0;
}); });
}); });
}, },
rm(event) { rm(event) {
const rawID = event.currentTarget.closest('tr').id; const rawID = event.currentTarget.closest('tr').id;
const [type, id] = rawID.split('-'); const [type, id] = rawID.split('-');
const url = `${base_url}api/admin/${ const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
type === 'title' ? 'titles' : 'entries' this.request('DELETE', url, () => {
}/missing/${id}`; this.load();
this.request('DELETE', url, () => { });
this.load(); },
}); rmAll() {
}, UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
rmAll() { labels: {
UIkit.modal ok: 'Yes, delete them',
.confirm( cancel: 'Cancel'
'Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', }
{ }).then(() => {
labels: { this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
ok: 'Yes, delete them', this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
cancel: 'Cancel', this.load();
}, });
}, });
) });
.then(() => { },
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => { request(method, url, cb) {
this.request( console.log(url);
'DELETE', $.ajax({
`${base_url}api/admin/entries/missing`, type: method,
() => { url: url,
this.load(); contentType: 'application/json'
}, })
); .done(data => {
}); if (data.error) {
}); alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
}, return;
request(method, url, cb) { }
console.log(url); if (cb) cb(data);
$.ajax({ })
type: method, .fail((jqXHR, status) => {
url, alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
contentType: 'application/json', });
}) }
.done((data) => { };
if (data.error) {
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
return;
}
if (cb) cb(data);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
};
}; };

View File

@@ -1,435 +1,446 @@
const component = () => { const component = () => {
return { return {
plugins: [], plugins: [],
subscribable: false, info: undefined,
info: undefined, pid: undefined,
pid: undefined, chapters: undefined, // undefined: not searched yet, []: empty
chapters: undefined, // undefined: not searched yet, []: empty manga: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty mid: undefined, // id of the selected manga
mid: undefined, // id of the selected manga allChapters: [],
allChapters: [], query: "",
query: '', mangaTitle: "",
mangaTitle: '', searching: false,
searching: false, adding: false,
adding: false, sortOptions: [],
sortOptions: [], showFilters: false,
showFilters: false, appliedFilters: [],
appliedFilters: [], chaptersLimit: 500,
chaptersLimit: 500, listManga: false,
listManga: false, subscribing: false,
subscribing: false, subscriptionName: "",
subscriptionName: '',
init() { init() {
const tableObserver = new MutationObserver(() => { const tableObserver = new MutationObserver(() => {
console.log('table mutated'); console.log("table mutated");
$('#selectable').selectable({ $("#selectable").selectable({
filter: 'tr', filter: "tr",
}); });
}); });
tableObserver.observe($('table').get(0), { tableObserver.observe($("table").get(0), {
childList: true, childList: true,
subtree: true, subtree: true,
}); });
fetch(`${base_url}api/admin/plugin`) fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (!data.success) throw new Error(data.error); if (!data.success) throw new Error(data.error);
this.plugins = data.plugins; this.plugins = data.plugins;
const pid = localStorage.getItem('plugin'); const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid)) if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid); return this.loadPlugin(pid);
if (this.plugins.length > 0) this.loadPlugin(this.plugins[0].id); if (this.plugins.length > 0)
}) this.loadPlugin(this.plugins[0].id);
.catch((e) => { })
alert('danger', `Failed to list the available plugins. Error: ${e}`); .catch((e) => {
}); alert(
}, "danger",
loadPlugin(pid) { `Failed to list the available plugins. Error: ${e}`
fetch( );
`${base_url}api/admin/plugin/info?${new URLSearchParams({ });
plugin: pid, },
})}`, loadPlugin(pid) {
) fetch(
.then((res) => res.json()) `${base_url}api/admin/plugin/info?${new URLSearchParams({
.then((data) => { plugin: pid,
if (!data.success) throw new Error(data.error); })}`
this.info = data.info; )
this.subscribable = data.subscribable; .then((res) => res.json())
this.pid = pid; .then((data) => {
}) if (!data.success) throw new Error(data.error);
.catch((e) => { this.info = data.info;
alert('danger', `Failed to get plugin metadata. Error: ${e}`); this.pid = pid;
}); })
}, .catch((e) => {
pluginChanged() { alert(
this.manga = undefined; "danger",
this.chapters = undefined; `Failed to get plugin metadata. Error: ${e}`
this.mid = undefined; );
this.loadPlugin(this.pid); });
localStorage.setItem('plugin', this.pid); },
}, pluginChanged() {
get chapterKeys() { this.loadPlugin(this.pid);
if (this.allChapters.length < 1) return []; localStorage.setItem("plugin", this.pid);
return Object.keys(this.allChapters[0]).filter( },
(k) => !['manga_title'].includes(k), get chapterKeys() {
); if (this.allChapters.length < 1) return [];
}, return Object.keys(this.allChapters[0]).filter(
searchChapters(query) { (k) => !["manga_title"].includes(k)
this.searching = true; );
this.allChapters = []; },
this.sortOptions = []; searchChapters(query) {
this.chapters = undefined; this.searching = true;
this.listManga = false; this.allChapters = [];
fetch( this.sortOptions = [];
`${base_url}api/admin/plugin/list?${new URLSearchParams({ this.chapters = undefined;
plugin: this.pid, this.listManga = false;
query, fetch(
})}`, `${base_url}api/admin/plugin/list?${new URLSearchParams({
) plugin: this.pid,
.then((res) => res.json()) query: query,
.then((data) => { })}`
if (!data.success) throw new Error(data.error); )
try { .then((res) => res.json())
this.mangaTitle = data.chapters[0].manga_title; .then((data) => {
if (!this.mangaTitle) throw new Error(); if (!data.success) throw new Error(data.error);
} catch (e) { try {
this.mangaTitle = data.title; this.mangaTitle = data.chapters[0].manga_title;
} if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
this.allChapters = data.chapters; this.allChapters = data.chapters;
this.chapters = data.chapters; this.chapters = data.chapters;
}) })
.catch((e) => { .catch((e) => {
alert('danger', `Failed to list chapters. Error: ${e}`); alert("danger", `Failed to list chapters. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.searching = false; this.searching = false;
}); });
}, },
searchManga(query) { searchManga(query) {
this.searching = true; this.searching = true;
this.allChapters = []; this.allChapters = [];
this.chapters = undefined; this.chapters = undefined;
this.manga = undefined; this.manga = undefined;
fetch( fetch(
`${base_url}api/admin/plugin/search?${new URLSearchParams({ `${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid, plugin: this.pid,
query, query: query,
})}`, })}`
) )
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (!data.success) throw new Error(data.error); if (!data.success) throw new Error(data.error);
this.manga = data.manga; this.manga = data.manga;
this.listManga = true; this.listManga = true;
}) })
.catch((e) => { .catch((e) => {
alert('danger', `Search failed. Error: ${e}`); alert("danger", `Search failed. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.searching = false; this.searching = false;
}); });
}, },
search() { search() {
const query = this.query.trim(); const query = this.query.trim();
if (!query) return; if (!query) return;
this.manga = undefined; this.manga = undefined;
this.mid = undefined; if (this.info.version === 1) {
if (this.info.version === 1) { this.searchChapters(query);
this.searchChapters(query); } else {
} else { this.searchManga(query);
this.searchManga(query); }
} },
}, selectAll() {
selectAll() { $("tbody > tr").each((i, e) => {
$('tbody#selectable > tr').each((i, e) => { $(e).addClass("ui-selected");
$(e).addClass('ui-selected'); });
}); },
}, clearSelection() {
clearSelection() { $("tbody > tr").each((i, e) => {
$('tbody#selectable > tr').each((i, e) => { $(e).removeClass("ui-selected");
$(e).removeClass('ui-selected'); });
}); },
}, download() {
download() { const selected = $("tbody > tr.ui-selected").get();
const selected = $('tbody#selectable > tr.ui-selected').get(); if (selected.length === 0) return;
if (selected.length === 0) return;
UIkit.modal UIkit.modal
.confirm(`Download ${selected.length} selected chapters?`) .confirm(`Download ${selected.length} selected chapters?`)
.then(() => { .then(() => {
const ids = selected.map((e) => e.id); const ids = selected.map((e) => e.id);
const chapters = this.chapters.filter((c) => ids.includes(c.id)); const chapters = this.chapters.filter((c) =>
console.log(chapters); ids.includes(c.id)
this.adding = true; );
fetch(`${base_url}api/admin/plugin/download`, { console.log(chapters);
method: 'POST', this.adding = true;
body: JSON.stringify({ fetch(`${base_url}api/admin/plugin/download`, {
chapters, method: "POST",
plugin: this.pid, body: JSON.stringify({
title: this.mangaTitle, chapters,
}), plugin: this.pid,
headers: { title: this.mangaTitle,
'Content-Type': 'application/json', }),
}, headers: {
}) "Content-Type": "application/json",
.then((res) => res.json()) },
.then((data) => { })
if (!data.success) throw new Error(data.error); .then((res) => res.json())
const successCount = parseInt(data.success); .then((data) => {
const failCount = parseInt(data.fail); if (!data.success) throw new Error(data.error);
alert( const successCount = parseInt(data.success);
'success', const failCount = parseInt(data.fail);
`${successCount} of ${ alert(
successCount + failCount "success",
} 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>.`, `${successCount} of ${
); successCount + failCount
}) } chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
.catch((e) => { );
alert( })
'danger', .catch((e) => {
`Failed to add chapters to the download queue. Error: ${e}`, alert(
); "danger",
}) `Failed to add chapters to the download queue. Error: ${e}`
.finally(() => { );
this.adding = false; })
}); .finally(() => {
}); this.adding = false;
}, });
thClicked(event) { });
const idx = parseInt(event.currentTarget.id.split('-')[1]); },
if (idx === undefined || isNaN(idx)) return; thClicked(event) {
const curOption = this.sortOptions[idx]; const idx = parseInt(event.currentTarget.id.split("-")[1]);
let option; if (idx === undefined || isNaN(idx)) return;
this.sortOptions = []; const curOption = this.sortOptions[idx];
switch (curOption) { let option;
case 1: this.sortOptions = [];
option = -1; switch (curOption) {
break; case 1:
case -1: option = -1;
option = 0; break;
break; case -1:
default: option = 0;
option = 1; break;
} default:
this.sortOptions[idx] = option; option = 1;
this.sort(this.chapterKeys[idx], option); }
}, this.sortOptions[idx] = option;
// Returns an array of filtered but unsorted chapters. Useful when this.sort(this.chapterKeys[idx], option);
// reseting the sort options. },
get filteredChapters() { // Returns an array of filtered but unsorted chapters. Useful when
let ary = this.allChapters.slice(); // reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log('initial size:', ary.length); console.log("initial size:", ary.length);
for (let filter of this.appliedFilters) { for (let filter of this.appliedFilters) {
if (!filter.value) continue; if (!filter.value) continue;
if (filter.type === 'array' && filter.value === 'all') continue; if (filter.type === "array" && filter.value === "all") continue;
if (filter.type.startsWith('number') && isNaN(filter.value)) continue; if (filter.type.startsWith("number") && isNaN(filter.value))
continue;
if (filter.type === 'string') { if (filter.type === "string") {
ary = ary.filter((ch) => ary = ary.filter((ch) =>
ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()), 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-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 === "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-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 === "date-max") {
); ary = ary.filter(
} (ch) => Number(ch[filter.key]) <= Number(filter.value)
if (filter.type === 'array') { );
ary = ary.filter((ch) => }
ch[filter.key] if (filter.type === "array") {
.map((s) => (typeof s === 'string' ? s.toLowerCase() : s)) ary = ary.filter((ch) =>
.includes(filter.value.toLowerCase()), ch[filter.key]
); .map((s) =>
} typeof s === "string" ? s.toLowerCase() : s
)
.includes(filter.value.toLowerCase())
);
}
console.log('filtered size:', ary.length); console.log("filtered size:", ary.length);
} }
return ary; return ary;
}, },
// option: // option:
// - 1: asending // - 1: asending
// - -1: desending // - -1: desending
// - 0: unsorted // - 0: unsorted
sort(key, option) { sort(key, option) {
if (option === 0) { if (option === 0) {
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
return; return;
} }
this.chapters = this.filteredChapters.sort((a, b) => { this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]); const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp; return option < 0 ? comp * -1 : comp;
}); });
}, },
compare(a, b) { compare(a, b) {
if (a === b) return 0; if (a === b) return 0;
// try numbers (also covers dates) // try numbers (also covers dates)
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b); if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
const preprocessString = (val) => { const preprocessString = (val) => {
if (typeof val !== 'string') return val; if (typeof val !== "string") return val;
return val.toLowerCase().replace(/\s\s/g, ' ').trim(); return val.toLowerCase().replace(/\s\s/g, " ").trim();
}; };
return preprocessString(a) > preprocessString(b) ? 1 : -1; return preprocessString(a) > preprocessString(b) ? 1 : -1;
}, },
fieldType(values) { fieldType(values) {
if (values.every((v) => this.numIsDate(v))) return 'date'; if (values.every((v) => this.numIsDate(v))) return "date";
if (values.every((v) => !isNaN(v))) return 'number'; if (values.every((v) => !isNaN(v))) return "number";
if (values.every((v) => Array.isArray(v))) return 'array'; if (values.every((v) => Array.isArray(v))) return "array";
return 'string'; return "string";
}, },
get filters() { get filters() {
if (this.allChapters.length < 1) return []; if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter( const keys = Object.keys(this.allChapters[0]).filter(
(k) => !['manga_title', 'id'].includes(k), (k) => !["manga_title", "id"].includes(k)
); );
return keys.map((k) => { return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]); let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values); const type = this.fieldType(values);
if (type === 'array') { if (type === "array") {
// if the type is an array, return the list of available elements // if the type is an array, return the list of available elements
// example: an array of groups or authors // example: an array of groups or authors
values = Array.from( values = Array.from(
new Set( new Set(
values.flat().map((v) => { values.flat().map((v) => {
if (typeof v === 'string') return v.toLowerCase(); if (typeof v === "string")
}), return v.toLowerCase();
), })
); )
} );
}
return { return {
key: k, key: k,
type, type: type,
values, values: values,
}; };
}); });
}, },
get filterSettings() { get filterSettings() {
return $('#filter-form input:visible, #filter-form select:visible') return $("#filter-form input:visible, #filter-form select:visible")
.get() .get()
.map((i) => { .map((i) => {
const type = i.getAttribute('data-filter-type'); const type = i.getAttribute("data-filter-type");
let value = i.value.trim(); let value = i.value.trim();
if (type.startsWith('date')) if (type.startsWith("date"))
value = value ? Date.parse(value).toString() : ''; value = value ? Date.parse(value).toString() : "";
return { return {
key: i.getAttribute('data-filter-key'), key: i.getAttribute("data-filter-key"),
value, value: value,
type, type: type,
}; };
}); });
}, },
applyFilters() { applyFilters() {
this.appliedFilters = this.filterSettings; this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
this.sortOptions = []; this.sortOptions = [];
}, },
clearFilters() { clearFilters() {
$('#filter-form input') $("#filter-form input")
.get() .get()
.forEach((i) => (i.value = '')); .forEach((i) => (i.value = ""));
$('#filter-form select').val('all'); $("#filter-form select").val("all");
this.appliedFilters = []; this.appliedFilters = [];
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
this.sortOptions = []; this.sortOptions = [];
}, },
mangaSelected(event) { mangaSelected(event) {
const mid = event.currentTarget.getAttribute('data-id'); const mid = event.currentTarget.getAttribute("data-id");
this.mid = mid; this.mid = mid;
this.searchChapters(mid); this.searchChapters(mid);
}, },
subscribe(modal) { subscribe(modal) {
this.subscribing = true; this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, { fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
filters: this.filterSettings, filters: this.filterSettings,
plugin: this.pid, plugin: this.pid,
name: this.subscriptionName.trim(), name: this.subscriptionName.trim(),
manga: this.mangaTitle, manga: this.mangaTitle,
manga_id: this.mid, manga_id: this.mid,
}), }),
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (!data.success) throw new Error(data.error); if (!data.success) throw new Error(data.error);
alert('success', 'Subscription created'); alert("success", "Subscription created");
}) })
.catch((e) => { .catch((e) => {
alert('danger', `Failed to subscribe. Error: ${e}`); alert("danger", `Failed to subscribe. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.subscribing = false; this.subscribing = false;
UIkit.modal(modal).hide(); UIkit.modal(modal).hide();
}); });
}, },
numIsDate(num) { numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980 return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
}, },
renderCell(value) { renderCell(value) {
if (this.numIsDate(value)) if (this.numIsDate(value))
return `<span>${moment(Number(value)).format('MMM D, YYYY')}</span>`; return `<span>${moment(Number(value)).format(
const maxLength = 40; "MMM D, YYYY"
if (value && value.length > maxLength) )}</span>`;
return `<span>${value.substr( const maxLength = 40;
0, if (value && value.length > maxLength)
maxLength, return `<span>${value.substr(
)}...</span><div uk-dropdown>${value}</div>`; 0,
return `<span>${value}</span>`; maxLength
}, )}...</span><div uk-dropdown>${value}</div>`;
renderFilterRow(ft) { return `<span>${value}</span>`;
const key = ft.key; },
let type = ft.type; renderFilterRow(ft) {
switch (type) { const key = ft.key;
case 'number-min': let type = ft.type;
type = 'number (minimum value)'; switch (type) {
break; case "number-min":
case 'number-max': type = "number (minimum value)";
type = 'number (maximum value)'; break;
break; case "number-max":
case 'date-min': type = "number (maximum value)";
type = 'minimum date'; break;
break; case "date-min":
case 'date-max': type = "minimum date";
type = 'maximum date'; break;
break; case "date-max":
} type = "maximum date";
let value = ft.value; break;
}
let value = ft.value;
if (ft.type.startsWith('number') && isNaN(value)) value = ''; if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith('date') && value) else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format('MMM D, YYYY'); value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`; return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
}, },
}; };
}; };

View File

@@ -1,370 +1,336 @@
const readerComponent = () => { const readerComponent = () => {
return { return {
loading: true, loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width' mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...', msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary', alertClass: 'uk-alert-primary',
items: [], items: [],
curItem: {}, curItem: {},
enableFlipAnimation: true, enableFlipAnimation: true,
flipAnimation: null, flipAnimation: null,
longPages: false, longPages: false,
lastSavedPage: page, lastSavedPage: page,
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, enableRightToLeft: false,
fitType: 'vert',
/** /**
* Initialize the component by fetching the page dimensions * Initialize the component by fetching the page dimensions
*/ */
init(nextTick) { init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) $.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then((data) => { .then(data => {
if (!data.success && data.error) throw new Error(resp.error); if (!data.success && data.error)
const dimensions = data.dimensions; throw new Error(resp.error);
const dimensions = data.dimensions;
this.items = dimensions.map((d, i) => { this.items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i + 1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width === 0 ? '100%' : d.width, width: d.width,
height: d.height === 0 ? '100%' : d.height, height: d.height,
}; };
}); });
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`. const avgRatio = this.items.reduce((acc, cur) => {
// TODO: support more image types in image_size.cr return acc + cur.height / cur.width
const avgRatio = }, 0) / this.items.length;
dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width;
}, 0) / dimensions.length;
console.log(avgRatio); console.log(avgRatio);
this.longPages = avgRatio > 2; this.longPages = avgRatio > 2;
this.loading = false; this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous'; this.mode = localStorage.getItem('mode') || 'continuous';
// Here we save a copy of this.mode, and use the copy as // Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode` // the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`, // might change this.mode and make it `height` or `width`,
// which are not available in mode-select // which are not available in mode-select
const mode = this.mode; const mode = this.mode;
this.updateMode(this.mode, page, nextTick); this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode); $('#mode-select').val(mode);
const savedMargin = localStorage.getItem('margin'); const savedMargin = localStorage.getItem('margin');
if (savedMargin) { if (savedMargin) {
this.margin = savedMargin; this.margin = savedMargin;
} }
// Preload Images // Preload Images
this.preloadLookahead = +( this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
localStorage.getItem('preloadLookahead') ?? 3 const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
); for (let idx = page + 1; idx <= limit; idx++) {
const limit = Math.min( this.preloadImage(this.items[idx - 1].url);
page + this.preloadLookahead, }
this.items.length,
);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFitType = localStorage.getItem('fitType'); const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
if (savedFitType) { this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
this.fitType = savedFitType;
$('#fit-select').val(savedFitType);
}
const savedFlipAnimation = localStorage.getItem(
'enableFlipAnimation',
);
this.enableFlipAnimation =
savedFlipAnimation === null || savedFlipAnimation === 'true';
const savedRightToLeft = localStorage.getItem('enableRightToLeft'); const savedRightToLeft = localStorage.getItem('enableRightToLeft');
if (savedRightToLeft === null) { if (savedRightToLeft === null) {
this.enableRightToLeft = false; this.enableRightToLeft = false;
} else { } else {
this.enableRightToLeft = savedRightToLeft === 'true'; 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}`;
console.error(e); console.error(e);
this.alertClass = 'uk-alert-danger'; this.alertClass = 'uk-alert-danger';
this.msg = errMsg; this.msg = errMsg;
}); })
}, },
/** /**
* Preload an image, which is expected to be cached * Preload an image, which is expected to be cached
*/ */
preloadImage(url) { preloadImage(url) {
new Image().src = url; (new Image()).src = url;
}, },
/** /**
* Handles the `change` event for the page selector * Handles the `change` event for the page selector
*/ */
pageChanged() { pageChanged() {
const p = parseInt($('#page-select').val()); const p = parseInt($('#page-select').val());
this.toPage(p); this.toPage(p);
}, },
/** /**
* Handles the `change` event for the mode selector * Handles the `change` event for the mode selector
* *
* @param {function} nextTick - Alpine $nextTick magic property * @param {function} nextTick - Alpine $nextTick magic property
*/ */
modeChanged(nextTick) { modeChanged(nextTick) {
const mode = $('#mode-select').val(); const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val()); const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick); this.updateMode(mode, curIdx, nextTick);
}, },
/** /**
* Handles the window `resize` event * Handles the window `resize` event
*/ */
resized() { resized() {
if (this.mode === 'continuous') return; if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height(); const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width'; this.mode = wideScreen ? 'height' : 'width';
}, },
/** /**
* Handles the window `keydown` event * Handles the window `keydown` event
* *
* @param {Event} event - The triggering event * @param {Event} event - The triggering event
*/ */
keyHandler(event) { keyHandler(event) {
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.enableRightToLeft); this.flipPage(false ^ this.enableRightToLeft);
if (event.key === 'ArrowRight' || event.key === 'j') if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true ^ this.enableRightToLeft); this.flipPage(true ^ this.enableRightToLeft);
}, },
/** /**
* Flips to the next or the previous page * Flips to the next or the previous page
* *
* @param {bool} isNext - Whether we are going to the next page * @param {bool} isNext - Whether we are going to the next page
*/ */
flipPage(isNext) { flipPage(isNext) {
const idx = parseInt(this.curItem.id); const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1); const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0) return; if (newIdx <= 0 || newIdx > this.items.length) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx + this.preloadLookahead < this.items.length + 1) { if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url); this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
} }
this.toPage(newIdx); this.toPage(newIdx);
if (this.enableFlipAnimation) { if (this.enableFlipAnimation) {
if (isNext ^ this.enableRightToLeft) this.flipAnimation = 'right'; if (isNext ^ this.enableRightToLeft)
else this.flipAnimation = 'left'; this.flipAnimation = 'right';
} else
this.flipAnimation = 'left';
}
setTimeout(() => { setTimeout(() => {
this.flipAnimation = null; this.flipAnimation = null;
}, 500); }, 500);
this.replaceHistory(newIdx); this.replaceHistory(newIdx);
}, },
/** /**
* Jumps to a specific page * Jumps to a specific page
* *
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
*/ */
toPage(idx) { toPage(idx) {
if (this.mode === 'continuous') { if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true); $(`#${idx}`).get(0).scrollIntoView(true);
} else { } else {
if (idx >= 1 && idx <= this.items.length) { if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1]; this.curItem = this.items[idx - 1];
} }
} }
this.replaceHistory(idx); this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide(); UIkit.modal($('#modal-sections')).hide();
}, },
/** /**
* Replace the address bar history and save the reading progress if necessary * Replace the address bar history and save the reading progress if necessary
* *
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
*/ */
replaceHistory(idx) { replaceHistory(idx) {
const ary = window.location.pathname.split('/'); const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx; ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/` ary.shift(); // remove leading `/`
ary.unshift(window.location.origin); ary.unshift(window.location.origin);
const url = ary.join('/'); const url = ary.join('/');
this.saveProgress(idx); this.saveProgress(idx);
history.replaceState(null, '', url); history.replaceState(null, "", url);
}, },
/** /**
* Updates the backend reading progress if: * Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last * 1) the current page is more than five pages away from the last
* saved page, or * saved page, or
* 2) the average height/width ratio of the pages is over 2, or * 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or * 3) the current page is the first page, or
* 4) the current page is the last page * 4) the current page is the last page
* *
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback * @param {function} cb - Callback
*/ */
saveProgress(idx, cb) { saveProgress(idx, cb) {
idx = parseInt(idx); idx = parseInt(idx);
if ( if (Math.abs(idx - this.lastSavedPage) >= 5 ||
Math.abs(idx - this.lastSavedPage) >= 5 || this.longPages ||
this.longPages || idx === 1 || idx === this.items.length
idx === 1 || ) {
idx === this.items.length this.lastSavedPage = idx;
) { console.log('saving progress', idx);
this.lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({ const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
eid, $.ajax({
})}`; method: 'PUT',
$.ajax({ url: url,
method: 'PUT', dataType: 'json'
url, })
dataType: 'json', .done(data => {
}) if (data.error)
.done((data) => { alert('danger', data.error);
if (data.error) alert('danger', data.error); if (cb) cb();
if (cb) cb(); })
}) .fail((jqXHR, status) => {
.fail((jqXHR, status) => { alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); });
}); }
} },
}, /**
/** * Updates the reader mode
* Updates the reader mode *
* * @param {string} mode - Either `continuous` or `paged`
* @param {string} mode - Either `continuous` or `paged` * @param {number} targetPage - The one-based index of the target page
* @param {number} targetPage - The one-based index of the target page * @param {function} nextTick - Alpine $nextTick magic property
* @param {function} nextTick - Alpine $nextTick magic property */
*/ updateMode(mode, targetPage, nextTick) {
updateMode(mode, targetPage, nextTick) { localStorage.setItem('mode', mode);
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen` // The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode; let propMode = mode;
if (mode === 'paged') { if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height(); const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width'; propMode = wideScreen ? 'height' : 'width';
} }
this.mode = propMode; this.mode = propMode;
if (mode === 'continuous') { if (mode === 'continuous') {
nextTick(() => { nextTick(() => {
this.setupScroller(); this.setupScroller();
}); });
} }
nextTick(() => { nextTick(() => {
this.toPage(targetPage); this.toPage(targetPage);
}); });
}, },
/** /**
* Handles clicked image * Shows the control modal
* *
* @param {Event} event - The triggering event * @param {Event} event - The triggering event
*/ */
clickImage(event) { showControl(event) {
const idx = event.currentTarget.id; const idx = event.currentTarget.id;
this.showControl(idx); this.selectedIndex = idx;
}, UIkit.modal($('#modal-sections')).show();
/** },
* Shows the control modal /**
* * Redirects to a URL
* @param {number} idx - selected page index *
*/ * @param {string} url - The target URL
showControl(idx) { */
this.selectedIndex = idx; redirect(url) {
UIkit.modal($('#modal-sections')).show(); window.location.replace(url);
}, },
/** /**
* Redirects to a URL * Set up the scroll handler that calls `replaceHistory` when an image
* * enters the view port
* @param {string} url - The target URL */
*/ setupScroller() {
redirect(url) { if (this.mode !== 'continuous') return;
window.location.replace(url); $('img').each((idx, el) => {
}, $(el).on('inview', (event, inView) => {
/** if (inView) {
* Set up the scroll handler that calls `replaceHistory` when an image const current = $(event.currentTarget).attr('id');
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1]; this.curItem = this.items[current - 1];
this.replaceHistory(current); this.replaceHistory(current);
} }
}); });
}); });
}, },
/** /**
* Marks progress as 100% and jumps to the next entry * Marks progress as 100% and jumps to the next entry
* *
* @param {string} nextUrl - URL of the next entry * @param {string} nextUrl - URL of the next entry
*/ */
nextEntry(nextUrl) { nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => { this.saveProgress(this.items.length, () => {
this.redirect(nextUrl); this.redirect(nextUrl);
}); });
}, },
/** /**
* Exits the reader, and sets the reading progress tp 100% * Exits the reader, and sets the reading progress tp 100%
* *
* @param {string} exitUrl - The Exit URL * @param {string} exitUrl - The Exit URL
*/ */
exitReader(exitUrl) { exitReader(exitUrl) {
this.saveProgress(this.items.length, () => { this.saveProgress(this.items.length, () => {
this.redirect(exitUrl); this.redirect(exitUrl);
}); });
}, },
/** /**
* Handles the `change` event for the entry selector * Handles the `change` event for the entry selector
*/ */
entryChanged() { entryChanged() {
const id = $('#entry-select').val(); const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`); this.redirect(`${base_url}reader/${tid}/${id}`);
}, },
marginChanged() { marginChanged() {
localStorage.setItem('margin', this.margin); localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex); this.toPage(this.selectedIndex);
}, },
fitChanged() { preloadLookaheadChanged() {
this.fitType = $('#fit-select').val(); localStorage.setItem('preloadLookahead', this.preloadLookahead);
localStorage.setItem('fitType', this.fitType); },
},
preloadLookaheadChanged() { enableFlipAnimationChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead); localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
}, },
enableFlipAnimationChanged() { enableRightToLeftChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation); localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
}, },
};
enableRightToLeftChanged() { }
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
},
};
};

View File

@@ -1,28 +1,30 @@
$(function () { $(function(){
let filter = []; var filter = [];
let result = []; var result = [];
$('.uk-card-title').each(function () { $('.uk-card-title').each(function(){
filter.push($(this).text()); filter.push($(this).text());
}); });
$('.uk-search-input').keyup(function () { $('.uk-search-input').keyup(function(){
let input = $('.uk-search-input').val(); var input = $('.uk-search-input').val();
let regex = new RegExp(input, 'i'); var regex = new RegExp(input, 'i');
if (input === '') { if (input === '') {
$('.item').each(function () { $('.item').each(function(){
$(this).removeAttr('hidden'); $(this).removeAttr('hidden');
}); });
} else { }
filter.forEach(function (text, i) { else {
result[i] = text.match(regex); filter.forEach(function(text, i){
}); result[i] = text.match(regex);
$('.item').each(function (i) { });
if (result[i]) { $('.item').each(function(i){
$(this).removeAttr('hidden'); if (result[i]) {
} else { $(this).removeAttr('hidden');
$(this).attr('hidden', ''); }
} else {
}); $(this).attr('hidden', '');
} }
}); });
}
});
}); });

View File

@@ -1,15 +1,15 @@
$(() => { $(() => {
$('#sort-select').change(() => { $('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id'); const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-'); const ary = sort.split('-');
const by = ary[0]; const by = ary[0];
const dir = ary[1]; const dir = ary[1];
const url = `${location.protocol}//${location.host}${location.pathname}`; const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({ const newURL = `${url}?${$.param({
sort: by, sort: by,
ascend: dir === 'up' ? 1 : 0, ascend: dir === 'up' ? 1 : 0
})}`; })}`;
window.location.href = newURL; window.location.href = newURL;
}); });
}); });

View File

@@ -1,144 +1,147 @@
const component = () => { const component = () => {
return { return {
subscriptions: [], subscriptions: [],
plugins: [], plugins: [],
pid: undefined, pid: undefined,
subscription: undefined, // selected subscription subscription: undefined, // selected subscription
loading: false, loading: false,
init() { init() {
fetch(`${base_url}api/admin/plugin`) fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (!data.success) throw new Error(data.error); if (!data.success) throw new Error(data.error);
this.plugins = data.plugins; this.plugins = data.plugins;
let pid = localStorage.getItem('plugin'); const pid = localStorage.getItem("plugin");
if (!pid || !this.plugins.find((p) => p.id === pid)) { if (pid && this.plugins.map((p) => p.id).includes(pid))
pid = this.plugins[0].id; this.pid = pid;
} else if (this.plugins.length > 0)
this.pid = this.plugins[0].id;
this.pid = pid; this.list(pid);
this.list(pid); })
}) .catch((e) => {
.catch((e) => { alert(
alert('danger', `Failed to list the available plugins. Error: ${e}`); "danger",
}); `Failed to list the available plugins. Error: ${e}`
}, );
pluginChanged() { });
localStorage.setItem('plugin', this.pid); },
this.list(this.pid); pluginChanged() {
}, localStorage.setItem("plugin", this.pid);
list(pid) { this.list(this.pid);
if (!pid) return; },
fetch( list(pid) {
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams({ if (!pid) return;
plugin: pid, fetch(
})}`, `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
{ {
method: 'GET', plugin: pid,
}, }
) )}`,
.then((response) => response.json()) {
.then((data) => { method: "GET",
if (!data.success) throw new Error(data.error); }
this.subscriptions = data.subscriptions; )
}) .then((response) => response.json())
.catch((e) => { .then((data) => {
alert('danger', `Failed to list subscriptions. Error: ${e}`); if (!data.success) throw new Error(data.error);
}); this.subscriptions = data.subscriptions;
}, })
renderStrCell(str) { .catch((e) => {
const maxLength = 40; alert(
if (str.length > maxLength) "danger",
return `<td><span>${str.substring( `Failed to list subscriptions. Error: ${e}`
0, );
maxLength, });
)}...</span><div uk-dropdown>${str}</div></td>`; },
return `<td>${str}</td>`; renderStrCell(str) {
}, const maxLength = 40;
renderDateCell(timestamp) { if (str.length > maxLength)
return `<td>${moment return `<td><span>${str.substring(
.duration(moment.unix(timestamp).diff(moment())) 0,
.humanize(true)}</td>`; maxLength
}, )}...</span><div uk-dropdown>${str}</div></td>`;
selected(event, modal) { return `<td>${str}</td>`;
const id = event.currentTarget.getAttribute('sid'); },
this.subscription = this.subscriptions.find((s) => s.id === id); renderDateCell(timestamp) {
UIkit.modal(modal).show(); return `<td>${moment
}, .duration(moment.unix(timestamp).diff(moment()))
renderFilterRow(ft) { .humanize(true)}</td>`;
const key = ft.key; },
let type = ft.type; selected(event, modal) {
switch (type) { const id = event.currentTarget.getAttribute("sid");
case 'number-min': this.subscription = this.subscriptions.find((s) => s.id === id);
type = 'number (minimum value)'; UIkit.modal(modal).show();
break; },
case 'number-max': renderFilterRow(ft) {
type = 'number (maximum value)'; const key = ft.key;
break; let type = ft.type;
case 'date-min': switch (type) {
type = 'minimum date'; case "number-min":
break; type = "number (minimum value)";
case 'date-max': break;
type = 'maximum date'; case "number-max":
break; type = "number (maximum value)";
} break;
let value = ft.value; 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 = ''; if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith('date') && value) else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format('MMM D, YYYY'); value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`; return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
}, },
actionHandler(event, type) { actionHandler(event, type) {
const id = $(event.currentTarget).closest('tr').attr('sid'); const id = $(event.currentTarget).closest("tr").attr("sid");
if (type !== 'delete') return this.action(id, type); if (type !== 'delete') return this.action(id, type);
UIkit.modal UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
.confirm( labels: {
'Are you sure you want to delete the subscription? This cannot be undone.', ok: 'Yes, delete it',
{ cancel: 'Cancel'
labels: { }
ok: 'Yes, delete it', }).then(() => {
cancel: 'Cancel', this.action(id, type);
}, });
}, },
) action(id, type) {
.then(() => { if (this.loading) return;
this.action(id, type); this.loading = true;
}); fetch(
}, `${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
action(id, type) { {
if (this.loading) return; plugin: this.pid,
this.loading = true; subscription: id,
fetch( }
`${base_url}api/admin/plugin/subscriptions${ )}`,
type === 'update' ? '/update' : '' {
}?${new URLSearchParams({ method: type === 'delete' ? "DELETE" : 'POST'
plugin: this.pid, }
subscription: id, )
})}`, .then((response) => response.json())
{ .then((data) => {
method: type === 'delete' ? 'DELETE' : 'POST', 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.`);
.then((response) => response.json()) })
.then((data) => { .catch((e) => {
if (!data.success) throw new Error(data.error); alert(
if (type === 'update') "danger",
alert( `Failed to ${type} subscription. Error: ${e}`
'success', );
`Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`, })
); .finally(() => {
}) this.loading = false;
.catch((e) => { this.list(this.pid);
alert('danger', `Failed to ${type} subscription. Error: ${e}`); });
}) },
.finally(() => { };
this.loading = false;
this.list(this.pid);
});
},
};
}; };

View File

@@ -1,112 +1,82 @@
const component = () => { const component = () => {
return { return {
available: undefined, available: undefined,
subscriptions: [], subscriptions: [],
init() { init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`) $.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => { .done((data) => {
if (data.error) { if (data.error) {
alert( alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
'danger', return;
'Failed to check MangaDex integration status. Error: ' + }
data.error, this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
);
return;
}
this.available = Boolean(
data.expires && data.expires > Math.floor(Date.now() / 1000),
);
if (this.available) this.getSubscriptions(); if (this.available) this.getSubscriptions();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'danger', })
`Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`, },
);
});
},
getSubscriptions() { getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`) $.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
'danger', return;
'Failed to get subscriptions. Error: ' + data.error, }
); this.subscriptions = data.subscriptions;
return; })
} .fail((jqXHR, status) => {
this.subscriptions = data.subscriptions; alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { },
alert(
'danger',
`Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
rm(event) { rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id'); const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`, url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json', contentType: 'application/json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to delete subscription. Error: ${data.error}`);
'danger', }
`Failed to delete subscription. Error: ${data.error}`, this.getSubscriptions();
); })
} .fail((jqXHR, status) => {
this.getSubscriptions(); alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) });
.fail((jqXHR, status) => { },
alert(
'danger',
`Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
check(event) { check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id'); const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`, url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json', contentType: 'application/json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to check subscription. Error: ${data.error}`);
'danger', return;
`Failed to check subscription. Error: ${data.error}`, }
); alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
return; })
} .fail((jqXHR, status) => {
alert( alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'success', });
'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.', },
);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
formatRange(min, max) { formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`; if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`; if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All'; if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`; if (min === max) return `= ${min}`;
return `${min} - ${max}`; return `${min} - ${max}`;
}, }
}; };
}; };

View File

@@ -1,421 +1,392 @@
$(() => { $(() => {
setupAcard(); setupAcard();
}); });
const setupAcard = () => { const setupAcard = () => {
$('.acard.is_entry').click((e) => { $('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return; if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard'); const card = $(e.target).closest('.acard');
showModal( showModal(
$(card).attr('data-encoded-path'), $(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')), parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')), parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'), $(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'), $(card).attr('data-encoded-title'),
$(card).attr('data-book-id'), $(card).attr('data-book-id'),
$(card).attr('data-id'), $(card).attr('data-id')
); );
}); });
}; };
function showModal( function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
encodedPath, const zipPath = decodeURIComponent(encodedPath);
pages, const title = decodeURIComponent(encodedeTitle);
percentage, const entry = decodeURIComponent(encodedEntryTitle);
encodedeTitle, $('#modal button, #modal a').each(function() {
encodedEntryTitle, $(this).removeAttr('hidden');
titleID, });
entryID, if (percentage === 0) {
) { $('#continue-btn').attr('hidden', '');
const zipPath = decodeURIComponent(encodedPath); $('#unread-btn').attr('hidden', '');
const title = decodeURIComponent(encodedeTitle); } else if (percentage === 100) {
const entry = decodeURIComponent(encodedEntryTitle); $('#read-btn').attr('hidden', '');
$('#modal button, #modal a').each(function () { $('#continue-btn').attr('hidden', '');
$(this).removeAttr('hidden'); } else {
}); $('#continue-btn').text('Continue from ' + percentage + '%');
if (percentage === 0) { }
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
$('#modal-entry-title').find('span').text(entry); $('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID); $('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID); $('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry); $('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`); $('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`); $('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function () { $('#read-btn').click(function() {
updateProgress(titleID, entryID, pages); updateProgress(titleID, entryID, pages);
}); });
$('#unread-btn').click(function () { $('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr( $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
'href',
`${base_url}api/download/${titleID}/${entryID}`,
);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
} }
UIkit.util.on(document, 'hidden', '#modal', () => { UIkit.util.on(document, 'hidden', '#modal', () => {
$('#read-btn').off('click'); $('#read-btn').off('click');
$('#unread-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({
eid, eid: eid
}); });
if (eid) url += `?${query}`; if (eid)
url += `?${query}`;
$.ajax({ $.ajax({
method: 'PUT', method: 'PUT',
url, url: url,
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
error = data.error; error = data.error;
alert('danger', error); alert('danger', error);
} }
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
}; };
const renameSubmit = (name, eid) => { 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');
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;
} }
const query = $.param({ const query = $.param({
eid, eid: eid
}); });
let url = `${base_url}api/admin/display_name/${titleId}/${name}`; let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) url += `?${query}`; if (eid)
url += `?${query}`;
$.ajax({ $.ajax({
type: 'PUT', type: 'PUT',
url, url: url,
contentType: 'application/json', contentType: "application/json",
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`); alert('danger', `Failed to update display name. Error: ${data.error}`);
return; return;
} }
location.reload(); location.reload();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'danger', });
`Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
}; };
const renameSortNameSubmit = (name, eid) => { const renameSortNameSubmit = (name, eid) => {
const upload = $('.upload-field'); const upload = $('.upload-field');
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const params = {}; const params = {};
if (eid) params.eid = eid; if (eid) params.eid = eid;
if (name) params.name = name; if (name) params.name = name;
const query = $.param(params); const query = $.param(params);
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`; let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
$.ajax({ $.ajax({
type: 'PUT', type: 'PUT',
url, url,
contentType: 'application/json', contentType: 'application/json',
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert('danger', `Failed to update sort title. Error: ${data.error}`); alert('danger', `Failed to update sort title. Error: ${data.error}`);
return; return;
} }
location.reload(); location.reload();
}) })
.fail((jqXHR, status) => { .fail((jqXHR, status) => {
alert( alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
'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 fileTitle = $('h2.uk-title').attr('data-file-title');
let sortTitle = $('h2.uk-title').attr('data-sort-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'); fileTitle = item.find('.uk-card-title').attr('data-file-title');
sortTitle = item.find('.uk-card-title').attr('data-sort-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');
} }
cover.attr('data-src', url); cover.attr('data-src', url);
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName); displayNameField.attr('value', displayName);
displayNameField.attr('placeholder', fileTitle); displayNameField.attr('placeholder', fileTitle);
displayNameField.keyup((event) => { displayNameField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSubmit(displayNameField.val() || fileTitle, eid); renameSubmit(displayNameField.val() || fileTitle, eid);
} }
}); });
displayNameField.siblings('a.uk-form-icon').click(() => { displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val() || fileTitle, eid); renameSubmit(displayNameField.val() || fileTitle, eid);
}); });
const sortTitleField = $('#sort-title-field'); const sortTitleField = $('#sort-title-field');
sortTitleField.val(sortTitle); sortTitleField.val(sortTitle);
sortTitleField.attr('placeholder', fileTitle); sortTitleField.attr('placeholder', fileTitle);
sortTitleField.keyup((event) => { sortTitleField.keyup(event => {
if (event.keyCode === 13) { if (event.keyCode === 13) {
renameSortNameSubmit(sortTitleField.val(), eid); renameSortNameSubmit(sortTitleField.val(), eid);
} }
}); });
sortTitleField.siblings('a.uk-form-icon').click(() => { sortTitleField.siblings('a.uk-form-icon').click(() => {
renameSortNameSubmit(sortTitleField.val(), eid); renameSortNameSubmit(sortTitleField.val(), eid);
}); });
setupUpload(eid); setupUpload(eid);
UIkit.modal($('#edit-modal')).show(); UIkit.modal($('#edit-modal')).show();
}; };
UIkit.util.on(document, 'hidden', '#edit-modal', () => { UIkit.util.on(document, 'hidden', '#edit-modal', () => {
const displayNameField = $('#display-name-field'); const displayNameField = $('#display-name-field');
displayNameField.off('keyup'); displayNameField.off('keyup');
displayNameField.off('click'); displayNameField.off('click');
const sortTitleField = $('#sort-title-field'); const sortTitleField = $('#sort-title-field');
sortTitleField.off('keyup'); sortTitleField.off('keyup');
sortTitleField.off('click'); 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);
const titleId = upload.attr('data-title-id'); const titleId = upload.attr('data-title-id');
const queryObj = { const queryObj = {
tid: titleId, tid: titleId
}; };
if (eid) queryObj['eid'] = eid; if (eid)
const query = $.param(queryObj); queryObj['eid'] = eid;
const url = `${base_url}api/admin/upload/cover?${query}`; const query = $.param(queryObj);
UIkit.upload('.upload-field', { const url = `${base_url}api/admin/upload/cover?${query}`;
url, UIkit.upload('.upload-field', {
name: 'file', url: url,
error: (e) => { name: 'file',
alert('danger', `Failed to upload cover image: ${e.toString()}`); error: (e) => {
}, alert('danger', `Failed to upload cover image: ${e.toString()}`);
loadStart: (e) => { },
$(bar).removeAttr('hidden'); loadStart: (e) => {
bar.max = e.total; $(bar).removeAttr('hidden');
bar.value = e.loaded; bar.max = e.total;
}, bar.value = e.loaded;
progress: (e) => { },
bar.max = e.total; progress: (e) => {
bar.value = e.loaded; bar.max = e.total;
}, bar.value = e.loaded;
loadEnd: (e) => { },
bar.max = e.total; loadEnd: (e) => {
bar.value = e.loaded; bar.max = e.total;
}, bar.value = e.loaded;
completeAll: () => { },
$(bar).attr('hidden', ''); completeAll: () => {
location.reload(); $(bar).attr('hidden', '');
}, location.reload();
}); }
});
}; };
const deselectAll = () => { const deselectAll = () => {
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
data['selected'] = false; data['selected'] = false;
}); });
$('#select-bar')[0].__x.$data['count'] = 0; $('#select-bar')[0].__x.$data['count'] = 0;
}; };
const selectAll = () => { const selectAll = () => {
let count = 0; let count = 0;
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
if (!data['disabled']) { if (!data['disabled']) {
data['selected'] = true; data['selected'] = true;
count++; count++;
} }
}); });
$('#select-bar')[0].__x.$data['count'] = count; $('#select-bar')[0].__x.$data['count'] = count;
}; };
const selectedIDs = () => { const selectedIDs = () => {
const ary = []; const ary = [];
$('.item .uk-card').each((i, e) => { $('.item .uk-card').each((i, e) => {
const data = e.__x.$data; const data = e.__x.$data;
if (!data['disabled'] && data['selected']) { if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item'); const item = $(e).closest('.item');
ary.push($(item).attr('id')); ary.push($(item).attr('id'));
} }
}); });
return ary; return ary;
}; };
const bulkProgress = (action, el) => { const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id'); const tid = $(el).attr('data-id');
const ids = selectedIDs(); const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`; const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({ $.ajax({
type: 'PUT', type: 'PUT',
url, url: url,
contentType: 'application/json', contentType: "application/json",
dataType: 'json', dataType: 'json',
data: JSON.stringify({ data: JSON.stringify({
ids, ids: ids
}), })
}) })
.done((data) => { .done(data => {
if (data.error) { if (data.error) {
alert( alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
'danger', return;
`Failed to mark entries as ${action}. Error: ${data.error}`, }
); location.reload();
return; })
} .fail((jqXHR, status) => {
location.reload(); alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}) })
.fail((jqXHR, status) => { .always(() => {
alert( deselectAll();
'danger', });
`Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
})
.always(() => {
deselectAll();
});
}; };
const tagsComponent = () => { const tagsComponent = () => {
return { return {
isAdmin: false, isAdmin: false,
tags: [], tags: [],
tid: $('.upload-field').attr('data-title-id'), tid: $('.upload-field').attr('data-title-id'),
loading: true, loading: true,
load(admin) { load(admin) {
this.isAdmin = admin; this.isAdmin = admin;
$('.tag-select').select2({ $('.tag-select').select2({
tags: true, tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found', placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin, disabled: !this.isAdmin,
templateSelection(state) { templateSelection(state) {
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute( a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
'href', a.setAttribute('class', 'uk-link-reset');
`${base_url}tags/${encodeURIComponent(state.text)}`, a.onclick = event => {
); event.stopPropagation();
a.setAttribute('class', 'uk-link-reset'); };
a.onclick = (event) => { a.innerText = state.text;
event.stopPropagation(); return a;
}; }
a.innerText = state.text; });
return a;
},
});
this.request(`${base_url}api/tags`, 'GET', (data) => { this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags; const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`; const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => { this.request(url, 'GET', data => {
this.tags = data.tags; this.tags = data.tags;
allTags.forEach((t) => { allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0); const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op); $('.tag-select').append(op);
}); });
$('.tag-select').on('select2:select', (e) => { $('.tag-select').on('select2:select', e => {
this.onAdd(e); this.onAdd(e);
}); });
$('.tag-select').on('select2:unselect', (e) => { $('.tag-select').on('select2:unselect', e => {
this.onDelete(e); this.onDelete(e);
}); });
$('.tag-select').on('change', () => { $('.tag-select').on('change', () => {
this.onChange(); this.onChange();
}); });
$('.tag-select').trigger('change'); $('.tag-select').trigger('change');
this.loading = false; this.loading = false;
}); });
}); });
}, },
onChange() { onChange() {
this.tags = $('.tag-select') this.tags = $('.tag-select').select2('data').map(o => o.text);
.select2('data') },
.map((o) => o.text); onAdd(event) {
}, const tag = event.params.data.text;
onAdd(event) { const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
const tag = event.params.data.text; this.request(url, 'PUT');
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent( },
tag, onDelete(event) {
)}`; const tag = event.params.data.text;
this.request(url, 'PUT'); const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
}, this.request(url, 'DELETE');
onDelete(event) { },
const tag = event.params.data.text; request(url, method, cb) {
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent( $.ajax({
tag, url: url,
)}`; method: method,
this.request(url, 'DELETE'); dataType: 'json'
}, })
request(url, method, cb) { .done(data => {
$.ajax({ if (data.success) {
url, if (cb) cb(data);
method, } else {
dataType: 'json', alert('danger', data.error);
}) }
.done((data) => { })
if (data.success) { .fail((jqXHR, status) => {
if (cb) cb(data); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
} else { });
alert('danger', data.error); }
} };
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
};
}; };

View File

@@ -1,6 +1,6 @@
$(() => { $(() => {
let target = base_url + 'admin/user/edit'; var target = base_url + 'admin/user/edit';
if (username) target += username; if (username) target += username;
$('form').attr('action', target); $('form').attr('action', target);
if (error) alert('danger', error); if (error) alert('danger', error);
}); });

View File

@@ -1,17 +1,16 @@
const remove = (username) => { const remove = (username) => {
$.ajax({ $.ajax({
url: `${base_url}api/admin/user/delete/${username}`, url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE', type: 'DELETE',
dataType: 'json', dataType: 'json'
}) })
.done((data) => { .done(data => {
if (data.success) location.reload(); if (data.success)
else alert('danger', data.error); location.reload();
}) else
.fail((jqXHR, status) => { alert('danger', data.error);
alert( })
'danger', .fail((jqXHR, status) => {
`Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`, alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
); });
});
}; };

View File

@@ -50,7 +50,7 @@ shards:
koa: koa:
git: https://github.com/hkalexling/koa.git git: https://github.com/hkalexling/koa.git
version: 0.9.0 version: 0.8.0
mg: mg:
git: https://github.com/hkalexling/mg.git git: https://github.com/hkalexling/mg.git
@@ -68,10 +68,6 @@ shards:
git: https://github.com/luislavena/radix.git git: https://github.com/luislavena/radix.git
version: 0.4.1 version: 0.4.1
sanitize:
git: https://github.com/hkalexling/sanitize.git
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
sqlite3: sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0 version: 0.18.0

View File

@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.27.0 version: 0.25.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -42,5 +42,3 @@ dependencies:
branch: master branch: master
mg: mg:
github: hkalexling/mg github: hkalexling/mg
sanitize:
github: hkalexling/sanitize

View File

@@ -1,6 +0,0 @@
{
"id": "test",
"title": "Test Plugin",
"placeholder": "placeholder",
"wait_seconds": 1
}

View File

@@ -1,31 +1,14 @@
require "./spec_helper" require "./spec_helper"
describe Config do describe Config do
it "creates default config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |config, path| with_default_config do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
config.port.should eq 9000
end end
end end
it "correctly loads config" do it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml" config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000 config.port.should eq 3000
config.base_url.should eq "/"
end
it "correctly reads config defaults from ENV" do
ENV["LOG_LEVEL"] = "debug"
config = Config.load "spec/asset/test-config.yml"
config.log_level.should eq "debug"
config.base_url.should eq "/"
end
it "correctly handles ENV truthiness" do
ENV["CACHE_ENABLED"] = "false"
config = Config.load "spec/asset/test-config.yml"
config.cache_enabled.should be_false
config.cache_log_enabled.should be_true
config.disable_login.should be_false
end end
end end

View File

@@ -1,70 +0,0 @@
require "./spec_helper"
describe Plugin do
describe "helper functions" do
it "mango.text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<a href="https://github.com">Click Me<a>');
JS
res.should eq "Click Me"
end
end
it "mango.text returns empty string when no text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<img src="https://github.com" />');
JS
res.should eq ""
end
end
it "mango.css" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
JS
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
end
end
it "mango.css returns empty array when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
JS
res.should eq [] of String
end
end
it "mango.attribute" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
JS
res.should eq "https://github.com"
end
end
it "mango.attribute returns undefined when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div />', 'href') === undefined;
JS
res.should be_true
end
end
# https://github.com/hkalexling/Mango/issues/320
it "mango.attribute handles tags in attribute values" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
JS
res.should eq "test"
end
end
end
end

View File

@@ -3,7 +3,6 @@ require "../src/queue"
require "../src/server" require "../src/server"
require "../src/config" require "../src/config"
require "../src/main_fiber" require "../src/main_fiber"
require "../src/plugin/plugin"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
@@ -55,10 +54,3 @@ def with_storage
end end
end end
end end
def with_plugin
with_default_config do
plugin = Plugin.new "test", "spec/asset/plugins"
yield plugin
end
end

View File

@@ -1,51 +1,31 @@
require "yaml" require "yaml"
class Config class Config
private OPTIONS = {
"host" => "0.0.0.0",
"port" => 9000,
"base_url" => "/",
"session_secret" => "mango-session-secret",
"library_path" => "~/mango/library",
"library_cache_path" => "~/mango/library.yml.gz",
"db_path" => "~/mango/mango.db",
"queue_db_path" => "~/mango/queue.db",
"scan_interval_minutes" => 5,
"thumbnail_generation_interval_hours" => 24,
"log_level" => "info",
"upload_path" => "~/mango/uploads",
"plugin_path" => "~/mango/plugins",
"download_timeout_seconds" => 30,
"cache_enabled" => true,
"cache_size_mbs" => 50,
"cache_log_enabled" => true,
"disable_login" => false,
"default_username" => "",
"auth_proxy_header_name" => "",
"plugin_update_interval_hours" => 24,
}
include YAML::Serializable include YAML::Serializable
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
property path : String = "" property path = ""
property host = "0.0.0.0"
# Go through the options constant above and define them as properties. property port : Int32 = 9000
# Allow setting the default values through environment variables. property base_url = "/"
# Overall precedence: config file > environment variable > default value property session_secret = "mango-session-secret"
{% begin %} property library_path = "~/mango/library"
{% for k, v in OPTIONS %} property library_cache_path = "~/mango/library.yml.gz"
{% if v.is_a? StringLiteral %} property db_path = "~/mango/mango.db"
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }} property queue_db_path = "~/mango/queue.db"
{% elsif v.is_a? NumberLiteral %} property scan_interval_minutes : Int32 = 5
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i property thumbnail_generation_interval_hours : Int32 = 24
{% elsif v.is_a? BoolLiteral %} property log_level = "info"
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }} property upload_path = "~/mango/uploads"
{% else %} property plugin_path = "~/mango/plugins"
raise "Unknown type in config option: {{ v.class_name.id }}" property download_timeout_seconds : Int32 = 30
{% end %} property cache_enabled = true
{% end %} property cache_size_mbs = 50
{% end %} property cache_log_enabled = true
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property plugin_update_interval_hours : Int32 = 24
@@singlet : Config? @@singlet : Config?
@@ -58,7 +38,7 @@ class Config
end end
def self.load(path : String?) def self.load(path : String?)
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path

View File

@@ -6,7 +6,6 @@ class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub # Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic" BASIC = "Basic"
BEARER = "Bearer"
AUTH = "Authorization" AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \ AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials" "You have to login with proper credentials"
@@ -19,14 +18,8 @@ class AuthHandler < Kemal::Handler
end end
def require_auth(env) def require_auth(env)
if request_path_startswith env, ["/api"] env.session.string "callback", env.request.path
# Do not redirect API requests redirect env, "/login"
env.response.status_code = 401
send_text env, "Unauthorized"
else
env.session.string "callback", env.request.path
redirect env, "/login"
end
end end
def validate_token(env) def validate_token(env)
@@ -42,18 +35,13 @@ class AuthHandler < Kemal::Handler
def validate_auth_header(env) def validate_auth_header(env)
if env.request.headers[AUTH]? if env.request.headers[AUTH]?
if value = env.request.headers[AUTH] if value = env.request.headers[AUTH]
if value.starts_with? BASIC if value.size > 0 && value.starts_with?(BASIC)
token = verify_user value token = verify_user value
return false if token.nil? return false if token.nil?
env.session.string "token", token env.session.string "token", token
return true return true
end end
if value.starts_with? BEARER
session_id = value.split(" ")[1]
token = Kemal::Session.get(session_id).try &.string? "token"
return !token.nil? && Storage.default.verify_token token
end
end end
end end
false false
@@ -66,10 +54,6 @@ class AuthHandler < Kemal::Handler
end end
def call(env) def call(env)
# OPTIONS requests do not require authentication
if env.request.method === "OPTIONS"
return call_next(env)
end
# Skip all authentication if requesting /login, /logout, /api/login, # Skip all authentication if requesting /login, /logout, /api/login,
# or a static file # or a static file
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) || if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
@@ -78,8 +62,8 @@ class AuthHandler < Kemal::Handler
end end
# Check user is logged in # Check user is logged in
if validate_token(env) || validate_auth_header(env) if validate_token env
# Skip if the request has a valid token (either from cookies or header) # Skip if the request has a valid token
elsif Config.current.disable_login elsif Config.current.disable_login
# Check default username if login is disabled # Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username unless Storage.default.username_exists Config.current.default_username

View File

@@ -1,8 +0,0 @@
class CORSHandler < Kemal::Handler
def call(env)
if request_path_startswith env, ["/api"]
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
call_next env
end
end

View File

@@ -1,111 +0,0 @@
require "yaml"
require "./entry"
class ArchiveEntry < Entry
include YAML::Serializable
getter zip_path : String
def initialize(@zip_path, @book)
storage = Storage.default
@path = @zip_path
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def examine : Bool
File.exists? @zip_path
end
def self.is_valid?(path : String) : Bool
is_supported_file path
end
end

View File

@@ -76,8 +76,8 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
entries : Array(Entry), opt : SortOptions?) entries : Array(Entry), opt : SortOptions?)
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : "" user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context + sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil")) (opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_entries" "#{sig}:sorted_entries"
end end
end end
@@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?) def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : "" user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(titles_sig + user_context + sig = Digest::SHA1.hexdigest (titles_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil")) (opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_titles" "#{sig}:sorted_titles"
end end
end end

View File

@@ -1,132 +0,0 @@
require "yaml"
require "./entry"
class DirEntry < Entry
include YAML::Serializable
getter dir_path : String
@[YAML::Field(ignore: true)]
@sorted_files : Array(String)?
@signature : String
def initialize(@dir_path, @book)
storage = Storage.default
@path = @dir_path
@encoded_path = URI.encode @dir_path
@title = File.basename @dir_path
@encoded_title = URI.encode @title
unless File.readable? @dir_path
@err_msg = "Directory #{@dir_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
unless DirEntry.is_valid? @dir_path
@err_msg = "Directory #{@dir_path} is not valid directory entry."
Logger.warn "#{@err_msg} Please make sure the " \
"directory has valid images."
return
end
size_sum = 0
sorted_files.each do |file_path|
size_sum += File.size file_path
end
@size = size_sum.humanize_bytes
@signature = Dir.directory_entry_signature @dir_path
id = storage.get_entry_id @dir_path, @signature
if id.nil?
id = random_str
storage.insert_entry_id({
path: @dir_path,
id: id,
signature: @signature,
})
end
@id = id
@mtime = sorted_files.map do |file_path|
File.info(file_path).modification_time
end.max
@pages = sorted_files.size
end
def read_page(page_num)
img = nil
begin
files = sorted_files
file_path = files[page_num - 1]
data = File.read(file_path).to_slice
if data
img = Image.new data, MIME.from_filename(file_path),
File.basename(file_path), data.size
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_files.each_with_index do |path, i|
data = File.read(path).to_slice
begin
data.not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
sizes
end
def examine : Bool
existence = File.exists? @dir_path
return false unless existence
files = DirEntry.image_files @dir_path
signature = Dir.directory_entry_signature @dir_path
existence = files.size > 0 && @signature == signature
@sorted_files = nil unless existence
# For more efficient, update a directory entry with new property
# and return true like Title.examine
existence
end
def sorted_files
cached_sorted_files = @sorted_files
return cached_sorted_files if cached_sorted_files
@sorted_files = DirEntry.sorted_image_files @dir_path
@sorted_files.not_nil!
end
def self.image_files(dir_path)
Dir.entries(dir_path)
.reject(&.starts_with? ".")
.map { |fn| File.join dir_path, fn }
.select { |fn| is_supported_image_file fn }
.reject { |fn| File.directory? fn }
.select { |fn| File.readable? fn }
end
def self.sorted_image_files(dir_path)
self.image_files(dir_path)
.sort { |a, b| compare_numerically a, b }
end
def self.is_valid?(path : String) : Bool
image_files(path).size > 0
end
end

View File

@@ -1,55 +1,63 @@
require "image_size" require "image_size"
require "yaml"
private def node_has_key(node : YAML::Nodes::Mapping, key : String) class Entry
node.nodes include YAML::Serializable
.map_with_index { |n, i| {n, i} }
.select(&.[1].even?)
.map(&.[0])
.select(YAML::Nodes::Scalar)
.map(&.as(YAML::Nodes::Scalar).value)
.includes? key
end
abstract class Entry getter zip_path : String, book : Title, title : String,
getter id : String, book : Title, title : String, path : String, size : String, pages : Int32, id : String, encoded_path : String,
size : String, pages : Int32, mtime : Time, encoded_title : String, mtime : Time, err_msg : String?
encoded_path : String, encoded_title : String, err_msg : String?
def initialize( @[YAML::Field(ignore: true)]
@id, @title, @book, @path, @sort_title : String?
@size, @pages, @mtime,
@encoded_path, @encoded_title, @err_msg
)
end
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) def initialize(@zip_path, @book)
unless node.is_a? YAML::Nodes::Mapping storage = Storage.default
raise "Unexpected node type in YAML" @encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end end
# Doing YAML::Any.new(ctx, node) here causes a weird error, so @id = id
# instead we are using a more hacky approach (see `node_has_key`). @mtime = File.info(@zip_path).modification_time
# TODO: Use a more elegant approach
if node_has_key node, "zip_path" unless File.readable? @zip_path
ArchiveEntry.new ctx, node @err_msg = "File #{@zip_path} is not readable."
elsif node_has_key node, "dir_path" Logger.warn "#{@err_msg} Please make sure the " \
DirEntry.new ctx, node "file permission is configured correctly."
else return
raise "Unknown entry found in YAML cache. Try deleting the " \
"`library.yml.gz` file"
end end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end end
def build_json(*, slim = false) def build_json(*, slim = false)
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for str in %w(path title size id) %} {% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, {{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
if err_msg
json.field "err_msg", err_msg
end
json.field "zip_path", path # for API backward compatability
json.field "path", path
json.field "title_id", @book.id json.field "title_id", @book.id
json.field "title_title", @book.title json.field "title_title", @book.title
json.field "sort_title", sort_title json.field "sort_title", sort_title
@@ -63,9 +71,6 @@ abstract class Entry
end end
end end
@[YAML::Field(ignore: true)]
@sort_title : String?
def sort_title def sort_title
sort_title_cached = @sort_title sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached return sort_title_cached if sort_title_cached
@@ -123,6 +128,58 @@ abstract class Entry
url url
end end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username) def next_entry(username)
entries = @book.sorted_entries username entries = @book.sorted_entries username
idx = entries.index self idx = entries.index self
@@ -137,6 +194,20 @@ abstract class Entry
entries[idx - 1] entries[idx - 1]
end end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
end
# For backward backward compatibility with v0.1.0, we save entry titles # For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json # instead of IDs in info.json
def save_progress(username, page) def save_progress(username, page)
@@ -216,7 +287,7 @@ abstract class Entry
end end
Storage.default.save_thumbnail @id, img Storage.default.save_thumbnail @id, img
rescue e rescue e
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}" Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end end
img img
@@ -225,34 +296,4 @@ abstract class Entry
def get_thumbnail : Image? def get_thumbnail : Image?
Storage.default.get_thumbnail @id Storage.default.get_thumbnail @id
end end
def date_added : Time
date_added = Time::UNIX_EPOCH
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime path
info.save
else
date_added = info_da
end
end
date_added
end
# Hack to have abstract class methods
# https://github.com/crystal-lang/crystal/issues/5956
private module ClassMethods
abstract def is_valid?(path : String) : Bool
end
macro inherited
extend ClassMethods
end
abstract def read_page(page_num)
abstract def page_dimensions
abstract def examine : Bool?
end end

View File

@@ -139,31 +139,14 @@ class Library
titles.flat_map &.deep_entries titles.flat_map &.deep_entries
end end
def build_json(*, slim = false, depth = -1, sort_context = nil, def build_json(*, slim = false, depth = -1)
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
JSON.build do |json| JSON.build do |json|
json.object do json.object do
json.field "dir", @dir json.field "dir", @dir
json.field "titles" do json.field "titles" do
json.array do json.array do
_titles.each do |title| self.titles.each do |title|
json.raw title.build_json(slim: slim, depth: depth, json.raw title.build_json(slim: slim, depth: depth)
sort_context: sort_context, percentage: percentage)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |title|
json.number title.load_percentage sort_context[:username]
end
end end
end end
end end

View File

@@ -49,18 +49,13 @@ class Title
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, cache title = Title.new path, @id, cache
unless title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
end
if DirEntry.is_valid? path
entry = DirEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
next next
end end
if is_supported_file path if is_supported_file path
entry = ArchiveEntry.new path, self entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg @entries << entry if entry.pages > 0 || entry.err_msg
end end
end end
@@ -132,12 +127,12 @@ class Title
previous_entries_size = @entries.size previous_entries_size = @entries.size
@entries.select! do |entry| @entries.select! do |entry|
existence = entry.examine existence = File.exists? entry.zip_path
Fiber.yield Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence context["deleted_entry_ids"] << entry.id unless existence
existence existence
end end
remained_entry_paths = @entries.map &.path remained_entry_zip_paths = @entries.map &.zip_path
is_titles_added = false is_titles_added = false
is_entries_added = false is_entries_added = false
@@ -145,43 +140,29 @@ class Title
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
unless remained_entry_paths.includes? path
if DirEntry.is_valid? path
entry = DirEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
next if remained_title_dirs.includes? path next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"] title = Title.new path, @id, context["cached_contents_signature"]
unless title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title Library.default.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
is_titles_added = true is_titles_added = true
# We think they are removed, but they are here! # We think they are removed, but they are here!
# Cancel reserved jobs # Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id| context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id) !(revival_title_ids.includes? deleted_title_id)
end end
revival_entry_ids = title.deep_entries.map &.id revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id| context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id) !(revival_entry_ids.includes? deleted_entry_id)
end
end end
next next
end end
if is_supported_file path if is_supported_file path
next if remained_entry_paths.includes? path next if remained_entry_zip_paths.includes? path
entry = ArchiveEntry.new path, self entry = Entry.new path, self
if entry.pages > 0 || entry.err_msg if entry.pages > 0 || entry.err_msg
@entries << entry @entries << entry
is_entries_added = true is_entries_added = true
@@ -221,21 +202,7 @@ class Title
alias SortContext = NamedTuple(username: String, opt: SortOptions) alias SortContext = NamedTuple(username: String, opt: SortOptions)
def build_json(*, slim = false, depth = -1, def build_json(*, slim = false, depth = -1,
sort_context : SortContext? = nil, sort_context : SortContext? = nil)
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for str in ["dir", "title", "id"] %} {% for str in ["dir", "title", "id"] %}
@@ -251,39 +218,25 @@ class Title
unless depth == 0 unless depth == 0
json.field "titles" do json.field "titles" do
json.array do json.array do
_titles.each do |title| self.titles.each do |title|
json.raw title.build_json(slim: slim, json.raw title.build_json(slim: slim,
depth: depth > 0 ? depth - 1 : depth, depth: depth > 0 ? depth - 1 : depth)
sort_context: sort_context, percentage: percentage)
end end
end end
end end
json.field "entries" do json.field "entries" do
json.array do json.array do
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
_entries.each do |entry| _entries.each do |entry|
json.raw entry.build_json(slim: slim) json.raw entry.build_json(slim: slim)
end end
end end
end end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |t|
json.number t.load_percentage sort_context[:username]
end
end
end
json.field "entry_percentages" do
json.array do
load_percentage_for_all_entries(
sort_context[:username],
sort_context[:opt]
).each do |p|
json.number p.nan? ? 0 : p
end
end
end
end
end end
json.field "parents" do json.field "parents" do
json.array do json.array do
@@ -632,16 +585,6 @@ class Title
if last_read_entry && last_read_entry.finished? username if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username last_read_entry = last_read_entry.next_entry username
if last_read_entry.nil?
# The last entry is finished. Return the first unfinished entry
# (if any)
sorted_entries(username).each do |e|
unless e.finished? username
last_read_entry = e
break
end
end
end
end end
last_read_entry last_read_entry
@@ -656,7 +599,7 @@ class Title
@entries.each do |e| @entries.each do |e|
next if da.has_key? e.title next if da.has_key? e.title
da[e.title] = ctime e.path da[e.title] = ctime e.zip_path
end end
TitleInfo.new @dir do |info| TitleInfo.new @dir do |info|

View File

@@ -1,3 +1,13 @@
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
)
enum SortMethod enum SortMethod
Auto Auto
Title Title
@@ -45,13 +55,6 @@ class SortOptions
def to_tuple def to_tuple
{@method.to_s.underscore, ascend} {@method.to_s.underscore, ascend}
end end
def to_json
{
"method" => method.to_s.underscore,
"ascend" => ascend,
}.to_json
end
end end
struct Image struct Image

View File

@@ -38,7 +38,6 @@ class Logger
Log.setup do |c| Log.setup do |c|
c.bind "*", @@severity, @backend c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend c.bind "db.*", :error, @backend
c.bind "duktape", :none, @backend
end end
end end

View File

@@ -7,7 +7,7 @@ require "option_parser"
require "clim" require "clim"
require "tallboy" require "tallboy"
MANGO_VERSION = "0.27.0" MANGO_VERSION = "0.25.0"
# From http://www.network-science.de/ascii/ # From http://www.network-science.de/ascii/
BANNER = %{ BANNER = %{

View File

@@ -105,10 +105,9 @@ class Plugin
getter js_path = "" getter js_path = ""
getter storage_path = "" getter storage_path = ""
def self.build_info_ary(dir : String? = nil) def self.build_info_ary
@@info_ary.clear @@info_ary.clear
dir ||= Config.current.plugin_path dir = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f| Dir.each_child dir do |f|
@@ -161,8 +160,8 @@ class Plugin
list.save list.save
end end
def initialize(id : String, dir : String? = nil) def initialize(id : String)
Plugin.build_info_ary dir Plugin.build_info_ary
@info = @@info_ary.find &.id.== id @info = @@info_ary.find &.id.== id
if @info.nil? if @info.nil?
@@ -224,10 +223,6 @@ class Plugin
raise Error.new "Missing required fields in the Page type" raise Error.new "Missing required fields in the Page type"
end end
def can_subscribe? : Bool
info.version > 1 && eval_exists?("newChapters")
end
def search_manga(query : String) def search_manga(query : String)
if info.version == 1 if info.version == 1
raise Error.new "Manga searching is only available for plugins " \ raise Error.new "Manga searching is only available for plugins " \
@@ -320,7 +315,7 @@ class Plugin
json json
end end
def eval(str) private def eval(str)
@rt.eval str @rt.eval str
rescue e : Duktape::SyntaxError rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message raise SyntaxError.new e.message
@@ -332,15 +327,6 @@ class Plugin
JSON.parse eval(str).as String JSON.parse eval(str).as String
end end
private def eval_exists?(str) : Bool
@rt.eval str
true
rescue e : Duktape::ReferenceError
false
rescue e : Duktape::Error
raise Error.new e.message
end
private def def_helper_functions(sbx) private def def_helper_functions(sbx)
sbx.push_object sbx.push_object
@@ -449,15 +435,9 @@ class Plugin
env = Duktape::Sandbox.new ptr env = Duktape::Sandbox.new ptr
html = env.require_string 0 html = env.require_string 0
begin str = XML.parse(html).inner_text
parser = Myhtml::Parser.new html
str = parser.body!.children.first.inner_text
env.push_string str
rescue
env.push_string ""
end
env.push_string str
env.call_success env.call_success
end end
sbx.put_prop_string -2, "text" sbx.put_prop_string -2, "text"
@@ -468,9 +448,8 @@ class Plugin
name = env.require_string 1 name = env.require_string 1
begin begin
parser = Myhtml::Parser.new html attr = XML.parse(html).first_element_child.not_nil![name]
attr = parser.body!.children.first.attribute_by name env.push_string attr
env.push_string attr.not_nil!
rescue rescue
env.push_undefined env.push_undefined
end end

View File

@@ -1,5 +1,3 @@
require "sanitize"
struct AdminRouter struct AdminRouter
def initialize def initialize
get "/admin" do |env| get "/admin" do |env|
@@ -16,13 +14,13 @@ struct AdminRouter
end end
get "/admin/user/edit" do |env| get "/admin/user/edit" do |env|
sanitizer = Sanitize::Policy::Text.new username = env.params.query["username"]?
username = env.params.query["username"]?.try { |s| sanitizer.process s }
admin = env.params.query["admin"]? admin = env.params.query["admin"]?
if admin if admin
admin = admin == "true" admin = admin == "true"
end end
error = env.params.query["error"]?.try { |s| sanitizer.process s } error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil? new_user = username.nil? && admin.nil?
layout "user-edit" layout "user-edit"
end end

View File

@@ -40,19 +40,14 @@ struct APIRouter
Koa.schema "entry", { Koa.schema "entry", {
"pages" => Int32, "pages" => Int32,
"mtime" => Int64, "mtime" => Int64,
}.merge(s %w(zip_path path title size id title_id display_name cover_url)), }.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book" desc: "An entry in a book"
Koa.schema "title", { Koa.schema "title", {
"mtime" => Int64, "mtime" => Int64,
"entries" => ["entry"], "entries" => ["entry"],
"titles" => ["title"], "titles" => ["title"],
"parents" => [{ "parents" => [String],
"title" => String,
"id" => String,
}],
"title_percentages" => [Float64?],
"entry_percentages" => [Float64?],
}.merge(s %w(dir title id display_name cover_url)), }.merge(s %w(dir title id display_name cover_url)),
desc: "A manga title (a collection of entries and sub-titles)" desc: "A manga title (a collection of entries and sub-titles)"
@@ -85,12 +80,6 @@ struct APIRouter
"username" => String, "username" => String,
"password" => String, "password" => String,
} }
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"session_id" => String?,
"is_admin" => Bool?,
}
Koa.tag "users" Koa.tag "users"
post "/api/login" do |env| post "/api/login" do |env|
begin begin
@@ -99,18 +88,11 @@ struct APIRouter
token = Storage.default.verify_user(username, password).not_nil! token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token env.session.string "token", token
send_json env, { "Authenticated"
"success" => true,
"session_id" => env.session.id,
"is_admin" => Storage.default.username_is_admin username,
}.to_json
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 403 env.response.status_code = 403
send_json env, { e.message
"success" => false,
"error" => e.message,
}.to_json
end end
end end
@@ -142,19 +124,14 @@ struct APIRouter
env.response.status_code = 304 env.response.status_code = 304
"" ""
else else
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control env.response.headers["Cache-Control"] = "public, max-age=86400"
send_img env, img send_img env, img
end end
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
send_text env, e.message e.message
end end
end end
@@ -191,13 +168,11 @@ struct APIRouter
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 500 env.response.status_code = 500
send_text env, e.message e.message
end end
end end
Koa.describe "Returns the book with title `tid`", <<-MD Koa.describe "Returns the book with title `tid`", <<-MD
The entries and titles will be sorted by the default sorting method for the logged-in user.
- Supply the `percentage` query parameter to include the reading progress
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time - Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `depth` query parameter to control the depth of nested titles to return. - Supply the `depth` query parameter to control the depth of nested titles to return.
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them - When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
@@ -208,7 +183,8 @@ struct APIRouter
Koa.path "tid", desc: "Title ID" Koa.path "tid", desc: "Title ID"
Koa.query "slim" Koa.query "slim"
Koa.query "depth" Koa.query "depth"
Koa.query "percentage" Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
Koa.response 200, schema: "title" Koa.response 200, schema: "title"
Koa.response 404, "Title not found" Koa.response 404, "Title not found"
Koa.tag "library" Koa.tag "library"
@@ -216,104 +192,29 @@ struct APIRouter
begin begin
username = get_username env username = get_username env
sort_opt = SortOptions.new
get_sort_opt
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = Library.default.get_title tid title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
sort_opt = SortOptions.from_info_json title.dir, username
slim = !env.params.query["slim"]?.nil? slim = !env.params.query["slim"]?.nil?
depth = env.params.query["depth"]?.try(&.to_i?) || -1 depth = env.params.query["depth"]?.try(&.to_i?) || -1
percentage = !env.params.query["percentage"]?.nil?
send_json env, title.build_json(slim: slim, depth: depth, send_json env, title.build_json(slim: slim, depth: depth,
sort_context: {username: username, sort_context: {username: username,
opt: sort_opt}, percentage: percentage) opt: sort_opt})
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
send_text env, e.message e.message
end end
end end
Koa.describe "Returns the sorting option of a title or the library", <<-MD
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
- If the query parameter `tid` is missing, returns the sorting option of the library.
MD
Koa.query "tid"
Koa.response 200, schema: {
"method" => String?,
"ascend" => Bool?,
"error" => String?,
}
Koa.tag "library"
get "/api/sort_opt" do |env|
username = get_username env
tid = env.params.query["tid"]?
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
sort_opt = SortOptions.from_info_json dir, username
send_json env, sort_opt.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Updates the sorting option of a title or the library", <<-MD
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
- When the `tid` field is missing in the body, updates the sorting option of the library.
MD
Koa.body schema: {
"tid" => String?,
"method" => String,
"ascend" => Bool,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tag "library"
put "/api/sort_opt" do |env|
username = get_username env
tid = env.params.json["tid"]?.try &.as String
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
method = env.params.json["sort"].as String
ascend = env.params.json["ascend"].as Bool
sort_opt = SortOptions.new method, ascend
TitleInfo.new dir do |info|
info.sort_by[username] = sort_opt.to_tuple
info.save
end
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the entire library with all titles and entries", <<-MD Koa.describe "Returns the entire library with all titles and entries", <<-MD
The titles will be sorted by the default sorting method for the logged-in user.
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time - Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `dpeth` query parameter to control the depth of nested titles to return. - Supply the `dpeth` query parameter to control the depth of nested titles to return.
- Supply the `percentage` query parameter to include the reading progress
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it - When `depth` is 1, returns the requested title and sub-titles/entries one level in it
- When `depth` is 0, returns the requested title without its sub-titles/entries - When `depth` is 0, returns the requested title without its sub-titles/entries
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it - When `depth` is N, returns the requested title and sub-titles/entries N levels in it
@@ -321,162 +222,16 @@ struct APIRouter
MD MD
Koa.query "slim" Koa.query "slim"
Koa.query "depth" Koa.query "depth"
Koa.query "percentage"
Koa.response 200, schema: { Koa.response 200, schema: {
"dir" => String, "dir" => String,
"titles" => ["title"], "titles" => ["title"],
"title_percentage" => [Float64?],
} }
Koa.tag "library" Koa.tag "library"
get "/api/library" do |env| get "/api/library" do |env|
username = get_username env
sort_opt = SortOptions.from_info_json Library.default.dir, username
slim = !env.params.query["slim"]?.nil? slim = !env.params.query["slim"]?.nil?
depth = env.params.query["depth"]?.try(&.to_i?) || -1 depth = env.params.query["depth"]?.try(&.to_i?) || -1
percentage = !env.params.query["percentage"]?.nil?
send_json env, Library.default.build_json(slim: slim, depth: depth, send_json env, Library.default.build_json(slim: slim, depth: depth)
sort_context: {username: username,
opt: sort_opt}, percentage: percentage)
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the continue reading entries"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"entries" => ["entry"],
"entry_percentages" => [Float64],
}
Koa.tag "library"
get "/api/library/continue_reading" do |env|
username = get_username env
cr_entries = Library.default.get_continue_reading_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "entries" do
j.array do
cr_entries.each do |e|
j.raw e[:entry].build_json
end
end
end
j.field "entry_percentages" do
j.array do
cr_entries.each do |e|
j.number e[:percentage]
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the start reading titles"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library/start_reading" do |env|
username = get_username env
titles = Library.default.get_start_reading_titles username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "titles" do
j.array do
titles.each do |t|
j.raw t.build_json depth: 1
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the recently added items"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"items" => [{
"item" => "title | entry",
"percentage" => Float64,
"count" => Int32,
}],
}
Koa.tag "library"
get "/api/library/recently_added" do |env|
username = get_username env
ra_entries = Library.default.get_recently_added_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "items" do
j.array do
ra_entries.each do |e|
j.object do
j.field "item" do
if e[:grouped_count] === 1
j.raw e[:entry].build_json
else
j.raw e[:entry].book.build_json depth: 0
end
end
j.field "percentage" do
j.number e[:percentage]
end
j.field "count" do
j.number e[:grouped_count]
end
end
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end end
Koa.describe "Triggers a library scan" Koa.describe "Triggers a library scan"
@@ -512,7 +267,6 @@ struct APIRouter
spawn do spawn do
Library.default.generate_thumbnails Library.default.generate_thumbnails
end end
send_text env, ""
end end
Koa.describe "Deletes a user with `username`" Koa.describe "Deletes a user with `username`"
@@ -871,15 +625,13 @@ struct APIRouter
"version" => Int32, "version" => Int32,
"settings" => {} of String => String, "settings" => {} of String => String,
}, },
"subscribable" => Bool,
} }
get "/api/admin/plugin/info" do |env| get "/api/admin/plugin/info" do |env|
begin begin
plugin = Plugin.new env.params.query["plugin"].as String plugin = Plugin.new env.params.query["plugin"].as String
send_json env, { send_json env, {
"success" => true, "success" => true,
"info" => plugin.info, "info" => plugin.info,
"subscribable" => plugin.can_subscribe?,
}.to_json }.to_json
rescue e rescue e
Logger.error e Logger.error e
@@ -1145,24 +897,15 @@ struct APIRouter
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
if entry.is_a? DirEntry file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size)
else
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s)
end
e_tag = "W/#{file_hash}" e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag if e_tag == prev_e_tag
env.response.status_code = 304 env.response.status_code = 304
send_text env, "" ""
else else
sizes = entry.page_dimensions sizes = entry.page_dimensions
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control env.response.headers["Cache-Control"] = "public, max-age=86400"
send_json env, { send_json env, {
"success" => true, "success" => true,
"dimensions" => sizes, "dimensions" => sizes,
@@ -1188,11 +931,10 @@ struct APIRouter
title = (Library.default.get_title env.params.url["tid"]).not_nil! title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil! entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.path send_attachment env, entry.zip_path
rescue e rescue e
Logger.error e Logger.error e
env.response.status_code = 404 env.response.status_code = 404
send_text env, e.message
end end
end end

View File

@@ -53,7 +53,6 @@ struct ReaderRouter
render "src/views/reader.html.ecr" render "src/views/reader.html.ecr"
rescue e rescue e
Logger.error e Logger.error e
Logger.debug e.backtrace?
env.response.status_code = 404 env.response.status_code = 404
end end
end end

View File

@@ -25,17 +25,6 @@ class Server
APIRouter.new APIRouter.new
OPDSRouter.new OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env|
cors
halt env
end
{% end %}
static_headers do |response|
response.headers.add("Access-Control-Allow-Origin", "*")
end
Kemal.config.logging = false Kemal.config.logging = false
add_handler LogHandler.new add_handler LogHandler.new
add_handler AuthHandler.new add_handler AuthHandler.new

View File

@@ -19,7 +19,7 @@ class File
# information as long as the above changes do not happen together with # information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between. # a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64 def self.signature(filename) : UInt64
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename) if is_supported_file filename
File.info(filename).inode File.info(filename).inode
else else
0u64 0u64
@@ -67,9 +67,7 @@ class Dir
else else
# Only add its signature value to `signatures` when it is a # Only add its signature value to `signatures` when it is a
# supported file # supported file
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn) signatures << fn if is_supported_file fn
signatures << fn
end
end end
Fiber.yield Fiber.yield
end end
@@ -78,19 +76,4 @@ class Dir
cache[dirname] = hash cache[dirname] = hash
hash hash
end end
def self.directory_entry_signature(dirname, cache = {} of String => String)
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
Fiber.yield
signatures = [] of String
image_files = DirEntry.sorted_image_files dirname
if image_files.size > 0
image_files.each do |path|
signatures << File.signature(path).to_s
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname + "?entry"] = hash
hash
end
end end

View File

@@ -1,19 +1,8 @@
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8 ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads" UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
/manifest.json) SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
image/jxl
)
def random_str def random_str
UUID.random.to_s.gsub "-", "" UUID.random.to_s.gsub "-", ""
@@ -51,7 +40,6 @@ def register_mime_types
# defiend by Crystal in `MIME.DEFAULT_TYPES` # defiend by Crystal in `MIME.DEFAULT_TYPES`
".apng" => "image/apng", ".apng" => "image/apng",
".avif" => "image/avif", ".avif" => "image/avif",
".jxl" => "image/jxl",
}.each do |k, v| }.each do |k, v|
MIME.register k, v MIME.register k, v
end end
@@ -61,10 +49,6 @@ def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end end
def is_supported_image_file(path)
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
end
struct Int struct Int
def or(other : Int) def or(other : Int)
if self == 0 if self == 0
@@ -96,9 +80,9 @@ class String
end end
end end
def env_is_true?(key : String, default : Bool = false) : Bool def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]? val = ENV[key.upcase]? || ENV[key.downcase]?
return default unless val return false unless val
val.downcase.in? "1", "true" val.downcase.in? "1", "true"
end end
@@ -184,7 +168,7 @@ def delete_cache_and_exit(path : String)
File.delete path File.delete path
Logger.fatal "Invalid library cache deleted. Mango needs to " \ Logger.fatal "Invalid library cache deleted. Mango needs to " \
"perform a full reset to recover from this. " \ "perform a full reset to recover from this. " \
"Please restart Mango. This is NOT a bug." "Pleae restart Mango. This is NOT a bug."
Logger.fatal "Exiting" Logger.fatal "Exiting"
exit 1 exit 1
end end

View File

@@ -39,28 +39,13 @@ macro send_error_page(msg)
end end
macro send_img(env, img) macro send_img(env, img)
cors
send_file {{env}}, {{img}}.data, {{img}}.mime send_file {{env}}, {{img}}.data, {{img}}.mime
end end
def get_token_from_auth_header(env) : String?
value = env.request.headers["Authorization"]
if value && value.starts_with? "Bearer"
session_id = value.split(" ")[1]
return Kemal::Session.get(session_id).try &.string? "token"
end
end
macro get_username(env) macro get_username(env)
begin begin
# Check if we can get the session id from the cookie token = env.session.string "token"
token = env.session.string? "token" (Storage.default.verify_token token).not_nil!
if token.nil?
# If not, check if we can get the session id from the auth header
token = get_token_from_auth_header env
end
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
(Storage.default.verify_token token.not_nil!).not_nil!
rescue e rescue e
if Config.current.disable_login if Config.current.disable_login
Config.current.default_username Config.current.default_username
@@ -72,29 +57,12 @@ macro get_username(env)
end end
end end
macro cors
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
"DELETE,OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
"Authorization"
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
def send_json(env, json) def send_json(env, json)
cors
env.response.content_type = "application/json" env.response.content_type = "application/json"
env.response.print json env.response.print json
end end
def send_text(env, text)
cors
env.response.content_type = "text/plain"
env.response.print text
end
def send_attachment(env, path) def send_attachment(env, path)
cors
send_file env, path, filename: File.basename(path), disposition: "attachment" send_file env, path, filename: File.basename(path), disposition: "attachment"
end end

View File

@@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" /> <link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" /> <link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" /> <link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" /> <link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />

View File

@@ -133,10 +133,8 @@
</template> </template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button> <button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button> <button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span x-show="subscribable"> <span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<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>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
</form> </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 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>

View File

@@ -5,7 +5,7 @@
<div> <div>
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3> <h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
</div> </div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p> <p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">

View File

@@ -5,7 +5,7 @@
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()"> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'"> <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@@ -19,7 +19,7 @@
</div> </div>
<div <div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;"> :class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak> <div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-if="!loading && mode === 'continuous'" x-for="item in items"> <template x-if="!loading && mode === 'continuous'" x-for="item in items">
<img <img
@@ -30,7 +30,7 @@
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`" :style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="clickImage($event)" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
@@ -40,18 +40,18 @@
<%- end -%> <%- end -%>
</div> </div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`"> <div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{ <img uk-img :class="{
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${fitType === 'horz' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${fitType === 'vert' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' }; max-width:100%;
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'}; max-height:100%;
object-fit: contain; object-fit: contain;
`" /> `" />
@@ -67,7 +67,7 @@
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3> <h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p> <p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div> </div>
<div class="uk-modal-body"> <div class="uk-modal-body">
<div class="uk-margin"> <div class="uk-margin">
@@ -94,17 +94,6 @@
</div> </div>
</div> </div>
<div class="uk-margin" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="mode-select">Page fit</label>
<div class="uk-form-controls">
<select id="fit-select" class="uk-select" @change="fitChanged()">
<option value="vert">Fit height</option>
<option value="horz">Fit width</option>
<option value="real">Real size</option>
</select>
</div>
</div>
<div class="uk-margin" x-show="mode === 'continuous'"> <div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label> <label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls"> <div class="uk-form-controls">