Compare commits

..

60 Commits

Author SHA1 Message Date
Alex Ling
a6c2799521 Bump version to v0.12.3 2020-09-22 08:55:29 +00:00
Alex Ling
2370e4d2c6 Add browserstack as a sponsor 2020-09-22 08:54:20 +00:00
Alex Ling
32b0384ea0 Clearer gulpfile 2020-09-22 08:46:53 +00:00
Alex Ling
50d4ffdb7b Use babel and polyfill.io 2020-09-22 07:40:47 +00:00
Alex Ling
96463641f9 Update progress on last page (#105) 2020-09-21 04:35:23 +00:00
Alex Ling
ddbba5d596 Bump version to v0.12.2 2020-09-17 16:08:52 +00:00
Alex Ling
2a04f4531e Bound the page number in the reader route
fixes #104
2020-09-17 16:06:01 +00:00
Alex Ling
a5b6fb781f Bump version to v0.12.1 2020-09-17 13:32:00 +00:00
Alex Ling
8dfdab9d73 Respect the base URL in direct download link (#103) 2020-09-17 13:29:52 +00:00
Alex Ling
3a95270dfb Don't copy unused UIKit files 2020-09-17 13:25:35 +00:00
Alex Ling
2960ca54df Move fontawesome to NPM 2020-09-17 13:20:24 +00:00
Alex Ling
f5fe3c6b1c Use image_size.cr v0.2.0 2020-09-16 15:40:01 +00:00
Alex Ling
a612cc15fb Bump version to v0.12.0 2020-09-12 14:35:56 +00:00
Alex Ling
c9c0818069 Add inline documentation to reader.js 2020-09-12 14:32:29 +00:00
Alex Ling
2f8efc382f Clean up 2020-09-12 14:05:15 +00:00
Alex Ling
a0fb1880bd Update Dockerfile [skip ci] 2020-09-12 13:59:32 +00:00
Alex Ling
a408f14425 Add .dockerignore 2020-09-12 13:59:14 +00:00
Alex Ling
243b6c8927 Typo fix [skip ci] 2020-09-12 07:13:15 +00:00
Alex Ling
ff3a44d017 Update ARM dockerfiles to use image_size.cr 2020-09-12 07:03:30 +00:00
Alex Ling
67ef1f7112 DRY when listing archive entries 2020-09-12 06:58:03 +00:00
Alex Ling
5d7b8a1ef9 Skip error entries in OPDS feed 2020-09-12 06:57:42 +00:00
Alex Ling
a68f3eea95 Allow hyphens in username (#99) 2020-09-12 05:29:25 +00:00
Alex Ling
220fc42bf2 Add system dependencies for image_size.cr 2020-09-11 17:02:45 +00:00
Alex Ling
a45e6ea3da Rewrite web reader 2020-09-11 17:00:42 +00:00
Alex Ling
88394d4636 Expose page ratios through API 2020-09-11 17:00:28 +00:00
Alex Ling
ef1ab940f5 Fix GitHub tags of dependencies in the Dockerfiles
[skip ci]
2020-08-16 16:45:57 +00:00
Alex Ling
97a1c408d8 Bump version to v0.11.0 2020-08-16 12:44:02 +00:00
Alex Ling
abbf77df13 Merge branch 'master' into dev 2020-08-10 14:32:53 +00:00
Alex Ling
3b4021f680 Workflow retry hack
I got random "Invalid memory access" when running `crystal build`.
This is probably a compiler or LLVM bug.
We use this temporary hack to retry until they fix it.
2020-08-10 13:14:05 +00:00
Alex Ling
68b1923cb6 Clear title ID at the end of scans
This minimizes the chance of getting an unexpected empty home page
2020-08-10 11:45:50 +00:00
Alex Ling
3cdd4b29a5 Add back to top button to all pages (#95) 2020-08-10 11:42:23 +00:00
Alex Ling
af84c0f6de Fix typo 2020-08-08 17:04:42 +08:00
Alex Ling
85a65f84d0 Remove unnecessary "require" statements 2020-08-06 18:10:13 +00:00
Alex Ling
5027a911cd Respect the *_PROXY environment variables (#94) 2020-08-06 17:01:53 +00:00
Alex Ling
ac63bf7599 Add sponsors [skip ci] 2020-08-06 12:49:43 +08:00
Alex Ling
30b0e0b8fb Pin down mythml and duktape versions in Dockerfile
[skip ci]
2020-08-05 12:00:49 +00:00
Alex Ling
ddda058d8d Fix spec 2020-08-05 09:59:52 +00:00
Alex Ling
46db25e8e0 Fix wildcard in workflow 2020-08-05 09:50:46 +00:00
Alex Ling
c07f421322 Fix CLI tool not exiting 2020-08-05 09:48:31 +00:00
Alex Ling
99a77966ad Add arm64v8 to Makefile and rename object files 2020-08-05 09:48:03 +00:00
Alex Ling
d00b917575 Build the object file in Action 2020-08-04 17:24:36 +00:00
Alex Ling
4fd8334c37 Name the object file 2020-08-04 17:24:13 +00:00
Alex Ling
3aa4630558 Use Crystal 0.34.0 2020-08-04 17:23:19 +00:00
Alex Ling
cde5af7066 Remove interactive prompt for easier use in docker 2020-08-04 12:57:40 +00:00
Alex Ling
eb528e1726 Add the arm32v7 target to Makefile 2020-08-04 11:50:07 +00:00
Alex Ling
5e01cc38fe Fix downloaders 2020-08-04 11:36:36 +00:00
Alex Ling
9a787ccbc3 Formatting 2020-08-04 11:36:24 +00:00
Alex Ling
8a83c0df4e ARM support (#25, #78) 2020-08-04 11:00:33 +00:00
Alex Ling
87dea01917 Add ASCII banner, because we can :) 2020-08-02 17:52:52 +00:00
Alex Ling
586ee4f0ba Bump version to v0.10.0 2020-08-02 12:33:31 +00:00
Alex Ling
53f3387e1a Rephrase the plugin part in README 2020-08-02 12:32:14 +00:00
Alex Ling
be5d1918aa Add offset to the sticky bar 2020-08-02 12:29:49 +00:00
Alex Ling
df2cc0ffa9 Display nested titles and entries separately 2020-08-02 10:43:46 +00:00
Alex Ling
b8cfc3a201 Remove unnecessary ids from HTML 2020-08-02 10:43:24 +00:00
Alex Ling
8dc60ac2ea Add select all button to the selection bar 2020-08-02 09:28:31 +00:00
Alex Ling
1719335d02 Add "Start Reading" section to home page (#92) 2020-08-01 15:17:18 +00:00
Alex Ling
0cd46abc66 Finish batch marking (#75) 2020-07-30 11:39:23 +00:00
Alex Ling
e4fd7c58ee Add multi-select for cards in web interface 2020-07-30 08:32:00 +00:00
Alex Ling
d4abee52db Fix .uk-card-media-top width 2020-07-30 08:29:41 +00:00
Alex Ling
d29c94e898 Use Alpine.js 2020-07-30 08:28:54 +00:00
43 changed files with 1021 additions and 428 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
lib

View File

@@ -17,15 +17,24 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
- name: Build
run: make static
run: make static || make static
- name: Linter
run: make check
- name: Run tests
run: make test
- name: Upload artifact
- name: Upload binary
uses: actions/upload-artifact@v2
with:
name: mango
path: mango
- name: build arm32v7 object file
run: make arm32v7 || make arm32v7
- name: build arm64v8 object file
run: make arm64v8 || make arm64v8
- name: Upload object files
uses: actions/upload-artifact@v2
with:
name: object files
path: ./*.o

View File

@@ -3,9 +3,8 @@ FROM crystallang/crystal:0.34.0-alpine AS builder
WORKDIR /Mango
COPY . .
COPY package*.json .
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
&& make static
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
RUN make static || make static
FROM library/alpine

14
Dockerfile.arm32v7 Normal file
View File

@@ -0,0 +1,14 @@
FROM arm32v7/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm32v7.o .
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]

14
Dockerfile.arm64v8 Normal file
View File

@@ -0,0 +1,14 @@
FROM arm64v8/ubuntu:18.04
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd ..
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd ..
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd ..
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd ..
COPY mango-arm64v8.o .
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
CMD ["./mango"]

View File

@@ -12,10 +12,10 @@ setup: libs
yarn gulp dev
build: libs
crystal build src/mango.cr --release --progress
crystal build src/mango.cr --release --progress --error-trace
static: uglify | libs
crystal build src/mango.cr --release --progress --static
crystal build src/mango.cr --release --progress --static --error-trace
libs:
shards install --production
@@ -31,6 +31,12 @@ check:
./bin/ameba
./dev/linewidth.sh
arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
arm64v8:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
install:
cp mango $(INSTALL_DIR)/mango

View File

@@ -13,7 +13,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Supports nested folders in library
- Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader
- [Plugins](https://github.com/hkalexling/mango-plugins) support
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from thrid-party sites
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
- All the static files are embedded in the binary, so the deployment process is easy and painless
@@ -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.9.0
Mango - Manga Server and Web Reader. Version 0.12.3
Usage:
@@ -139,8 +139,13 @@ Mobile UI:
![mobile screenshot](./.github/screenshots/mobile.png)
## Sponsors
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
## Contributors
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/0)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/1)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/2)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/3)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/4)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/5)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/6)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[![](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/images/7)](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)

View File

@@ -1,15 +1,43 @@
const gulp = require('gulp');
const minify = require("gulp-babel-minify");
const babel = require('gulp-babel');
const minify = require('gulp-babel-minify');
const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
gulp.task('copy-uikit-js', () => {
return gulp.src('node_modules/uikit/dist/js/*.min.js')
// Copy libraries from node_moduels to public/js
gulp.task('copy-js', () => {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
'node_modules/uikit/dist/js/uikit.min.js',
'node_modules/uikit/dist/js/uikit-icons.min.js'
])
.pipe(gulp.dest('public/js'));
});
gulp.task('minify-js', () => {
return gulp.src('public/js/*.js')
// Copy UIKit SVG icons to public/img
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
// Compile less
gulp.task('less', () => {
return gulp.src('public/css/*.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
@@ -17,40 +45,26 @@ gulp.task('minify-js', () => {
.pipe(gulp.dest('dist/js'));
});
gulp.task('less', () => {
return gulp.src('public/css/*.less')
.pipe(less())
.pipe(gulp.dest('public/css'));
});
// Minify CSS and output to dist
gulp.task('minify-css', () => {
return gulp.src('public/css/*.css')
.pipe(minifyCss())
.pipe(gulp.dest('dist/css'));
});
gulp.task('copy-uikit-icons', () => {
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('img', () => {
return gulp.src('public/img/*')
.pipe(gulp.dest('dist/img'));
});
// Copy static files (includeing images) to dist
gulp.task('copy-files', () => {
return gulp.src('public/*.*')
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
base: 'public'
})
.pipe(gulp.dest('dist'));
});
gulp.task('default', gulp.parallel(
gulp.series('copy-uikit-js', 'minify-js'),
gulp.series('less', 'minify-css'),
gulp.series('copy-uikit-icons', 'img'),
'copy-files'
));
// Set up the public folder for development
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
gulp.task('dev', gulp.parallel(
'copy-uikit-js', 'less', 'copy-uikit-icons'
));
// Set up the dist folder for deployment
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
// Default task
gulp.task('default', gulp.series('dev', 'deploy'));

View File

@@ -1,21 +1,24 @@
{
"name": "mango",
"version": "1.0.0",
"main": "index.js",
"repository": "https://github.com/hkalexling/Mango.git",
"author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT",
"devDependencies": {
"gulp": "^4.0.2",
"gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4",
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"uikit": "^3.5.4"
}
"name": "mango",
"version": "1.0.0",
"main": "index.js",
"repository": "https://github.com/hkalexling/Mango.git",
"author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT",
"devDependencies": {
"@babel/preset-env": "^7.11.5",
"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"
},
"scripts": {
"uglify": "gulp"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "^3.5.4"
}
}

View File

@@ -7,6 +7,7 @@
}
.uk-card-media-top {
width: 100%;
height: 250px;
}
@@ -122,3 +123,32 @@ td>.uk-dropdown {
.uk-light .uk-description-list>dt {
color: #555;
}
[x-cloak] {
display: none;
}
#select-bar-controls a {
transform: scale(1.5, 1.5);
}
#select-bar-controls a:hover {
color: orange;
}
#main-section {
position: relative;
}
#totop-wrapper {
position: absolute;
top: 100vh;
right: 2em;
bottom: 0;
}
#totop-wrapper a {
position: fixed;
position: sticky;
top: calc(100vh - 5em);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,64 +1,102 @@
$(function() {
function bind() {
var controller = new ScrollMagic.Controller();
let lastSavedPage = page;
let items = [];
let longPages = false;
// replace history on scroll
$('img').each(function(idx) {
var scene = new ScrollMagic.Scene({
triggerElement: $(this).get(),
triggerHook: 'onEnter',
reverse: true
})
.addTo(controller)
.on('enter', function(event) {
current = $(event.target.triggerElement()).attr('id');
replaceHistory(current);
})
.on('leave', function(event) {
var prev = $(event.target.triggerElement()).prev();
current = $(prev).attr('id');
replaceHistory(current);
});
});
$(() => {
getPages();
// poor man's infinite scroll
var scene = new ScrollMagic.Scene({
triggerElement: $('.next-url').get(),
triggerHook: 'onEnter',
offset: -500
})
.addTo(controller)
.on('enter', function() {
var nextURL = $('.next-url').attr('href');
$('.next-url').remove();
if (!nextURL) {
console.log('No .next-url found. Reached end of page');
var lastURL = $('img').last().attr('id');
// load the reader URL for the last page to update reading progrss to 100%
$.get(lastURL);
$('#next-btn').removeAttr('hidden');
return;
}
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
if (status === 'error') console.log(xhr.statusText);
if (status === 'success') {
console.log(nextURL + ' loaded');
// new page loaded to #hidden, we now append it
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
$('#hidden').empty();
bind();
}
});
$('#page-select').change(() => {
const p = parseInt($('#page-select').val());
toPage(p);
});
});
/**
* Set an alpine.js property
*
* @function setProp
* @param {string} key - Key of the data property
* @param {*} prop - The data property
*/
const setProp = (key, prop) => {
$('#root').get(0).__x.$data[key] = prop;
};
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
});
}
bind();
});
$('#page-select').change(function() {
jumpTo(parseInt($('#page-select').val()));
});
const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
function showControl(idx) {
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
waitForPage(items.length, () => {
toPage(page);
setupScroller();
});
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
$(`#${idx}`).get(0).scrollIntoView(true);
UIkit.modal($('#modal-sections')).hide();
};
/**
* Check if a page exists every 100ms. If so, invoke the callback function.
*
* @function waitForPage
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {object} event - The onclick event that triggers the function
*/
const showControl = (event) => {
const idx = parseInt($(event.currentTarget).attr('id'));
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
@@ -66,19 +104,92 @@ function showControl(idx) {
UIkit.modal($('#modal-sections')).show();
}
function jumpTo(page) {
var ary = window.location.pathname.split('/');
ary[ary.length - 1] = page;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
window.location.replace(ary.join('/'));
}
function replaceHistory(url) {
history.replaceState(null, "", url);
console.log('reading ' + url);
}
function redirect(url) {
/**
* Redirect to a URL
*
* @function redirect
* @param {string} url - The target URL
*/
const redirect = (url) => {
window.location.replace(url);
}
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const 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('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
replaceHistory(current);
}
});
});
};
/**
* Update 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
*
* @function saveProgress
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
const saveProgress = (idx, cb) => {
idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 ||
longPages ||
idx === 1 || idx === items.length
) {
lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`;
$.post(url)
.then(data => {
if (data.error) throw new Error(data.error);
if (cb) cb();
})
.catch(e => {
console.error(e);
alert('danger', e);
});
}
};
/**
* Mark progress to 100% and redirect to the next entry
* Used as the onclick handler for the "Next Entry" button
*
* @function nextEntry
* @param {string} nextUrl - URL of the next entry
*/
const nextEntry = (nextUrl) => {
saveProgress(items.length, () => {
redirect(nextUrl);
});
};

