Compare commits

...

85 Commits

Author SHA1 Message Date
Alex Ling
6f39c2a74c Restrict table selection to id seletable
fixes #335
2022-09-09 13:47:30 +00:00
Alex Ling
61d6c2e1d9 Fix incorrect default DB path 2022-09-05 03:18:44 +00:00
Alex Ling
ce559984e6 Fix uikit version to ~3.14.0 (fixes #334) 2022-09-05 03:17:36 +00:00
Alex Ling
76b4666708 Merge pull request #331 from getmango/feature/eslint
Add ESLint
2022-08-19 20:31:58 +08:00
Alex Ling
5bdeca94fe Merge dev 2022-08-19 12:12:45 +00:00
Alex Ling
f8c569f204 Merge branch 'dev' into feature/eslint 2022-08-19 12:11:00 +00:00
Alex Ling
7ef2e4d162 Add eslint to make check 2022-08-19 12:06:40 +00:00
Alex Ling
28c098a56e Add eslint and style fix 2022-08-19 12:05:38 +00:00
Alex Ling
2597b4ce60 Merge pull request #330 from getmango/fix/subscription-manager-single-plugin
Correctly load subscriptions when there's only one plugin
2022-08-19 19:32:49 +08:00
Alex Ling
cd3ee0728c Handle the case where only one plugin is installed
fixes #329
2022-08-19 11:07:45 +00:00
Alex Ling
e4af194d0c Merge pull request #328 from dudeitssm/patch
Fix typo: "Pleae" should be "Please"
2022-08-10 20:54:11 +08:00
dudeitssm
586ebf8dc8 Fix typo: "Pleae" should be "Please" 2022-08-01 00:06:31 +00:00
Alex Ling
1fb48648ad Merge pull request #322 from getmango/rc/0.27.0
v0.27.0
2022-07-31 22:53:11 +08:00
Alex Ling
7ceb91f051 Merge branch 'rc/0.27.0' into dev 2022-07-31 13:55:02 +00:00
Alex Ling
9ea4ced729 Merge pull request #327 from phlhg/fix/static-manifest
Fix for Error 404 on manifest.json
2022-07-31 21:54:06 +08:00
Alex Ling
4c2f802e2e Fix linter 2022-07-31 10:19:21 +00:00
Philippe Hugo
7258b3cece Add /manifest.json to static files 2022-07-27 17:59:11 +02:00
Alex Ling
bf885a8b30 Bump version to 0.27.0 2022-07-18 12:38:22 +00:00
Alex Ling
98a0c54499 Merge pull request #311 from hkalexling/fix/hide-subscribe-btn
Hide subscribe btn
2022-07-18 20:03:10 +08:00
Alex Ling
cb3df432d0 Merge branch 'dev' into fix/hide-subscribe-btn 2022-07-18 19:42:23 +08:00
Alex Ling
47af6ee284 Merge pull request #321 from hkalexling/fix/plugin-use-html-parser
Use html parser in plugin helper functions
2022-07-18 19:41:40 +08:00
Alex Ling
9fe269ab13 Disable plugin_spec.cr line limit 2022-07-17 15:24:01 +00:00
Alex Ling
75a30a88e0 Use myhtml in plugin helper and add tests (#320) 2022-07-17 14:54:25 +00:00
Alex Ling
5daeac72cb Merge pull request #317 from Hiers/feature/image-fit
Fit image options
2022-07-17 11:55:35 +08:00
Hiers
dc3ac42dec Right flip panels are 1/3 of the rightmost area of the entire screen, not of the page. (same for left flip panels) 2022-07-16 12:09:23 +01:00
Hiers
624283643c Fixed right flip panel not being all the way on the right; changed real image size option to not be hard coded. 2022-07-13 14:20:43 +01:00
Hiers
6ddbe8d436 Changed setFit function to not have redundant ifs and a better comment explaining what it does. 2022-07-07 08:55:54 +01:00
Hiers
db5e99b3f0 Fix in reader.html.ecr. 2022-07-05 22:24:31 +01:00
Hiers
405b958deb First draft of image fit. 2022-07-05 22:01:21 +01:00
Alex Ling
e7c4123dec Merge pull request #315 from Leeingnyo/fix/rescan-when-files-added
Fix Dir.contents_signature to detect valid image files
2022-07-03 15:59:49 +08:00
Alex Ling
2d2486a598 Merge branch 'dev' into fix/rescan-when-files-added 2022-07-03 15:44:02 +08:00
Alex Ling
b6a1ad889e Merge pull request #314 from crainte/feature/default-env-vars
Allow config defaults to be sourced from ENV
2022-07-03 15:39:47 +08:00
Alex Ling
f2d6d28a72 Define properties with macro 2022-07-03 07:24:33 +00:00
Alex Ling
49425ff714 Merge branch 'feature/default-env-vars' of https://github.com/crainte/Mango into feature/default-env-vars 2022-07-03 06:31:06 +00:00
Chris Alexander
f3eb62a271 Disable line length warnings 2022-06-27 09:30:04 -05:00
Chris Alexander
2e91028ead Allow config defaults to be sourced from ENV
This allows the default config to source values from ENV variables if
they are set. With this change we don't have to modify the docker CMD or
edit the config.yml and then relaunch.
2022-06-27 09:30:04 -05:00
Alex Ling
19a8f3100b Merge branch 'dev' into fix/rescan-when-files-added 2022-06-26 11:52:15 +08:00
Alex Ling
3b5e764d36 Merge pull request #312 from tr7zw/jxl-support
Add Jxl support
2022-06-18 19:43:30 +08:00
Alex Ling
32ce26a133 Merge branch 'dev' into jxl-support 2022-06-18 19:26:09 +08:00
Alex Ling
31df058f81 Comment about infinity average ratio 2022-06-18 11:25:20 +00:00
Alex Ling
fe440d82d4 Fix linter issue 2022-06-18 11:10:14 +00:00
Alex Ling
44636e051e Merge pull request #310 from torta/feature/greedy-continue-reading
Feature/greedy continue reading
2022-06-18 18:38:20 +08:00
Alex Ling
a639392ca0 Update comment 2022-06-18 10:22:25 +00:00
Leeingnyo
17a9c8ecd3 pass lint 2022-06-18 18:51:33 +09:00
Leeingnyo
bbc0c2cbb7 Fix Dir.contents_signature to detect valid image files added 2022-06-18 17:43:57 +09:00
Chris Alexander
be46dd1f86 Allow config defaults to be sourced from ENV
This allows the default config to source values from ENV variables if
they are set. With this change we don't have to modify the docker CMD or
edit the config.yml and then relaunch.
2022-06-15 12:03:40 -05:00
tr7zw
ae583cf2a9 Workaround for "0 width/height" api responses
This needs a more proper fix probably.
2022-06-07 16:09:02 +02:00
tr7zw
ea35faee91 Add jxl support 2022-06-07 00:28:41 +02:00
Alex Ling
5b58d8ac59 Clear page when switching plugins 2022-06-05 12:40:45 +00:00
Alex Ling
30d5ad0c19 Hide subscribe button when not subscribable 2022-06-05 12:33:26 +00:00
torta
d9dce4a881 Fix Continue Reading not show missed reading chapter if the latest chapter mark as read 2022-06-05 19:29:49 +08:00
Alex Ling
2d97faa7c0 Merge pull request #305 from Leeingnyo/feature/unzipped-entry
Support unzipped entry
2022-06-05 16:23:57 +08:00
Leeingnyo
9ce8e918f0 Replace to is_valid? 2022-06-04 00:26:46 +09:00
Leeingnyo
8e4bb995d3 Add zip_path to API document, add path property 2022-06-04 00:18:45 +09:00
Alex Ling
39a331c879 Avoid not_nil in date_added 2022-05-29 05:44:11 +00:00
Alex Ling
df618704ea Fix linter 2022-05-29 05:28:50 +00:00
Alex Ling
2fb620211d Choose correct subclass based on YAML node 2022-05-29 05:24:41 +00:00
Alex Ling
5b23a112b2 Remove unnecessary path method 2022-05-22 05:17:05 +00:00
Alex Ling
e6dbeb623b Use is_valid? 2022-05-22 05:12:43 +00:00
Alex Ling
872e6dc6d6 Better method naming in DirEntry 2022-05-22 04:20:14 +00:00
Alex Ling
82c60ccc1d Replace puts with Logger.debug 2022-05-22 04:04:40 +00:00
Alex Ling
ae503ae099 Remove unnecessary createtime method 2022-05-22 02:54:05 +00:00
Alex Ling
648cdd772c Add back zip_path for backward compatibility 2022-05-22 02:48:06 +00:00
Leeingnyo
238539c27d Split files 2022-05-20 14:21:08 +09:00
Leeingnyo
1f5aed64f7 Rename Entries to ArchiveEntry and DirEntry 2022-05-20 09:51:56 +09:00
Alex Ling
f18f6a5418 Fix linter issues 2022-05-19 12:41:07 +00:00
Leeingnyo
0ed565519b Rollback crystal format 2022-05-15 17:38:21 +09:00
Leeingnyo
3da5d9ba4e Fix contents_signature 2022-05-15 17:36:57 +09:00
Leeingnyo
3a60286c3a Run 'crystal tool format' 2022-05-15 17:02:29 +09:00
Leeingnyo
9f6be70995 Rename Entry.exists? to Entry.examine 2022-05-15 16:28:53 +09:00
Leeingnyo
caf4cfb6cd Fix Entry.new in YAML::Serializable to support DirectyEntry
so hacky
2022-05-15 16:12:43 +09:00
Leeingnyo
137e84dfb6 Fix caching policy
Before rendering it, the Mango reader should check the E-Tag of page
or it renders wrong image when an image file is moved/removed/reordered
2022-05-15 16:12:31 +09:00
Leeingnyo
3b3a0738e8 Scan DirectoryEntry when init and examine 2022-05-15 16:12:31 +09:00
Leeingnyo
55ccd928a2 Implement DirectoryEntry 2022-05-15 16:12:31 +09:00
Leeingnyo
10587f48cb Implement is_supported_image_file 2022-05-15 16:12:31 +09:00
Leeingnyo
ea6cbbd9ce Split Entry and ZippedEntry, Fix to work anyway
make Entry an abstract class
2022-05-15 05:41:25 +09:00
Alex Ling
883e01bbdd Merge pull request #302 from Leeingnyo/fix/preload-bug
Fix preload bug
2022-05-13 20:11:20 +08:00
Alex Ling
5f59b7ee42 Merge branch 'dev' into fix/preload-bug 2022-05-13 19:51:26 +08:00
Alex Ling
eac274a211 Merge pull request #301 from Leeingnyo/feature/show-control-at-end-in-paged-mode
Show control after reading all (in paged mode)
2022-05-13 19:47:57 +08:00
Leeingnyo
0e4169cb22 Fix preload bug
cause index error
2022-05-13 08:43:25 +09:00
Leeingnyo
28656695c6 Show control after reading at the end in paged mode 2022-05-13 08:35:04 +09:00
Alex Ling
61dc92838a Merge pull request #294 from hkalexling/rc/0.26.2
v0.26.2
2022-04-18 18:46:11 +08:00
Alex Ling
ce1dcff229 Bump version to 0.26.2 2022-04-18 09:41:36 +00:00
Alex Ling
4f599fb719 Add back accidentally deleted OPDS routes
Resolves https://github.com/hkalexling/Mango/issues/255#issuecomment-1097588181
2022-04-18 08:49:09 +00:00
Alex Ling
c831879c23 Merge pull request #293 from hkalexling/rc/0.26.1
v0.26.1
2022-04-04 22:11:24 +08:00
47 changed files with 2440 additions and 1863 deletions

View File

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

11
.eslintrc.js Normal file
View File

@@ -0,0 +1,11 @@
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',
},
};

6
.prettierrc Normal file
View File

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

View File

@@ -29,6 +29,7 @@ test:
check:
crystal tool format --check
./bin/ameba
yarn lint
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
```
Mango - Manga Server and Web Reader. Version 0.26.1
Mango - Manga Server and Web Reader. Version 0.27.0
Usage:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,82 +1,112 @@
const component = () => {
return {
available: undefined,
subscriptions: [],
return {
available: undefined,
subscriptions: [],
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert(
'danger',
'Failed to check MangaDex integration status. Error: ' +
data.error,
);
return;
}
this.available = Boolean(
data.expires && data.expires > Math.floor(Date.now() / 1000),
);
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done(data => {
if (data.error) {
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done((data) => {
if (data.error) {
alert(
'danger',
'Failed to get subscriptions. Error: ' + data.error,
);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json',
})
.done((data) => {
if (data.error) {
alert(
'danger',
`Failed to delete subscription. Error: ${data.error}`,
);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to check subscription. Error: ${data.error}`);
return;
}
alert('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}`);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json',
})
.done((data) => {
if (data.error) {
alert(
'danger',
`Failed to check subscription. Error: ${data.error}`,
);
return;
}
alert(
'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) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
}
};
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
},
};
};

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.26.1
version: 0.27.0
authors:
- Alex Ling <hkalexling@gmail.com>