File diff suppressed because one or more lines are too long

View File

@@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
$('#modal-download-btn').attr('href', `${base_url}opds/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show();
}
@@ -182,3 +182,63 @@ const setupUpload = (eid) => {
}
});
};
const deselectAll = () => {
$('.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;
};
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 bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk-progress/${action}/${tid}`;
$.ajax({
type: 'POST',
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();
});
};

View File

@@ -28,6 +28,14 @@ shards:
github: crystal-loot/exception_page
version: 0.1.4
http_proxy:
github: mamantoha/http_proxy
version: 0.7.1
image_size:
github: hkalexling/image_size.cr
version: 0.2.0
kemal:
github: kemalcr/kemal
version: 0.26.1

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.9.0
version: 0.12.3
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -32,3 +32,7 @@ dependencies:
version: ~> 0.20.0
myhtml:
github: kostya/myhtml
http_proxy:
github: mamantoha/http_proxy
image_size:
github: hkalexling/image_size.cr

View File

@@ -2,6 +2,7 @@ require "spec"
require "../src/queue"
require "../src/server"
require "../src/config"
require "../src/main_fiber"
class State
@@hash = {} of String => String

View File

@@ -52,12 +52,8 @@ class Config
config.fill_defaults
return config
end
puts "The config file #{cfg_path} does not exist." \
" Do you want mango to dump the default config there? [Y/n]"
input = gets
if input && input.downcase == "n"
abort "Aborting..."
end
puts "The config file #{cfg_path} does not exist. " \
"Dumping the default config there."
default = self.allocate
default.path = path
default.fill_defaults

View File

@@ -1,3 +1,5 @@
require "image_size"
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
@@ -77,11 +79,9 @@ class Entry
url
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
page = file.entries
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
@@ -89,7 +89,15 @@ class Entry
.sort { |a, b|
compare_numerically a.filename, b.filename
}
.[page_num - 1]
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
@@ -99,6 +107,26 @@ class Entry
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
Logger.warn "Failed to read page #{i} of entry #{@id}"
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

View File

@@ -30,6 +30,41 @@ class Library
@title_ids.map { |tid| self.get_title!(tid) }
end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
end
def deep_titles
titles + titles.map { |t| t.deep_titles }.flatten
end
@@ -57,7 +92,6 @@ class Library
"Attempting to create it"
Dir.mkdir_p @dir
end
@title_ids.clear
storage = Storage.new auto_close: false
@@ -68,6 +102,7 @@ class Library
.map { |path| Title.new path, "", storage, self }
.select { |title| !(title.entries.empty? && title.titles.empty?) }
.sort { |a, b| a.title <=> b.title }
.tap { |_| @title_ids.clear }
.each do |title|
@title_hash[title.id] = title
@title_ids << title.id
@@ -83,7 +118,7 @@ class Library
cr_entries = deep_titles
.map { |t| t.get_last_read_entry username }
# Select elements with type `Entry` from the array and ignore all `Nil`s
.select(Entry)[0..11]
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
.map { |e|
# Get the last read time of the entry. If it hasn't been started, get
# the last read time of the previous entry
@@ -143,41 +178,20 @@ class Library
end
end
recently_added[0..11]
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
end
def sorted_titles(username, opt : SortOptions? = nil)
if opt.nil?
opt = SortOptions.from_info_json @dir, username
else
TitleInfo.new @dir do |info|
info.sort_by[username] = opt.to_tuple
info.save
end
end
# This is a hack to bypass a compiler bug
ary = titles
case opt.not_nil!.method
when .time_modified?
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
compare_numerically a.title, b.title }
when .progress?
ary.sort! do |a, b|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
compare_numerically a.title, b.title
end
else
unless opt.method.auto?
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
"Auto instead"
end
ary.sort! { |a, b| compare_numerically a.title, b.title }
end
ary.reverse! unless opt.not_nil!.ascend
ary
def get_start_reading_titles(username)
# Here we are not using `deep_titles` as it may cause unexpected behaviors
# For example, consider the following nested titles:
# - One Puch Man
# - Vol. 1
# - Vol. 2
# If we use `deep_titles`, the start reading section might include `Vol. 2`
# when the user hasn't started `Vol. 1` yet
titles
.select { |t| t.load_percentage(username) == 0 }
.sample(ENTRIES_IN_HOME_SECTIONS)
.shuffle
end
end