View File

View File

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

View File

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

70
spec/plugin_spec.cr Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

132
src/library/dir_entry.cr Normal file
View File

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

View File

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

View File

@@ -49,13 +49,18 @@ class Title
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, cache
next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
end
if DirEntry.is_valid? path
entry = DirEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
next
end
if is_supported_file path
entry = Entry.new path, self
entry = ArchiveEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
end
@@ -127,12 +132,12 @@ class Title
previous_entries_size = @entries.size
@entries.select! do |entry|
existence = File.exists? entry.zip_path
existence = entry.examine
Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence
existence
end
remained_entry_zip_paths = @entries.map &.zip_path
remained_entry_paths = @entries.map &.path
is_titles_added = false
is_entries_added = false
@@ -140,29 +145,43 @@ class Title
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
unless remained_entry_paths.includes? path
if DirEntry.is_valid? path
entry = DirEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"]
next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
end
end
next
end
if is_supported_file path
next if remained_entry_zip_paths.includes? path
entry = Entry.new path, self
next if remained_entry_paths.includes? path
entry = ArchiveEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
@@ -613,6 +632,16 @@ class Title
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username
if last_read_entry.nil?
# The last entry is finished. Return the first unfinished entry
# (if any)
sorted_entries(username).each do |e|
unless e.finished? username
last_read_entry = e
break
end
end
end
end
last_read_entry
@@ -627,7 +656,7 @@ class Title
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.zip_path
da[e.title] = ctime e.path
end
TitleInfo.new @dir do |info|