View File

@@ -355,4 +355,24 @@ class Title
return zip if title_ids.empty?
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
end
def bulk_progress(action, ids : Array(String), username)
selected_entries = ids
.map { |id|
@entries.find { |e| e.id == id }
}
.select(Entry)
TitleInfo.new @dir do |info|
selected_entries.each do |e|
page = action == "read" ? e.pages : 0
if info.progress[username]?.nil?
info.progress[username] = {e.title => page}
else
info.progress[username][e.title] = page
end
end
info.save
end
end
end

34
src/main_fiber.cr Normal file
View File

@@ -0,0 +1,34 @@
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
# This is a temporary workaround that forces the relevant code to run in the
# main fiber
class MainFiber
@@channel = Channel(-> Nil).new
@@done = Channel(Bool).new
@@main_fiber = Fiber.current
def self.start_and_block
loop do
if proc = @@channel.receive
begin
proc.call
ensure
@@done.send true
end
end
Fiber.yield
end
end
def self.run(&block : -> Nil)
if @@main_fiber == Fiber.current
block.call
else
@@channel.send block
until @@done.receive
Fiber.yield
end
end
end
end

View File

@@ -1,4 +1,3 @@
require "http/client"
require "json"
require "csv"
require "../rename"

View File

@@ -27,14 +27,16 @@ module MangaDex
def pop : Queue::Job?
job = nil
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id not like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id not like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
rescue
end
end
job

View File

@@ -1,12 +1,29 @@
require "./config"
require "./queue"
require "./server"
require "./main_fiber"
require "./mangadex/*"
require "option_parser"
require "clim"
require "./plugin/*"
MANGO_VERSION = "0.9.0"
MANGO_VERSION = "0.12.3"
# From http://www.network-science.de/ascii/
BANNER = %{
_| _|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
_| _| _| _| _| _| _| _| _| _| _|
_| _| _| _| _| _| _| _| _| _|
_| _| _|_|_| _| _| _|_|_| _|_|
_|
_|_|
}
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
macro common_option
option "-c PATH", "--config=PATH", type: String,
@@ -22,20 +39,28 @@ end
class CLI < Clim
main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
desc DESCRIPTION
usage "mango [sub_command] [options]"
help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v"
common_option
run do |opts|
puts BANNER
puts DESCRIPTION
puts
# empty ARGV so it won't be passed to Kemal
ARGV.clear
Config.load(opts.config).set_current
MangaDex::Downloader.default
Plugin::Downloader.default
# empty ARGV so it won't be passed to Kemal
ARGV.clear
server = Server.new
server.start
spawn do
Server.new.start
end
MainFiber.start_and_block
end
sub "admin" do

View File

@@ -8,14 +8,16 @@ class Plugin
def pop : Queue::Job?
job = nil
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
MainFiber.run do
DB.open "sqlite3://#{@queue.path}" do |db|
begin
db.query_one "select * from queue where id like '%-%' " \
"and (status = 0 or status = 1) " \
"order by time limit 1" do |res|
job = Queue::Job.from_query_result res
end
rescue
end
rescue
end
end
job

View File

@@ -1,6 +1,5 @@
require "duktape/runtime"
require "myhtml"
require "http"
require "xml"
class Plugin

View File