View File

@@ -1,13 +1,3 @@
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
)
enum SortMethod
Auto
Title

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ struct APIRouter
Koa.schema "entry", {
"pages" => Int32,
"mtime" => Int64,
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
}.merge(s %w(zip_path path title size id title_id display_name cover_url)),
desc: "An entry in a book"
Koa.schema "title", {
@@ -142,8 +142,13 @@ struct APIRouter
env.response.status_code = 304
""
else
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = "public, max-age=86400"
env.response.headers["Cache-Control"] = cache_control
send_img env, img
end
rescue e
@@ -866,13 +871,15 @@ struct APIRouter
"version" => Int32,
"settings" => {} of String => String,
},
"subscribable" => Bool,
}
get "/api/admin/plugin/info" do |env|
begin
plugin = Plugin.new env.params.query["plugin"].as String
send_json env, {
"success" => true,
"info" => plugin.info,
"success" => true,
"info" => plugin.info,
"subscribable" => plugin.can_subscribe?,
}.to_json
rescue e
Logger.error e
@@ -1138,15 +1145,24 @@ struct APIRouter
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
if entry.is_a? DirEntry
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size)
else
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s)
end
e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag
env.response.status_code = 304
send_text env, ""
else
sizes = entry.page_dimensions
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = "public, max-age=86400"
env.response.headers["Cache-Control"] = cache_control
send_json env, {
"success" => true,
"dimensions" => sizes,
@@ -1172,7 +1188,7 @@ struct APIRouter
title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.zip_path
send_attachment env, entry.path
rescue e
Logger.error e
env.response.status_code = 404

View File

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

View File

@@ -23,6 +23,7 @@ class Server
AdminRouter.new
ReaderRouter.new
APIRouter.new
OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env|

View File

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

View File

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

View File

@@ -133,8 +133,10 @@
</template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
<span x-show="subscribable">
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
</form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>

View File

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

View File

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