@@ -119,22 +119,24 @@ class Queue
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
begin
db.exec "create table if not exists queue " \
"(id text, manga_id text, title text, manga_title " \
"text, status integer, status_message text, " \
"pages integer, success_count integer, " \
"fail_count integer, time integer)"
db.exec "create unique index if not exists id_idx " \
"on queue (id)"
db.exec "create index if not exists manga_id_idx " \
"on queue (manga_id)"
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
Logger.error "Error when checking tables in DB: #{e}"
raise e
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
begin
db.exec "create table if not exists queue " \
"(id text, manga_id text, title text, manga_title " \
"text, status integer, status_message text, " \
"pages integer, success_count integer, " \
"fail_count integer, time integer)"
db.exec "create unique index if not exists id_idx " \
"on queue (id)"
db.exec "create index if not exists manga_id_idx " \
"on queue (manga_id)"
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
Logger.error "Error when checking tables in DB: #{e}"
raise e
end
end
end
end
@@ -143,23 +145,27 @@ class Queue
# inserted. Any job already exists in the queue will be ignored.
def push(jobs : Array(Job))
start_count = self.count
DB.open "sqlite3://#{@path}" do |db|
jobs.each do |job|
db.exec "insert or ignore into queue values " \
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
job.id, job.manga_id, job.title, job.manga_title,
job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
jobs.each do |job|
db.exec "insert or ignore into queue values " \
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
job.id, job.manga_id, job.title, job.manga_title,
job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
end
end
end
self.count - start_count
end
def reset(id : String)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
end
end
end
@@ -169,16 +175,20 @@ class Queue
# Reset all failed tasks (missing pages and error)
def reset
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end
end
def delete(id : String)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where id = (?)", id
end
end
end
@@ -187,71 +197,89 @@ class Queue
end
def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from queue where status = (?)", status.to_i
end
end
end
def count_status(status : JobStatus)
num = 0
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue where " \
"status = (?)", status.to_i, as: Int32
end
end
num
end
def count
num = 0
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
num = db.query_one "select count(*) from queue", as: Int32
end
end
num
end
def set_status(status : JobStatus, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status = (?) where id = (?)",
status.to_i, job.id
end
end
end
def get_all
jobs = [] of Job
DB.open "sqlite3://#{@path}" do |db|
jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
jobs = db.query_all "select * from queue order by time" do |rs|
Job.from_query_result rs
end
end
end
jobs
end
def add_success(job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id
end
end
end
def add_fail(job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \
"where id = (?)", job.id
end
end
end
def set_pages(pages : Int32, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set pages = (?), success_count = 0, " \
"fail_count = 0 where id = (?)", pages, job.id
end
end
end
def add_message(msg : String, job : Job)
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
end
end

View File

@@ -97,6 +97,28 @@ class APIRouter < Router
end
end
post "/api/bulk-progress/:action/:title" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
action = env.params.url["action"]
ids = env.params.json["ids"].as(Array).map &.as_s
unless action.in? ["read", "unread"]
raise "Unknow action #{action}"
end
title.bulk_progress action, ids, username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/admin/display_name/:title/:name" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
@@ -310,5 +332,28 @@ class APIRouter < Router
}.to_json
end
end
get "/api/dimensions/:tid/:eid" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
sizes = entry.page_dimensions
send_json env, {
"success" => true,
"dimensions" => sizes,
}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
end
end

View File

@@ -103,6 +103,7 @@ class MainRouter < Router
continue_reading = @context
.library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username
start_reading = @context.library.get_start_reading_titles username
titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0

View File

@@ -12,11 +12,7 @@ class ReaderRouter < Router
next layout "reader-error" if entry.err_msg
# load progress
page = entry.load_progress username
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
page = [1, entry.load_progress username].max
# start from page 1 if the user has finished reading the entry
page = 1 if entry.finished? username
@@ -32,29 +28,17 @@ class ReaderRouter < Router
begin
base_url = Config.current.base_url
username = get_username env
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
entry.save_progress username, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
}
reader_urls = pages.map { |idx|
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
}
next_page = page + IMGS_PER_PAGE
next_url = next_entry_url = nil
exit_url = "#{base_url}book/#{title.id}"
next_entry_url = nil
next_entry = entry.next_entry username
unless next_page > entry.pages
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil?
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
end

View File

@@ -32,38 +32,40 @@ class Storage
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
begin
# We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
MainFiber.run do
DB.open "sqlite3://#{@path}" do |db|
begin
# We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message.not_nil!.ends_with? "already exists"
Logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
init_admin if init_user
end
# If the DB is initialized through CLI but no user is added, we need
# to create the admin user when first starting the app
user_count = db.query_one "select count(*) from users", as: Int32
init_admin if init_user && user_count == 0
else
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
init_admin if init_user
end
end
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
unless @auto_close
@db = DB.open "sqlite3://#{@path}"
end
end
end
@@ -87,37 +89,45 @@ class Storage
end
def verify_user(username, password)
get_db do |db|
begin
hash, token = db.query_one "select password, token from " \
"users where username = (?)",
username, as: {String, String?}
unless verify_password hash, password
Logger.debug "Password does not match the hash"
return nil
out_token = nil
MainFiber.run do
get_db do |db|
begin
hash, token = db.query_one "select password, token from " \
"users where username = (?)",
username, as: {String, String?}
unless verify_password hash, password
Logger.debug "Password does not match the hash"
next
end
Logger.debug "User #{username} verified"
if token
out_token = token
next
end
token = random_str
Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)",
token, username
out_token = token
rescue e
Logger.error "Error when verifying user #{username}: #{e}"
end
Logger.debug "User #{username} verified"
return token if token
token = random_str
Logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)",
token, username
return token
rescue e
Logger.error "Error when verifying user #{username}: #{e}"
return nil
end
end
out_token
end
def verify_token(token)
username = nil
get_db do |db|
begin
username = db.query_one "select username from users where " \
"token = (?)", token, as: String
rescue e
Logger.debug "Unable to verify token"
MainFiber.run do
get_db do |db|
begin
username = db.query_one "select username from users where " \
"token = (?)", token, as: String
rescue e
Logger.debug "Unable to verify token"
end
end
end
username
@@ -125,12 +135,14 @@ class Storage
def verify_admin(token)
is_admin = false
get_db do |db|
begin
is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool
rescue e
Logger.debug "Unable to verify user as admin"
MainFiber.run do
get_db do |db|
begin
is_admin = db.query_one "select admin from users where " \
"token = (?)", token, as: Bool
rescue e
Logger.debug "Unable to verify user as admin"
end
end
end
is_admin
@@ -138,10 +150,12 @@ class Storage
def list_users
results = Array(Tuple(String, Bool)).new
get_db do |db|
db.query "select username, admin from users" do |rs|
rs.each do
results << {rs.read(String), rs.read(Bool)}
MainFiber.run do
get_db do |db|
db.query "select username, admin from users" do |rs|
rs.each do
results << {rs.read(String), rs.read(Bool)}
end
end
end
end
@@ -152,10 +166,12 @@ class Storage
validate_username username
validate_password password
admin = (admin ? 1 : 0)
get_db do |db|
hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
MainFiber.run do
get_db do |db|
hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin
end
end
end
@@ -163,40 +179,48 @@ class Storage
admin = (admin ? 1 : 0)
validate_username username
validate_password password unless password.empty?
get_db do |db|
if password.empty?
db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",
username, admin, original_username
else
hash = hash_password password
db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",
username, admin, hash, original_username
MainFiber.run do
get_db do |db|
if password.empty?
db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",
username, admin, original_username
else
hash = hash_password password
db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",
username, admin, hash, original_username
end
end
end
end
def delete_user(username)
get_db do |db|
db.exec "delete from users where username = (?)", username
MainFiber.run do
get_db do |db|
db.exec "delete from users where username = (?)", username
end
end
end
def logout(token)
get_db do |db|
begin
db.exec "update users set token = (?) where token = (?)", nil, token
rescue
MainFiber.run do
get_db do |db|
begin
db.exec "update users set token = (?) where token = (?)", nil, token
rescue
end
end
end
end
def get_id(path, is_title)
id = nil
get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
MainFiber.run do
get_db do |db|
id = db.query_one? "select id from ids where path = (?)", path,
as: {String}
end
end
id
end
@@ -206,20 +230,24 @@ class Storage
end
def bulk_insert_ids
get_db do |db|
db.transaction do |tx|
@insert_ids.each do |tp|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
MainFiber.run do
get_db do |db|
db.transaction do |tx|
@insert_ids.each do |tp|
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
tp[:id], tp[:is_title] ? 1 : 0
end
end
end
@insert_ids.clear
end
@insert_ids.clear
end
def close
unless @db.nil?
@db.not_nil!.close
MainFiber.run do
unless @db.nil?
@db.not_nil!.close
end
end
end

42
src/util/proxy.cr Normal file
View File

@@ -0,0 +1,42 @@
require "http_proxy"
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
# environment variables
module HTTP
class Client
private def self.exec(uri : URI, tls : TLSContext = nil)
Logger.debug "Using monkey-patched HTTP::Client"
previous_def uri, tls do |client, path|
client.set_proxy get_proxy uri
yield client, path
end
end
end
end
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
return if no_proxy &&
no_proxy.split(",").any? &.== uri.hostname
case uri.scheme
when "http"
env_to_proxy "http_proxy"
when "https"
env_to_proxy "https_proxy"
else
nil
end
end
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
val = ENV[key.downcase]? || ENV[key.upcase]?
return if val.nil?
begin
uri = URI.parse val
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!
rescue
nil
end
end

View File

@@ -1,6 +1,7 @@
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
def random_str
UUID.random.to_s.gsub "-", ""

View File

@@ -2,9 +2,9 @@ def validate_username(username)
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters " \
"and underscores only"
if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
raise "Username can only contain alphanumeric characters, " \
"underscores, and hyphens"
end
end

View File

@@ -35,12 +35,20 @@
onclick="location='<%= base_url %>book/<%= item.id %>'"
<% end %>>
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img
<div class="uk-card uk-card-default" x-data="{selected: false, hover: false, disabled: true}" :class="{selected: selected}"
<% if page == "title" && item.is_a?(Entry) && item.err_msg.nil? %>
x-init="disabled = false"
<% end %>>
<div class="uk-card-media-top uk-inline" @mouseenter="hover = true" @mouseleave="hover = false">
<img data-src="<%= item.cover_url %>" width="100%" height="100%" alt="" uk-img
<% if item.is_a? Entry && item.err_msg %>
class="grayscale"
<% end %>>
<div class="uk-overlay-primary uk-position-cover" x-show="!disabled && (selected || hover)">
<div class="uk-position-center">
<span class="fas fa-check-circle fa-3x" @click.stop="selected = !selected; $dispatch(selected ? 'add' : 'remove')" :style="`color:${selected && 'orange'};`"></span>
</div>
</div>
</div>
<div class="uk-card-body">

View File

@@ -7,8 +7,12 @@
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine.min.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.5.0/dist/alpine-ie11.min.js" defer></script>
<script src="<%= base_url %>js/theme.js"></script>
</head>

View File

@@ -41,7 +41,7 @@
<%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- continue_reading.each do |cr| -%>
<% item = cr[:entry] %>
<% progress = cr[:percentage] %>
@@ -50,9 +50,20 @@
</div>
<%- end -%>
<%- unless start_reading.empty? -%>
<h2 class="uk-title home-headings">Start Reading</h2>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- start_reading.each do |t| -%>
<% item = t %>
<% progress = 0.0 %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%- unless recently_added.empty? -%>
<h2 class="uk-title home-headings">Recently Added</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- recently_added.each do |ra| -%>
<% item = ra %>
<% progress = ra[:percentage] %>

View File

@@ -67,10 +67,13 @@
</div>
<div class="uk-section uk-section-small">
</div>
<div class="uk-section uk-section-small">
<div class="uk-section uk-section-small" id="main-section">
<div class="uk-container uk-container-small">
<div id="alert"></div>
<%= content %>
<div class="uk-visible@m" id="totop-wrapper" x-data="{}" x-show="$('body').height() > 1.5 * $(window).height()">
<a href="#" uk-totop uk-scroll></a>
</div>
</div>
</div>
<script>

View File

@@ -16,7 +16,7 @@
<%= render_component "sort-form" %>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>

View File

@@ -21,18 +21,18 @@
<% end %>
<% title.entries.each do |e| %>
<entry>
<title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id>
<% next if e.err_msg %>
<entry>
<title><%= HTML.escape(e.display_name) %></title>
<id>urn:mango:<%= e.id %></id>
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image" 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 %>opds/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 %>opds/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />
</entry>
<% end %>
</feed>
<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 %>" />
</entry>
<% end %>
</feed>

View File

@@ -6,22 +6,40 @@
<body>
<div class="uk-section uk-section-default uk-section-small reader-bg">
<div class="uk-container uk-container-small">
<%- urls.each_with_index do |url, i| -%>
<img class="uk-align-center" data-src="<%= url %>" src="<%= base_url %>img/loading.gif" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
<%- end -%>
<%- if next_url -%>
<a class="next-url" href="<%= next_url %>"></a>
<%- end -%>
<div id="alert"></div>
<div id="root" x-data="{
loading: true,
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: []
}">
<div x-show="loading">
<div :class="alertClass" x-show="msg" uk-alert>
<p x-text="msg"></p>
</div>
</div>
<div x-show="!loading" x-cloak>
<template x-for="item in items">
<img
uk-img
class="uk-align-center"
:data-src="item.url"
:width="item.width"
:height="item.height"
:id="item.id"
@click="showControl($event)"
/>
</template>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="nextEntry('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" @click="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
</div>
</div>
<%- if next_entry_url -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
<%- else -%>
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
<%- end -%>
</div>
<div id="hidden" hidden></div>
<div id="modal-sections" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
@@ -49,14 +67,23 @@
</div>
</div>
</div>
<script>
const base_url = "<%= base_url %>"
const base_url = "<%= base_url %>";
const page = <%= page %>;
const tid = "<%= title.id %>";
const eid = "<%= entry.id %>";
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/uikit.min.js"></script>
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script>
</body>
<style>
img[data-src][src*='data:image'] { background: white; }
#root img { width: 100%; }
</style>
</html>

View File

@@ -1,4 +1,23 @@
<div>
<div id="select-bar" class="uk-card uk-card-body uk-card-default uk-margin-bottom" uk-sticky="offset:10" x-data="{count: 0}" @add.window="count++" @remove.window="count--" x-show="count > 0" style="border:orange;border-style:solid;" x-cloak data-id="<%= title.id %>">
<div class="uk-child-width-1-3" uk-grid>
<div>
<p x-text="count + ' items selected'" style="color:orange"></p>
</div>
<div class="uk-text-center" id="select-bar-controls">
<a class="uk-icon uk-margin-right" uk-tooltip="title: Mark selected as read" href="" @click.prevent="bulkProgress('read', $el)">
<i class="fas fa-check-circle"></i>
</a>
<a class="uk-icon" uk-tooltip="title: Mark selected as unread" href="" @click.prevent="bulkProgress('unread', $el)">
<i class="fas fa-times-circle"></i>
</a>
</div>
<div class="uk-text-right">
<a @click="selectAll()" uk-tooltip="title: Select all"><i class="fas fa-check-double uk-margin-small-right"></i></a>
<a @click="deselectAll();" uk-tooltip="title: Deselect all"><i class="fas fa-times"></i></a>
</div>
</div>
</div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
@@ -32,11 +51,14 @@
<%= render_component "sort-form" %>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% title.titles.each_with_index do |item, i| %>
<% progress = title_percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<div class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>